mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-06-05 13:02:57 +00:00
update
This commit is contained in:
+1
-1
@@ -16,7 +16,7 @@ CORS_ALLOWED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
|
|||||||
|
|
||||||
# Traefik Authentication (Basic Auth for dashboard)
|
# Traefik Authentication (Basic Auth for dashboard)
|
||||||
# Generate with: htpasswd -nb username password
|
# Generate with: htpasswd -nb username password
|
||||||
TRAEFIK_AUTH=admin:$apr1$b8mh8c8v$KkR8hQZQZQZQZQZQZQZQZ/
|
TRAEFIK_AUTH=admin:$$apr1$$b8mh8c8v$$KkR8hQZQZQZQZQZQZQZQZ/
|
||||||
|
|
||||||
# Optional: Cloudflare Tunnel (alternative to domain)
|
# Optional: Cloudflare Tunnel (alternative to domain)
|
||||||
# Get token from: https://dash.cloudflare.com/argotunnel
|
# Get token from: https://dash.cloudflare.com/argotunnel
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ JWT_SECRET=your_very_secure_jwt_secret_key_here
|
|||||||
CORS_ALLOWED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
|
CORS_ALLOWED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
|
||||||
|
|
||||||
# Traefik Authentication (Basic Auth for dashboard)
|
# Traefik Authentication (Basic Auth for dashboard)
|
||||||
TRAEFIK_AUTH=admin:$apr1$b8mh8c8v$KkR8hQZQZQZQZQZQZQZQZ/
|
TRAEFIK_AUTH=admin:$$apr1$$b8mh8c8v$$KkR8hQZQZQZQZQZQZQZQZ/
|
||||||
|
|
||||||
# Cloudflare Tunnel (alternative to domain)
|
# Cloudflare Tunnel (alternative to domain)
|
||||||
CLOUDFLARED_TOKEN=your_cloudflare_tunnel_token_here
|
CLOUDFLARED_TOKEN=your_cloudflare_tunnel_token_here
|
||||||
|
|||||||
+1
-1
@@ -151,7 +151,7 @@ services:
|
|||||||
cloudflared:
|
cloudflared:
|
||||||
image: cloudflare/cloudflared:latest
|
image: cloudflare/cloudflared:latest
|
||||||
container_name: containr-cloudflared
|
container_name: containr-cloudflared
|
||||||
command: tunnel --no-autoupdate run --token ${CLOUDFLARED_TOKEN}
|
command: tunnel --no-autoupdate run --token ${CLOUDFLARED_TOKEN:-}
|
||||||
networks:
|
networks:
|
||||||
- containr-network
|
- containr-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ type BuildHandler struct {
|
|||||||
dockerClient *docker.Client
|
dockerClient *docker.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *BuildHandler) buildUnavailable(c *gin.Context) bool {
|
||||||
|
if h.buildManager != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"error": "Build service is unavailable: Docker client not initialized",
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// NewBuildHandler creates a new build handler
|
// NewBuildHandler creates a new build handler
|
||||||
func NewBuildHandler(buildManager *build.BuildManager, dockerClient *docker.Client) *BuildHandler {
|
func NewBuildHandler(buildManager *build.BuildManager, dockerClient *docker.Client) *BuildHandler {
|
||||||
return &BuildHandler{
|
return &BuildHandler{
|
||||||
@@ -96,6 +107,10 @@ type BuildListResponse struct {
|
|||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
// @Router /api/v1/builds [post]
|
// @Router /api/v1/builds [post]
|
||||||
func (h *BuildHandler) StartBuild(c *gin.Context) {
|
func (h *BuildHandler) StartBuild(c *gin.Context) {
|
||||||
|
if h.buildUnavailable(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req BuildRequest
|
var req BuildRequest
|
||||||
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()})
|
||||||
@@ -286,6 +301,10 @@ func (h *BuildHandler) GetBuildLogs(c *gin.Context) {
|
|||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
// @Router /api/v1/builds/plan [post]
|
// @Router /api/v1/builds/plan [post]
|
||||||
func (h *BuildHandler) GetBuildPlan(c *gin.Context) {
|
func (h *BuildHandler) GetBuildPlan(c *gin.Context) {
|
||||||
|
if h.buildUnavailable(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req BuildRequest
|
var req BuildRequest
|
||||||
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()})
|
||||||
@@ -327,6 +346,10 @@ func (h *BuildHandler) GetBuildPlan(c *gin.Context) {
|
|||||||
// @Failure 500 {object} map[string]string
|
// @Failure 500 {object} map[string]string
|
||||||
// @Router /api/v1/builds/detect [get]
|
// @Router /api/v1/builds/detect [get]
|
||||||
func (h *BuildHandler) DetectBuildType(c *gin.Context) {
|
func (h *BuildHandler) DetectBuildType(c *gin.Context) {
|
||||||
|
if h.buildUnavailable(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
sourcePath := c.Query("source_path")
|
sourcePath := c.Query("source_path")
|
||||||
if sourcePath == "" {
|
if sourcePath == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "source_path is required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "source_path is required"})
|
||||||
|
|||||||
+203
-51
@@ -4,8 +4,8 @@ import (
|
|||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/deployment"
|
"containr/internal/deployment"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -139,6 +139,10 @@ func handleCreateDeployment(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.Trigger == "" {
|
||||||
|
req.Trigger = "manual"
|
||||||
|
}
|
||||||
|
|
||||||
userID, exists := c.Get("user_id")
|
userID, exists := c.Get("user_id")
|
||||||
if !exists {
|
if !exists {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||||
@@ -172,11 +176,20 @@ func handleCreateDeployment(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.Branch == "" {
|
||||||
|
req.Branch = service.GitBranch
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
var commitHash *string
|
||||||
|
if trimmed := strings.TrimSpace(req.CommitHash); trimmed != "" {
|
||||||
|
commitHash = &trimmed
|
||||||
|
}
|
||||||
|
|
||||||
d := DeploymentModel{
|
d := DeploymentModel{
|
||||||
ID: uuid.New(),
|
ID: uuid.New(),
|
||||||
ServiceID: serviceID,
|
ServiceID: serviceID,
|
||||||
CommitHash: &req.CommitHash,
|
CommitHash: commitHash,
|
||||||
Status: "pending",
|
Status: "pending",
|
||||||
ImageName: "",
|
ImageName: "",
|
||||||
ImageTag: "",
|
ImageTag: "",
|
||||||
@@ -184,10 +197,6 @@ func handleCreateDeployment(c *gin.Context) {
|
|||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.CommitHash != "" {
|
|
||||||
d.CommitHash = &req.CommitHash
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = db.(*database.DB).Exec(
|
_, err = db.(*database.DB).Exec(
|
||||||
`INSERT INTO deployments
|
`INSERT INTO deployments
|
||||||
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
(id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at)
|
||||||
@@ -200,59 +209,202 @@ func handleCreateDeployment(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = db.(*database.DB).Exec(
|
|
||||||
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
|
|
||||||
time.Now(), serviceID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
}
|
|
||||||
|
|
||||||
engine, exists := c.Get("deployment_engine")
|
engine, exists := c.Get("deployment_engine")
|
||||||
if exists && engine != nil {
|
if !exists || engine == nil {
|
||||||
go func() {
|
unavailableErr := "Deployment engine unavailable. Docker may not be configured on this server."
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
completedAt := time.Now()
|
||||||
defer cancel()
|
_, _ = db.(*database.DB).Exec(
|
||||||
|
`UPDATE deployments
|
||||||
|
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
|
||||||
|
WHERE id = $3`,
|
||||||
|
unavailableErr, completedAt, d.ID,
|
||||||
|
)
|
||||||
|
d.Status = "failed"
|
||||||
|
d.Error = &unavailableErr
|
||||||
|
d.CompletedAt = &completedAt
|
||||||
|
} else {
|
||||||
|
_, err = db.(*database.DB).Exec(
|
||||||
|
`UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`,
|
||||||
|
time.Now(), serviceID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update service status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
envVarsJSON, _ := json.Marshal(req.EnvVars)
|
engineInstance := engine.(*deployment.DeploymentEngine)
|
||||||
_ = envVarsJSON
|
go runDeploymentAndSync(context.Background(), db.(*database.DB), engineInstance, &d, service, req, userID.(string))
|
||||||
|
|
||||||
deployReq := &deployment.DeploymentRequest{
|
|
||||||
ProjectID: service.ProjectID.String(),
|
|
||||||
ServiceID: serviceID.String(),
|
|
||||||
Environment: service.Environment,
|
|
||||||
Config: deployment.ServiceConfig{
|
|
||||||
Name: service.Name,
|
|
||||||
Image: service.Image,
|
|
||||||
Environment: req.EnvVars,
|
|
||||||
Replicas: 1,
|
|
||||||
},
|
|
||||||
BuildConfig: &deployment.BuildConfig{
|
|
||||||
BuildType: "nixpacks",
|
|
||||||
SourcePath: service.BuildPath,
|
|
||||||
Branch: service.GitBranch,
|
|
||||||
Commit: req.CommitHash,
|
|
||||||
},
|
|
||||||
Trigger: deployment.TriggerConfig{
|
|
||||||
Type: req.Trigger,
|
|
||||||
Source: "api",
|
|
||||||
User: userID.(string),
|
|
||||||
Timestamp: now,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = engine.(*deployment.DeploymentEngine).Deploy(ctx, deployReq)
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusCreated, DeploymentResponse{
|
c.JSON(http.StatusCreated, DeploymentResponse{
|
||||||
ID: d.ID,
|
ID: d.ID,
|
||||||
ServiceID: d.ServiceID,
|
ServiceID: d.ServiceID,
|
||||||
CommitHash: d.CommitHash,
|
CommitHash: d.CommitHash,
|
||||||
Status: d.Status,
|
Status: d.Status,
|
||||||
CreatedAt: d.CreatedAt,
|
Error: d.Error,
|
||||||
|
CompletedAt: d.CompletedAt,
|
||||||
|
CreatedAt: d.CreatedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runDeploymentAndSync(
|
||||||
|
parentCtx context.Context,
|
||||||
|
db *database.DB,
|
||||||
|
engine *deployment.DeploymentEngine,
|
||||||
|
dbDeployment *DeploymentModel,
|
||||||
|
service Service,
|
||||||
|
req CreateDeploymentRequest,
|
||||||
|
userID string,
|
||||||
|
) {
|
||||||
|
ctx, cancel := context.WithTimeout(parentCtx, 30*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
sourcePath := strings.TrimSpace(service.BuildPath)
|
||||||
|
if sourcePath == "" {
|
||||||
|
sourcePath = "."
|
||||||
|
}
|
||||||
|
|
||||||
|
deployReq := &deployment.DeploymentRequest{
|
||||||
|
ProjectID: service.ProjectID.String(),
|
||||||
|
ServiceID: service.ID.String(),
|
||||||
|
Environment: service.Environment,
|
||||||
|
Config: deployment.ServiceConfig{
|
||||||
|
Name: service.Name,
|
||||||
|
Image: service.Image,
|
||||||
|
Environment: req.EnvVars,
|
||||||
|
Replicas: 1,
|
||||||
|
},
|
||||||
|
BuildConfig: &deployment.BuildConfig{
|
||||||
|
BuildType: "nixpacks",
|
||||||
|
SourcePath: sourcePath,
|
||||||
|
Branch: req.Branch,
|
||||||
|
Commit: req.CommitHash,
|
||||||
|
},
|
||||||
|
Trigger: deployment.TriggerConfig{
|
||||||
|
Type: req.Trigger,
|
||||||
|
Source: "api",
|
||||||
|
User: userID,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
engineDeployment, err := engine.Deploy(ctx, deployReq)
|
||||||
|
if err != nil {
|
||||||
|
failedAt := time.Now()
|
||||||
|
failure := "Failed to start deployment engine: " + err.Error()
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE deployments
|
||||||
|
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
|
||||||
|
WHERE id = $3`,
|
||||||
|
failure, failedAt, dbDeployment.ID,
|
||||||
|
)
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
||||||
|
failedAt, service.ID,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncTicker := time.NewTicker(1 * time.Second)
|
||||||
|
defer syncTicker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
failedAt := time.Now()
|
||||||
|
timeoutErr := "Deployment timed out before completion"
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE deployments
|
||||||
|
SET status = 'failed', error = $1, completed_at = $2, updated_at = $2
|
||||||
|
WHERE id = $3`,
|
||||||
|
timeoutErr, failedAt, dbDeployment.ID,
|
||||||
|
)
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
||||||
|
failedAt, service.ID,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
case <-syncTicker.C:
|
||||||
|
current, getErr := engine.GetDeployment(engineDeployment.ID)
|
||||||
|
if getErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
dbStatus := mapEngineStatusToDBStatus(current.Status)
|
||||||
|
imageName, imageTag := splitImageReference(current.ImageName, dbDeployment.ImageTag)
|
||||||
|
|
||||||
|
var dbError interface{}
|
||||||
|
if current.Error != "" {
|
||||||
|
dbError = current.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE deployments
|
||||||
|
SET status = $1,
|
||||||
|
image_name = $2,
|
||||||
|
image_tag = $3,
|
||||||
|
build_log = $4,
|
||||||
|
runtime_log = $5,
|
||||||
|
error = $6,
|
||||||
|
started_at = $7,
|
||||||
|
completed_at = $8,
|
||||||
|
updated_at = $9
|
||||||
|
WHERE id = $10`,
|
||||||
|
dbStatus,
|
||||||
|
imageName,
|
||||||
|
imageTag,
|
||||||
|
current.BuildLog,
|
||||||
|
current.DeployLog,
|
||||||
|
dbError,
|
||||||
|
current.StartedAt,
|
||||||
|
current.CompletedAt,
|
||||||
|
time.Now(),
|
||||||
|
dbDeployment.ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
switch dbStatus {
|
||||||
|
case "deployed":
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`,
|
||||||
|
time.Now(), service.ID,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
case "failed":
|
||||||
|
_, _ = db.Exec(
|
||||||
|
`UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`,
|
||||||
|
time.Now(), service.ID,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapEngineStatusToDBStatus(status string) string {
|
||||||
|
switch status {
|
||||||
|
case "running":
|
||||||
|
return "deployed"
|
||||||
|
case "cancelled":
|
||||||
|
return "failed"
|
||||||
|
default:
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitImageReference(image, fallbackTag string) (string, string) {
|
||||||
|
if image == "" {
|
||||||
|
return "", fallbackTag
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSlash := strings.LastIndex(image, "/")
|
||||||
|
lastColon := strings.LastIndex(image, ":")
|
||||||
|
if lastColon > lastSlash && !strings.Contains(image[lastColon:], "@") {
|
||||||
|
return image[:lastColon], image[lastColon+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return image, fallbackTag
|
||||||
|
}
|
||||||
|
|
||||||
func handleGetDeployment(c *gin.Context) {
|
func handleGetDeployment(c *gin.Context) {
|
||||||
db, exists := c.Get("db")
|
db, exists := c.Get("db")
|
||||||
if !exists {
|
if !exists {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
// firstPathParam returns the first non-empty route param from the provided names.
|
||||||
|
func firstPathParam(c *gin.Context, names ...string) string {
|
||||||
|
for _, name := range names {
|
||||||
|
if value := c.Param(name); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -32,7 +33,7 @@ type PreviewEnvironment struct {
|
|||||||
|
|
||||||
// CreatePreviewEnvironmentRequest represents a request to create a preview environment
|
// CreatePreviewEnvironmentRequest represents a request to create a preview environment
|
||||||
type CreatePreviewEnvironmentRequest struct {
|
type CreatePreviewEnvironmentRequest struct {
|
||||||
ProjectID uuid.UUID `json:"project_id" binding:"required"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
ServiceID uuid.UUID `json:"service_id" binding:"required"`
|
ServiceID uuid.UUID `json:"service_id" binding:"required"`
|
||||||
BranchName string `json:"branch_name" binding:"required"`
|
BranchName string `json:"branch_name" binding:"required"`
|
||||||
PRNumber *int `json:"pr_number"`
|
PRNumber *int `json:"pr_number"`
|
||||||
@@ -61,7 +62,7 @@ func handleGetPreviewEnvironments(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
projectIDStr := c.Param("project_id")
|
projectIDStr := firstPathParam(c, "id", "project_id", "projectId")
|
||||||
projectID, err := uuid.Parse(projectIDStr)
|
projectID, err := uuid.Parse(projectIDStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
||||||
@@ -113,20 +114,29 @@ func handleGetPreviewEnvironments(c *gin.Context) {
|
|||||||
var environments []PreviewEnvironment
|
var environments []PreviewEnvironment
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var env PreviewEnvironment
|
var env PreviewEnvironment
|
||||||
var service Service
|
var serviceID sql.NullString
|
||||||
|
var serviceName sql.NullString
|
||||||
|
var serviceType sql.NullString
|
||||||
|
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
|
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
|
||||||
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
|
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
|
||||||
&service.ID, &service.Name, &service.Type,
|
&serviceID, &serviceName, &serviceType,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan preview environment"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan preview environment"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if service.ID != uuid.Nil {
|
if serviceID.Valid {
|
||||||
env.Service = &service
|
parsedServiceID, parseErr := uuid.Parse(serviceID.String)
|
||||||
|
if parseErr == nil {
|
||||||
|
env.Service = &Service{
|
||||||
|
ID: parsedServiceID,
|
||||||
|
Name: serviceName.String,
|
||||||
|
Type: serviceType.String,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
environments = append(environments, env)
|
environments = append(environments, env)
|
||||||
@@ -143,12 +153,26 @@ func handleCreatePreviewEnvironment(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
projectIDStr := firstPathParam(c, "id", "project_id", "projectId")
|
||||||
|
projectID, err := uuid.Parse(projectIDStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req CreatePreviewEnvironmentRequest
|
var req CreatePreviewEnvironmentRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.ProjectID == uuid.Nil {
|
||||||
|
req.ProjectID = projectID
|
||||||
|
} else if req.ProjectID != projectID {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Project ID in URL and request body must match"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Get user ID from JWT token
|
// Get user ID from JWT token
|
||||||
userID, exists := c.Get("user_id")
|
userID, exists := c.Get("user_id")
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -158,7 +182,7 @@ func handleCreatePreviewEnvironment(c *gin.Context) {
|
|||||||
|
|
||||||
// Check if project exists and user has access
|
// Check if project exists and user has access
|
||||||
var project Project
|
var project Project
|
||||||
err := db.(*database.DB).QueryRow(
|
err = db.(*database.DB).QueryRow(
|
||||||
"SELECT id, name, owner_id FROM projects WHERE id = $1",
|
"SELECT id, name, owner_id FROM projects WHERE id = $1",
|
||||||
req.ProjectID,
|
req.ProjectID,
|
||||||
).Scan(&project.ID, &project.Name, &project.OwnerID)
|
).Scan(&project.ID, &project.Name, &project.OwnerID)
|
||||||
@@ -268,7 +292,9 @@ func handleGetPreviewEnvironment(c *gin.Context) {
|
|||||||
|
|
||||||
// Get preview environment with project ownership check
|
// Get preview environment with project ownership check
|
||||||
var env PreviewEnvironment
|
var env PreviewEnvironment
|
||||||
var serviceName, serviceType string
|
var serviceID sql.NullString
|
||||||
|
var serviceName sql.NullString
|
||||||
|
var serviceType sql.NullString
|
||||||
err = db.(*database.DB).QueryRow(
|
err = db.(*database.DB).QueryRow(
|
||||||
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
|
`SELECT pe.id, pe.project_id, pe.service_id, pe.branch_name, pe.pr_number,
|
||||||
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
|
pe.environment, pe.status, pe.url, pe.expires_at, pe.created_at, pe.updated_at,
|
||||||
@@ -281,7 +307,7 @@ func handleGetPreviewEnvironment(c *gin.Context) {
|
|||||||
).Scan(
|
).Scan(
|
||||||
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
|
&env.ID, &env.ProjectID, &env.ServiceID, &env.BranchName, &env.PRNumber,
|
||||||
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
|
&env.Environment, &env.Status, &env.URL, &env.ExpiresAt, &env.CreatedAt, &env.UpdatedAt,
|
||||||
&env.ServiceID, &serviceName, &serviceType,
|
&serviceID, &serviceName, &serviceType,
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -290,11 +316,14 @@ func handleGetPreviewEnvironment(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Populate service info if available
|
// Populate service info if available
|
||||||
if env.ServiceID != uuid.Nil {
|
if serviceID.Valid {
|
||||||
env.Service = &Service{
|
parsedServiceID, parseErr := uuid.Parse(serviceID.String)
|
||||||
ID: env.ServiceID,
|
if parseErr == nil {
|
||||||
Name: serviceName,
|
env.Service = &Service{
|
||||||
Type: serviceType,
|
ID: parsedServiceID,
|
||||||
|
Name: serviceName.String,
|
||||||
|
Type: serviceType.String,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import (
|
|||||||
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
|
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
|
||||||
// Initialize Docker client (non-fatal if it fails)
|
// Initialize Docker client (non-fatal if it fails)
|
||||||
var dockerClient *docker.Client
|
var dockerClient *docker.Client
|
||||||
buildManager := &build.BuildManager{} // Default empty manager
|
var buildManager *build.BuildManager
|
||||||
|
var deploymentEngine *deployment.DeploymentEngine
|
||||||
|
|
||||||
if client, err := docker.NewClient(); err != nil {
|
if client, err := docker.NewClient(); err != nil {
|
||||||
log.Printf("Warning: Failed to initialize Docker client: %v", err)
|
log.Printf("Warning: Failed to initialize Docker client: %v", err)
|
||||||
@@ -29,6 +30,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
} else {
|
} else {
|
||||||
dockerClient = client
|
dockerClient = client
|
||||||
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
|
buildManager = build.NewBuildManager("/tmp/containr-builds", dockerClient)
|
||||||
|
deploymentEngine = deployment.NewDeploymentEngine(buildManager, dockerClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize build handler
|
// Initialize build handler
|
||||||
@@ -81,6 +83,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
c.Set("jwt_secret", cfg.JWTSecret)
|
c.Set("jwt_secret", cfg.JWTSecret)
|
||||||
c.Set("docker_client", dockerClient)
|
c.Set("docker_client", dockerClient)
|
||||||
c.Set("build_manager", buildManager)
|
c.Set("build_manager", buildManager)
|
||||||
|
if deploymentEngine != nil {
|
||||||
|
c.Set("deployment_engine", deploymentEngine)
|
||||||
|
}
|
||||||
c.Set("scheduler", scheduler)
|
c.Set("scheduler", scheduler)
|
||||||
c.Set("metrics_collector", metricsCollector)
|
c.Set("metrics_collector", metricsCollector)
|
||||||
c.Set("auto_scaler", autoScaler)
|
c.Set("auto_scaler", autoScaler)
|
||||||
|
|||||||
+204
-15
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
"containr/internal/security"
|
"containr/internal/security"
|
||||||
|
"database/sql"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -48,7 +49,35 @@ func (sh *SecurityHandler) StartSecurityScan(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := c.MustGet("user_id").(string)
|
userID, ok := sh.requireProjectAccess(c, req.ProjectID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ServiceID != "" {
|
||||||
|
if _, err := uuid.Parse(req.ServiceID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var serviceExists bool
|
||||||
|
err := sh.db.QueryRow(
|
||||||
|
`SELECT EXISTS(
|
||||||
|
SELECT 1 FROM services WHERE id = $1 AND project_id = $2
|
||||||
|
)`,
|
||||||
|
req.ServiceID,
|
||||||
|
req.ProjectID,
|
||||||
|
).Scan(&serviceExists)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate service"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !serviceExists {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Service not found in project"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Log audit event
|
// Log audit event
|
||||||
sh.auditLogger.LogSecurityEvent(userID, "security_scan_started", "project",
|
sh.auditLogger.LogSecurityEvent(userID, "security_scan_started", "project",
|
||||||
@@ -69,7 +98,10 @@ func (sh *SecurityHandler) StartSecurityScan(c *gin.Context) {
|
|||||||
|
|
||||||
// GetSecurityScan retrieves a security scan
|
// GetSecurityScan retrieves a security scan
|
||||||
func (sh *SecurityHandler) GetSecurityScan(c *gin.Context) {
|
func (sh *SecurityHandler) GetSecurityScan(c *gin.Context) {
|
||||||
scanID := c.Param("scanId")
|
scanID := firstPathParam(c, "scanId", "id")
|
||||||
|
if !sh.requireSecurityScanAccess(c, scanID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
scan, err := sh.scanner.GetSecurityScan(scanID)
|
scan, err := sh.scanner.GetSecurityScan(scanID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,11 +114,14 @@ func (sh *SecurityHandler) GetSecurityScan(c *gin.Context) {
|
|||||||
|
|
||||||
// GetProjectSecurityHistory retrieves security scan history for a project
|
// GetProjectSecurityHistory retrieves security scan history for a project
|
||||||
func (sh *SecurityHandler) GetProjectSecurityHistory(c *gin.Context) {
|
func (sh *SecurityHandler) GetProjectSecurityHistory(c *gin.Context) {
|
||||||
projectID := c.Param("projectId")
|
projectID := firstPathParam(c, "projectId", "id", "project_id")
|
||||||
|
if _, ok := sh.requireProjectAccess(c, projectID); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
limit := 10
|
limit := 10
|
||||||
if limitStr := c.Query("limit"); limitStr != "" {
|
if limitStr := c.Query("limit"); limitStr != "" {
|
||||||
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
|
if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 && parsedLimit <= 1000 {
|
||||||
limit = parsedLimit
|
limit = parsedLimit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,7 +137,10 @@ func (sh *SecurityHandler) GetProjectSecurityHistory(c *gin.Context) {
|
|||||||
|
|
||||||
// GetVulnerabilities retrieves vulnerabilities for a project
|
// GetVulnerabilities retrieves vulnerabilities for a project
|
||||||
func (sh *SecurityHandler) GetVulnerabilities(c *gin.Context) {
|
func (sh *SecurityHandler) GetVulnerabilities(c *gin.Context) {
|
||||||
projectID := c.Param("projectId")
|
projectID := firstPathParam(c, "projectId", "id", "project_id")
|
||||||
|
if _, ok := sh.requireProjectAccess(c, projectID); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Query vulnerabilities
|
// Query vulnerabilities
|
||||||
rows, err := sh.db.Query(`
|
rows, err := sh.db.Query(`
|
||||||
@@ -146,8 +184,11 @@ func (sh *SecurityHandler) GetVulnerabilities(c *gin.Context) {
|
|||||||
|
|
||||||
// UpdateVulnerability updates a vulnerability status
|
// UpdateVulnerability updates a vulnerability status
|
||||||
func (sh *SecurityHandler) UpdateVulnerability(c *gin.Context) {
|
func (sh *SecurityHandler) UpdateVulnerability(c *gin.Context) {
|
||||||
vulnID := c.Param("vulnId")
|
vulnID := firstPathParam(c, "vulnId", "id")
|
||||||
userID := c.MustGet("user_id").(string)
|
userID, ok := sh.requireVulnerabilityAccess(c, vulnID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Status string `json:"status" binding:"required,oneof=open resolved ignored"`
|
Status string `json:"status" binding:"required,oneof=open resolved ignored"`
|
||||||
@@ -199,7 +240,31 @@ func (sh *SecurityHandler) StartComplianceAssessment(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := c.MustGet("user_id").(string)
|
userID, ok := sh.requireProjectAccess(c, req.ProjectID)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := uuid.Parse(req.FrameworkID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid framework ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var frameworkExists bool
|
||||||
|
err := sh.db.QueryRow(
|
||||||
|
`SELECT EXISTS(
|
||||||
|
SELECT 1 FROM compliance_frameworks WHERE id = $1
|
||||||
|
)`,
|
||||||
|
req.FrameworkID,
|
||||||
|
).Scan(&frameworkExists)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate framework"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !frameworkExists {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Compliance framework not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Log audit event
|
// Log audit event
|
||||||
sh.auditLogger.LogSecurityEvent(userID, "compliance_assessment_started", "project",
|
sh.auditLogger.LogSecurityEvent(userID, "compliance_assessment_started", "project",
|
||||||
@@ -219,7 +284,10 @@ func (sh *SecurityHandler) StartComplianceAssessment(c *gin.Context) {
|
|||||||
|
|
||||||
// GetComplianceReport retrieves a compliance report
|
// GetComplianceReport retrieves a compliance report
|
||||||
func (sh *SecurityHandler) GetComplianceReport(c *gin.Context) {
|
func (sh *SecurityHandler) GetComplianceReport(c *gin.Context) {
|
||||||
reportID := c.Param("reportId")
|
reportID := firstPathParam(c, "reportId", "id")
|
||||||
|
if !sh.requireComplianceReportAccess(c, reportID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
report, err := sh.complianceManager.GetComplianceReport(reportID)
|
report, err := sh.complianceManager.GetComplianceReport(reportID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -280,7 +348,10 @@ func (sh *SecurityHandler) InitializeGDPRFramework(c *gin.Context) {
|
|||||||
|
|
||||||
// GetSecurityMetrics retrieves security metrics for a project
|
// GetSecurityMetrics retrieves security metrics for a project
|
||||||
func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
|
func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
|
||||||
projectID := c.Param("projectId")
|
projectID := firstPathParam(c, "projectId", "id", "project_id")
|
||||||
|
if _, ok := sh.requireProjectAccess(c, projectID); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Get vulnerability counts
|
// Get vulnerability counts
|
||||||
var vulnMetrics struct {
|
var vulnMetrics struct {
|
||||||
@@ -293,7 +364,7 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
|
|||||||
Resolved int `json:"resolved"`
|
Resolved int `json:"resolved"`
|
||||||
}
|
}
|
||||||
|
|
||||||
sh.db.QueryRow(`
|
err := sh.db.QueryRow(`
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as total,
|
COUNT(*) as total,
|
||||||
COUNT(*) FILTER (WHERE severity = 'critical') as critical,
|
COUNT(*) FILTER (WHERE severity = 'critical') as critical,
|
||||||
@@ -306,6 +377,10 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
|
|||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
`, projectID).Scan(&vulnMetrics.Total, &vulnMetrics.Critical, &vulnMetrics.High,
|
`, projectID).Scan(&vulnMetrics.Total, &vulnMetrics.Critical, &vulnMetrics.High,
|
||||||
&vulnMetrics.Medium, &vulnMetrics.Low, &vulnMetrics.Open, &vulnMetrics.Resolved)
|
&vulnMetrics.Medium, &vulnMetrics.Low, &vulnMetrics.Open, &vulnMetrics.Resolved)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get vulnerability metrics"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Get latest scan
|
// Get latest scan
|
||||||
var latestScan struct {
|
var latestScan struct {
|
||||||
@@ -315,7 +390,7 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
err := sh.db.QueryRow(`
|
err = sh.db.QueryRow(`
|
||||||
SELECT id, score, started_at as scanned_at, status
|
SELECT id, score, started_at as scanned_at, status
|
||||||
FROM security_scans
|
FROM security_scans
|
||||||
WHERE project_id = $1
|
WHERE project_id = $1
|
||||||
@@ -323,13 +398,16 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, projectID).Scan(&latestScan.ID, &latestScan.Score, &latestScan.ScannedAt, &latestScan.Status)
|
`, projectID).Scan(&latestScan.ID, &latestScan.Score, &latestScan.ScannedAt, &latestScan.Status)
|
||||||
|
|
||||||
if err != nil {
|
if err == sql.ErrNoRows {
|
||||||
latestScan = struct {
|
latestScan = struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Score int `json:"score"`
|
Score int `json:"score"`
|
||||||
ScannedAt time.Time `json:"scanned_at"`
|
ScannedAt time.Time `json:"scanned_at"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}{ID: "", Score: 0, ScannedAt: time.Time{}, Status: "never_scanned"}
|
}{ID: "", Score: 0, ScannedAt: time.Time{}, Status: "never_scanned"}
|
||||||
|
} else if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get latest scan"})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get compliance status
|
// Get compliance status
|
||||||
@@ -347,12 +425,15 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, projectID).Scan(&complianceStatus.OverallStatus, &complianceStatus.Score, &complianceStatus.LastAssessed)
|
`, projectID).Scan(&complianceStatus.OverallStatus, &complianceStatus.Score, &complianceStatus.LastAssessed)
|
||||||
|
|
||||||
if err != nil {
|
if err == sql.ErrNoRows {
|
||||||
complianceStatus = struct {
|
complianceStatus = struct {
|
||||||
OverallStatus string `json:"overall_status"`
|
OverallStatus string `json:"overall_status"`
|
||||||
Score int `json:"score"`
|
Score int `json:"score"`
|
||||||
LastAssessed *time.Time `json:"last_assessed"`
|
LastAssessed *time.Time `json:"last_assessed"`
|
||||||
}{OverallStatus: "not_assessed", Score: 0, LastAssessed: nil}
|
}{OverallStatus: "not_assessed", Score: 0, LastAssessed: nil}
|
||||||
|
} else if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get compliance status"})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics := gin.H{
|
metrics := gin.H{
|
||||||
@@ -386,7 +467,10 @@ func (sh *SecurityHandler) calculateOverallSecurityScore(vulnMetrics struct {
|
|||||||
|
|
||||||
// GetAuditLogs retrieves audit logs for security events
|
// GetAuditLogs retrieves audit logs for security events
|
||||||
func (sh *SecurityHandler) GetAuditLogs(c *gin.Context) {
|
func (sh *SecurityHandler) GetAuditLogs(c *gin.Context) {
|
||||||
_ = c.Param("projectId") // projectID is available but not used in this implementation
|
projectID := firstPathParam(c, "projectId", "id", "project_id")
|
||||||
|
if _, ok := sh.requireProjectAccess(c, projectID); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
limit := 50
|
limit := 50
|
||||||
if limitStr := c.Query("limit"); limitStr != "" {
|
if limitStr := c.Query("limit"); limitStr != "" {
|
||||||
@@ -414,6 +498,111 @@ func (sh *SecurityHandler) GetAuditLogs(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (sh *SecurityHandler) requireProjectAccess(c *gin.Context, projectID string) (string, bool) {
|
||||||
|
userIDValue, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
userID, ok := userIDValue.(string)
|
||||||
|
if !ok || userID == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := uuid.Parse(projectID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasAccess bool
|
||||||
|
err := sh.db.QueryRow(
|
||||||
|
`SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM projects p
|
||||||
|
WHERE p.id = $1
|
||||||
|
AND (p.owner_id = $2 OR EXISTS (
|
||||||
|
SELECT 1 FROM project_members pm
|
||||||
|
WHERE pm.project_id = p.id AND pm.user_id = $2
|
||||||
|
))
|
||||||
|
)`,
|
||||||
|
projectID, userID,
|
||||||
|
).Scan(&hasAccess)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify project access"})
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasAccess {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"})
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return userID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sh *SecurityHandler) requireSecurityScanAccess(c *gin.Context, scanID string) bool {
|
||||||
|
if _, err := uuid.Parse(scanID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid scan ID"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectID string
|
||||||
|
err := sh.db.QueryRow("SELECT project_id FROM security_scans WHERE id = $1", scanID).Scan(&projectID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Security scan not found"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify scan access"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := sh.requireProjectAccess(c, projectID)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sh *SecurityHandler) requireComplianceReportAccess(c *gin.Context, reportID string) bool {
|
||||||
|
if _, err := uuid.Parse(reportID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid report ID"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectID string
|
||||||
|
err := sh.db.QueryRow("SELECT project_id FROM compliance_reports WHERE id = $1", reportID).Scan(&projectID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Compliance report not found"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify report access"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := sh.requireProjectAccess(c, projectID)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sh *SecurityHandler) requireVulnerabilityAccess(c *gin.Context, vulnID string) (string, bool) {
|
||||||
|
if _, err := uuid.Parse(vulnID); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid vulnerability ID"})
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectID string
|
||||||
|
err := sh.db.QueryRow("SELECT project_id FROM vulnerabilities WHERE id = $1", vulnID).Scan(&projectID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Vulnerability not found"})
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify vulnerability access"})
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return sh.requireProjectAccess(c, projectID)
|
||||||
|
}
|
||||||
|
|
||||||
// max helper function
|
// max helper function
|
||||||
func max(a, b int) int {
|
func max(a, b int) int {
|
||||||
if a > b {
|
if a > b {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ type Service struct {
|
|||||||
|
|
||||||
// CreateServiceRequest represents a request to create a service
|
// CreateServiceRequest represents a request to create a service
|
||||||
type CreateServiceRequest struct {
|
type CreateServiceRequest struct {
|
||||||
ProjectID uuid.UUID `json:"project_id" binding:"required"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
Name string `json:"name" binding:"required,min=1,max=255"`
|
Name string `json:"name" binding:"required,min=1,max=255"`
|
||||||
Type string `json:"type" binding:"required,oneof=web worker database cron"`
|
Type string `json:"type" binding:"required,oneof=web worker database cron"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
@@ -65,7 +65,7 @@ func handleGetServices(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
projectIDStr := c.Param("project_id")
|
projectIDStr := firstPathParam(c, "id", "project_id", "projectId")
|
||||||
projectID, err := uuid.Parse(projectIDStr)
|
projectID, err := uuid.Parse(projectIDStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
||||||
@@ -139,12 +139,26 @@ func handleCreateService(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
projectIDStr := firstPathParam(c, "id", "project_id", "projectId")
|
||||||
|
projectID, err := uuid.Parse(projectIDStr)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req CreateServiceRequest
|
var req CreateServiceRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.ProjectID == uuid.Nil {
|
||||||
|
req.ProjectID = projectID
|
||||||
|
} else if req.ProjectID != projectID {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Project ID in URL and request body must match"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Get user ID from JWT token
|
// Get user ID from JWT token
|
||||||
userID, exists := c.Get("user_id")
|
userID, exists := c.Get("user_id")
|
||||||
if !exists {
|
if !exists {
|
||||||
@@ -154,7 +168,7 @@ func handleCreateService(c *gin.Context) {
|
|||||||
|
|
||||||
// Check if project exists and user has access
|
// Check if project exists and user has access
|
||||||
var project Project
|
var project Project
|
||||||
err := db.(*database.DB).QueryRow(
|
err = db.(*database.DB).QueryRow(
|
||||||
"SELECT id, name, owner_id FROM projects WHERE id = $1",
|
"SELECT id, name, owner_id FROM projects WHERE id = $1",
|
||||||
req.ProjectID,
|
req.ProjectID,
|
||||||
).Scan(&project.ID, &project.Name, &project.OwnerID)
|
).Scan(&project.ID, &project.Name, &project.OwnerID)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,11 +14,11 @@ import (
|
|||||||
|
|
||||||
// DNSServer provides internal DNS resolution for services
|
// DNSServer provides internal DNS resolution for services
|
||||||
type DNSServer struct {
|
type DNSServer struct {
|
||||||
server *dns.Server
|
server *dns.Server
|
||||||
serviceDiscovery *ServiceDiscovery
|
serviceDiscovery *ServiceDiscovery
|
||||||
domain string
|
domain string
|
||||||
addresses []string
|
addresses []string
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// DNSConfig holds DNS server configuration
|
// DNSConfig holds DNS server configuration
|
||||||
@@ -31,8 +32,8 @@ type DNSConfig struct {
|
|||||||
// NewDNSServer creates a new DNS server
|
// NewDNSServer creates a new DNS server
|
||||||
func NewDNSServer(config DNSConfig, serviceDiscovery *ServiceDiscovery) *DNSServer {
|
func NewDNSServer(config DNSConfig, serviceDiscovery *ServiceDiscovery) *DNSServer {
|
||||||
return &DNSServer{
|
return &DNSServer{
|
||||||
domain: config.Domain,
|
domain: config.Domain,
|
||||||
addresses: config.Addresses,
|
addresses: config.Addresses,
|
||||||
serviceDiscovery: serviceDiscovery,
|
serviceDiscovery: serviceDiscovery,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,7 +310,7 @@ func (nu *NetworkUtils) GetLocalIP() (string, error) {
|
|||||||
|
|
||||||
// IsPortOpen checks if a port is open on a host
|
// IsPortOpen checks if a port is open on a host
|
||||||
func (nu *NetworkUtils) IsPortOpen(host string, port int, timeout time.Duration) bool {
|
func (nu *NetworkUtils) IsPortOpen(host string, port int, timeout time.Duration) bool {
|
||||||
address := fmt.Sprintf("%s:%d", host, port)
|
address := net.JoinHostPort(host, strconv.Itoa(port))
|
||||||
conn, err := net.DialTimeout("tcp", address, timeout)
|
conn, err := net.DialTimeout("tcp", address, timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -354,7 +355,7 @@ func (sd *ServiceDiscovery) startHealthCheck(instance *ServiceInstance) {
|
|||||||
func (sd *ServiceDiscovery) checkInstanceHealth(ctx context.Context, instance *ServiceInstance) bool {
|
func (sd *ServiceDiscovery) checkInstanceHealth(ctx context.Context, instance *ServiceInstance) bool {
|
||||||
// Simple TCP connection check
|
// Simple TCP connection check
|
||||||
if instance.Port > 0 {
|
if instance.Port > 0 {
|
||||||
address := fmt.Sprintf("%s:%d", instance.IPAddress, instance.Port)
|
address := net.JoinHostPort(instance.IPAddress, strconv.Itoa(instance.Port))
|
||||||
conn, err := net.DialTimeout("tcp", address, 5*time.Second)
|
conn, err := net.DialTimeout("tcp", address, 5*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
+138
-138
@@ -4,138 +4,138 @@ import "time"
|
|||||||
|
|
||||||
// Node represents a Proxmox cluster node
|
// Node represents a Proxmox cluster node
|
||||||
type Node struct {
|
type Node struct {
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CPU float64 `json:"cpu"`
|
CPU float64 `json:"cpu"`
|
||||||
Memory int `json:"mem"`
|
Memory int `json:"-"`
|
||||||
MemoryUsed int `json:"mem"`
|
MemoryUsed int `json:"mem"`
|
||||||
MaxMemory int `json:"maxmem"`
|
MaxMemory int `json:"maxmem"`
|
||||||
Disk int `json:"disk"`
|
Disk int `json:"disk"`
|
||||||
DiskUsed int `json:"diskused"`
|
DiskUsed int `json:"diskused"`
|
||||||
MaxDisk int `json:"maxdisk"`
|
MaxDisk int `json:"maxdisk"`
|
||||||
Uptime int `json:"uptime"`
|
Uptime int `json:"uptime"`
|
||||||
Level string `json:"level"`
|
Level string `json:"level"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// VM represents a virtual machine in Proxmox
|
// VM represents a virtual machine in Proxmox
|
||||||
type VM struct {
|
type VM struct {
|
||||||
VMID int `json:"vmid"`
|
VMID int `json:"vmid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CPU float64 `json:"cpu"`
|
CPU float64 `json:"cpu"`
|
||||||
Memory int `json:"mem"`
|
Memory int `json:"mem"`
|
||||||
MemoryUsed int `json:"maxmem"`
|
MemoryUsed int `json:"maxmem"`
|
||||||
Disk int `json:"disk"`
|
Disk int `json:"disk"`
|
||||||
DiskUsed int `json:"maxdisk"`
|
DiskUsed int `json:"maxdisk"`
|
||||||
Uptime int `json:"uptime"`
|
Uptime int `json:"uptime"`
|
||||||
Template bool `json:"template"`
|
Template bool `json:"template"`
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
NetIn int64 `json:"netin"`
|
NetIn int64 `json:"netin"`
|
||||||
NetOut int64 `json:"netout"`
|
NetOut int64 `json:"netout"`
|
||||||
DiskRead int64 `json:"diskread"`
|
DiskRead int64 `json:"diskread"`
|
||||||
DiskWrite int64 `json:"diskwrite"`
|
DiskWrite int64 `json:"diskwrite"`
|
||||||
CPUUsage float64 `json:"cpuusage"`
|
CPUUsage float64 `json:"cpuusage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Container represents an LXC container in Proxmox
|
// Container represents an LXC container in Proxmox
|
||||||
type Container struct {
|
type Container struct {
|
||||||
VMID int `json:"vmid"`
|
VMID int `json:"vmid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CPU float64 `json:"cpu"`
|
CPU float64 `json:"cpu"`
|
||||||
Memory int `json:"mem"`
|
Memory int `json:"mem"`
|
||||||
MemoryUsed int `json:"maxmem"`
|
MemoryUsed int `json:"maxmem"`
|
||||||
Disk int `json:"disk"`
|
Disk int `json:"disk"`
|
||||||
DiskUsed int `json:"maxdisk"`
|
DiskUsed int `json:"maxdisk"`
|
||||||
Uptime int `json:"uptime"`
|
Uptime int `json:"uptime"`
|
||||||
Template bool `json:"template"`
|
Template bool `json:"template"`
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
NetIn int64 `json:"netin"`
|
NetIn int64 `json:"netin"`
|
||||||
NetOut int64 `json:"netout"`
|
NetOut int64 `json:"netout"`
|
||||||
DiskRead int64 `json:"diskread"`
|
DiskRead int64 `json:"diskread"`
|
||||||
DiskWrite int64 `json:"diskwrite"`
|
DiskWrite int64 `json:"diskwrite"`
|
||||||
CPUUsage float64 `json:"cpuusage"`
|
CPUUsage float64 `json:"cpuusage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// VMConfig represents the configuration for creating a VM
|
// VMConfig represents the configuration for creating a VM
|
||||||
type VMConfig struct {
|
type VMConfig struct {
|
||||||
VMID int `json:"vmid"`
|
VMID int `json:"vmid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Memory int `json:"memory"`
|
Memory int `json:"memory"`
|
||||||
Cores int `json:"cores"`
|
Cores int `json:"cores"`
|
||||||
DiskSize int `json:"disk_size"` // in GB
|
DiskSize int `json:"disk_size"` // in GB
|
||||||
Storage string `json:"storage"`
|
Storage string `json:"storage"`
|
||||||
NetworkBridge string `json:"network_bridge"`
|
NetworkBridge string `json:"network_bridge"`
|
||||||
Template string `json:"template,omitempty"`
|
Template string `json:"template,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContainerConfig represents the configuration for creating an LXC container
|
// ContainerConfig represents the configuration for creating an LXC container
|
||||||
type ContainerConfig struct {
|
type ContainerConfig struct {
|
||||||
VMID int `json:"vmid"`
|
VMID int `json:"vmid"`
|
||||||
Hostname string `json:"hostname"`
|
Hostname string `json:"hostname"`
|
||||||
Memory int `json:"memory"`
|
Memory int `json:"memory"`
|
||||||
Cores int `json:"cores"`
|
Cores int `json:"cores"`
|
||||||
DiskSize int `json:"disk_size"` // in GB
|
DiskSize int `json:"disk_size"` // in GB
|
||||||
Storage string `json:"storage"`
|
Storage string `json:"storage"`
|
||||||
NetworkBridge string `json:"network_bridge"`
|
NetworkBridge string `json:"network_bridge"`
|
||||||
Template string `json:"template"`
|
Template string `json:"template"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// VMStatus represents the current status of a VM
|
// VMStatus represents the current status of a VM
|
||||||
type VMStatus struct {
|
type VMStatus struct {
|
||||||
VMID int `json:"vmid"`
|
VMID int `json:"vmid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CPU float64 `json:"cpu"`
|
CPU float64 `json:"cpu"`
|
||||||
Memory int `json:"mem"`
|
Memory int `json:"mem"`
|
||||||
MemoryUsed int `json:"maxmem"`
|
MemoryUsed int `json:"maxmem"`
|
||||||
Disk int `json:"disk"`
|
Disk int `json:"disk"`
|
||||||
DiskUsed int `json:"maxdisk"`
|
DiskUsed int `json:"maxdisk"`
|
||||||
Uptime int `json:"uptime"`
|
Uptime int `json:"uptime"`
|
||||||
Lock string `json:"lock,omitempty"`
|
Lock string `json:"lock,omitempty"`
|
||||||
HA bool `json:"ha"`
|
HA bool `json:"ha"`
|
||||||
QMPStatus string `json:"qmpstatus"`
|
QMPStatus string `json:"qmpstatus"`
|
||||||
Spice bool `json:"spice"`
|
Spice bool `json:"spice"`
|
||||||
Template bool `json:"template"`
|
Template bool `json:"template"`
|
||||||
Agent bool `json:"agent"`
|
Agent bool `json:"agent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContainerStatus represents the current status of a container
|
// ContainerStatus represents the current status of a container
|
||||||
type ContainerStatus struct {
|
type ContainerStatus struct {
|
||||||
VMID int `json:"vmid"`
|
VMID int `json:"vmid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CPU float64 `json:"cpu"`
|
CPU float64 `json:"cpu"`
|
||||||
Memory int `json:"mem"`
|
Memory int `json:"mem"`
|
||||||
MemoryUsed int `json:"maxmem"`
|
MemoryUsed int `json:"maxmem"`
|
||||||
Disk int `json:"disk"`
|
Disk int `json:"disk"`
|
||||||
DiskUsed int `json:"maxdisk"`
|
DiskUsed int `json:"maxdisk"`
|
||||||
Uptime int `json:"uptime"`
|
Uptime int `json:"uptime"`
|
||||||
Lock string `json:"lock,omitempty"`
|
Lock string `json:"lock,omitempty"`
|
||||||
HA bool `json:"ha"`
|
HA bool `json:"ha"`
|
||||||
Template bool `json:"template"`
|
Template bool `json:"template"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeStats represents detailed statistics for a node
|
// NodeStats represents detailed statistics for a node
|
||||||
type NodeStats struct {
|
type NodeStats struct {
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CPU float64 `json:"cpu"`
|
CPU float64 `json:"cpu"`
|
||||||
MemoryTotal int `json:"memory_total"`
|
MemoryTotal int `json:"memory_total"`
|
||||||
MemoryUsed int `json:"memory_used"`
|
MemoryUsed int `json:"memory_used"`
|
||||||
MemoryFree int `json:"memory_free"`
|
MemoryFree int `json:"memory_free"`
|
||||||
DiskTotal int `json:"disk_total"`
|
DiskTotal int `json:"disk_total"`
|
||||||
DiskUsed int `json:"disk_used"`
|
DiskUsed int `json:"disk_used"`
|
||||||
DiskFree int `json:"disk_free"`
|
DiskFree int `json:"disk_free"`
|
||||||
Uptime int `json:"uptime"`
|
Uptime int `json:"uptime"`
|
||||||
LoadAverage []float64 `json:"load_average"`
|
LoadAverage []float64 `json:"load_average"`
|
||||||
NetworkIn int64 `json:"network_in"`
|
NetworkIn int64 `json:"network_in"`
|
||||||
NetworkOut int64 `json:"network_out"`
|
NetworkOut int64 `json:"network_out"`
|
||||||
LastUpdate time.Time `json:"last_update"`
|
LastUpdate time.Time `json:"last_update"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// VMTemplate represents a VM template that can be cloned
|
// VMTemplate represents a VM template that can be cloned
|
||||||
@@ -165,17 +165,17 @@ type ContainerTemplate struct {
|
|||||||
|
|
||||||
// StorageInfo represents storage information on a node
|
// StorageInfo represents storage information on a node
|
||||||
type StorageInfo struct {
|
type StorageInfo struct {
|
||||||
Storage string `json:"storage"`
|
Storage string `json:"storage"`
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
Used int `json:"used"`
|
Used int `json:"used"`
|
||||||
Available int `json:"avail"`
|
Available int `json:"avail"`
|
||||||
Shared bool `json:"shared"`
|
Shared bool `json:"shared"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
ReadOnly bool `json:"read_only"`
|
ReadOnly bool `json:"read_only"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkInfo represents network interface information
|
// NetworkInfo represents network interface information
|
||||||
@@ -193,15 +193,15 @@ type NetworkInfo struct {
|
|||||||
|
|
||||||
// TaskInfo represents a task running on Proxmox
|
// TaskInfo represents a task running on Proxmox
|
||||||
type TaskInfo struct {
|
type TaskInfo struct {
|
||||||
UPID string `json:"upid"`
|
UPID string `json:"upid"`
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
StartTime time.Time `json:"starttime"`
|
StartTime time.Time `json:"starttime"`
|
||||||
EndTime time.Time `json:"endtime"`
|
EndTime time.Time `json:"endtime"`
|
||||||
Duration string `json:"duration"`
|
Duration string `json:"duration"`
|
||||||
PID int `json:"pid"`
|
PID int `json:"pid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClusterInfo represents cluster information
|
// ClusterInfo represents cluster information
|
||||||
@@ -226,23 +226,23 @@ type Resource struct {
|
|||||||
|
|
||||||
// Pool represents a resource pool
|
// Pool represents a resource pool
|
||||||
type Pool struct {
|
type Pool struct {
|
||||||
PoolID string `json:"poolid"`
|
PoolID string `json:"poolid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Comment string `json:"comment,omitempty"`
|
Comment string `json:"comment,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// User represents a Proxmox user
|
// User represents a Proxmox user
|
||||||
type User struct {
|
type User struct {
|
||||||
UserID string `json:"userid"`
|
UserID string `json:"userid"`
|
||||||
Realm string `json:"realm"`
|
Realm string `json:"realm"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
FirstName string `json:"firstname,omitempty"`
|
FirstName string `json:"firstname,omitempty"`
|
||||||
LastName string `json:"lastname,omitempty"`
|
LastName string `json:"lastname,omitempty"`
|
||||||
Groups []string `json:"groups,omitempty"`
|
Groups []string `json:"groups,omitempty"`
|
||||||
Comment string `json:"comment,omitempty"`
|
Comment string `json:"comment,omitempty"`
|
||||||
Expire int `json:"expire,omitempty"`
|
Expire int `json:"expire,omitempty"`
|
||||||
LastLogin int64 `json:"last_login,omitempty"`
|
LastLogin int64 `json:"last_login,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role represents a Proxmox user role
|
// Role represents a Proxmox user role
|
||||||
@@ -254,9 +254,9 @@ type Role struct {
|
|||||||
|
|
||||||
// Permission represents a permission in Proxmox
|
// Permission represents a permission in Proxmox
|
||||||
type Permission struct {
|
type Permission struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
User string `json:"user,omitempty"`
|
User string `json:"user,omitempty"`
|
||||||
Group string `json:"group,omitempty"`
|
Group string `json:"group,omitempty"`
|
||||||
Realm string `json:"realm,omitempty"`
|
Realm string `json:"realm,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,6 +217,18 @@ func (cm *ComplianceManager) performAssessment(report *ComplianceReport) {
|
|||||||
var recommendations []string
|
var recommendations []string
|
||||||
compliantCount := 0
|
compliantCount := 0
|
||||||
|
|
||||||
|
if len(controls) == 0 {
|
||||||
|
_, updateErr := cm.db.Exec(`
|
||||||
|
UPDATE compliance_reports
|
||||||
|
SET overall_status = $1, score = $2
|
||||||
|
WHERE id = $3
|
||||||
|
`, "non_compliant", 0, report.ID)
|
||||||
|
if updateErr != nil {
|
||||||
|
log.Printf("Failed to update compliance report %s with empty control set: %v", report.ID, updateErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for _, control := range controls {
|
for _, control := range controls {
|
||||||
assessedControl := cm.assessControl(ctx, report.ProjectID, control)
|
assessedControl := cm.assessControl(ctx, report.ProjectID, control)
|
||||||
assessedControls = append(assessedControls, assessedControl)
|
assessedControls = append(assessedControls, assessedControl)
|
||||||
|
|||||||
@@ -143,9 +143,14 @@ func (s *Scanner) scanDependencies(ctx context.Context, scan *SecurityScan) []Vu
|
|||||||
var vulnerabilities []Vulnerability
|
var vulnerabilities []Vulnerability
|
||||||
|
|
||||||
// Get project services
|
// Get project services
|
||||||
rows, err := s.db.Query(`
|
query := `SELECT id, name FROM services WHERE project_id = $1`
|
||||||
SELECT id, name FROM services WHERE project_id = $1
|
args := []interface{}{scan.ProjectID}
|
||||||
`, scan.ProjectID)
|
if scan.ServiceID != nil {
|
||||||
|
query += ` AND id = $2`
|
||||||
|
args = append(args, *scan.ServiceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := s.db.Query(query, args...)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to query services for scan: %v", err)
|
log.Printf("Failed to query services for scan: %v", err)
|
||||||
@@ -160,7 +165,7 @@ func (s *Scanner) scanDependencies(ctx context.Context, scan *SecurityScan) []Vu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Simulate dependency scanning (in real implementation, this would check package.json, go.mod, etc.)
|
// Simulate dependency scanning (in real implementation, this would check package.json, go.mod, etc.)
|
||||||
serviceVulns := s.simulateDependencyScan(serviceID, serviceName)
|
serviceVulns := s.simulateDependencyScan(serviceID, serviceName, scan.ProjectID)
|
||||||
vulnerabilities = append(vulnerabilities, serviceVulns...)
|
vulnerabilities = append(vulnerabilities, serviceVulns...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +173,7 @@ func (s *Scanner) scanDependencies(ctx context.Context, scan *SecurityScan) []Vu
|
|||||||
}
|
}
|
||||||
|
|
||||||
// simulateDependencyScan simulates scanning for vulnerable dependencies
|
// simulateDependencyScan simulates scanning for vulnerable dependencies
|
||||||
func (s *Scanner) simulateDependencyScan(serviceID, serviceName string) []Vulnerability {
|
func (s *Scanner) simulateDependencyScan(serviceID, serviceName, projectID string) []Vulnerability {
|
||||||
var vulns []Vulnerability
|
var vulns []Vulnerability
|
||||||
|
|
||||||
// Simulate finding some common vulnerabilities
|
// Simulate finding some common vulnerabilities
|
||||||
@@ -190,7 +195,7 @@ func (s *Scanner) simulateDependencyScan(serviceID, serviceName string) []Vulner
|
|||||||
Title: vuln.title,
|
Title: vuln.title,
|
||||||
Description: vuln.description,
|
Description: vuln.description,
|
||||||
ServiceID: serviceID,
|
ServiceID: serviceID,
|
||||||
ProjectID: "", // Will be filled by caller
|
ProjectID: projectID,
|
||||||
Status: "open",
|
Status: "open",
|
||||||
FoundAt: time.Now(),
|
FoundAt: time.Now(),
|
||||||
Metadata: fmt.Sprintf(`{"service": "%s", "package": "example-package-%d"}`, serviceName, i+1),
|
Metadata: fmt.Sprintf(`{"service": "%s", "package": "example-package-%d"}`, serviceName, i+1),
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ ON CONFLICT (name) DO NOTHING;
|
|||||||
|
|
||||||
-- Insert default GDPR framework
|
-- Insert default GDPR framework
|
||||||
INSERT INTO compliance_frameworks (id, name, description, version, enabled) VALUES
|
INSERT INTO compliance_frameworks (id, name, description, version, enabled) VALUES
|
||||||
('gen_random_uuid()', 'GDPR', 'General Data Protection Regulation compliance framework', '1.0', true)
|
(gen_random_uuid(), 'GDPR', 'General Data Protection Regulation compliance framework', '1.0', true)
|
||||||
ON CONFLICT (name) DO UPDATE SET version = '1.0', enabled = true;
|
ON CONFLICT (name) DO UPDATE SET version = '1.0', enabled = true;
|
||||||
|
|
||||||
-- Update updated_at trigger function
|
-- Update updated_at trigger function
|
||||||
|
|||||||
Generated
+2
-22
@@ -31,7 +31,6 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -2711,10 +2710,6 @@
|
|||||||
"version": "7946.0.16",
|
"version": "7946.0.16",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/history": {
|
|
||||||
"version": "4.7.11",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -2730,6 +2725,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -2743,23 +2739,6 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-router": {
|
|
||||||
"version": "5.1.20",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/history": "^4.7.11",
|
|
||||||
"@types/react": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/react-router-dom": {
|
|
||||||
"version": "5.3.3",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/history": "^4.7.11",
|
|
||||||
"@types/react": "*",
|
|
||||||
"@types/react-router": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/use-sync-external-store": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
@@ -3644,6 +3623,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/d3-array": {
|
"node_modules/d3-array": {
|
||||||
|
|||||||
@@ -37,7 +37,6 @@
|
|||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tanstack/react-query": "^5.66.0",
|
"@tanstack/react-query": "^5.66.0",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ Object.defineProperty(global, 'localStorage', { value: localStorageMock });
|
|||||||
const createMockAuth = (overrides = {}) => ({
|
const createMockAuth = (overrides = {}) => ({
|
||||||
user: null,
|
user: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
isAuthenticating: false,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
login: vi.fn(),
|
login: vi.fn(),
|
||||||
register: vi.fn(),
|
register: vi.fn(),
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import GitIntegration from './pages/GitIntegration';
|
|||||||
import Infrastructure from './pages/Infrastructure';
|
import Infrastructure from './pages/Infrastructure';
|
||||||
import NodeAgents from './pages/NodeAgents';
|
import NodeAgents from './pages/NodeAgents';
|
||||||
import DatabaseServices from './pages/DatabaseServices';
|
import DatabaseServices from './pages/DatabaseServices';
|
||||||
|
import Canvas from './pages/Canvas';
|
||||||
|
import Security from './pages/Security';
|
||||||
import Settings from './pages/Settings';
|
import Settings from './pages/Settings';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
|
|
||||||
@@ -53,10 +55,12 @@ function AppContent() {
|
|||||||
<Route path="projects" element={<Projects />} />
|
<Route path="projects" element={<Projects />} />
|
||||||
<Route path="projects/:projectId" element={<ProjectDetail />} />
|
<Route path="projects/:projectId" element={<ProjectDetail />} />
|
||||||
<Route path="analytics" element={<Analytics />} />
|
<Route path="analytics" element={<Analytics />} />
|
||||||
|
<Route path="canvas" element={<Canvas />} />
|
||||||
<Route path="git" element={<GitIntegration />} />
|
<Route path="git" element={<GitIntegration />} />
|
||||||
<Route path="infrastructure" element={<Infrastructure />} />
|
<Route path="infrastructure" element={<Infrastructure />} />
|
||||||
<Route path="agents" element={<NodeAgents />} />
|
<Route path="agents" element={<NodeAgents />} />
|
||||||
<Route path="databases" element={<DatabaseServices />} />
|
<Route path="databases" element={<DatabaseServices />} />
|
||||||
|
<Route path="security" element={<Security />} />
|
||||||
<Route path="settings" element={<Settings />} />
|
<Route path="settings" element={<Settings />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const createMockStore = () => ({
|
|||||||
const createMockAuth = (overrides = {}) => ({
|
const createMockAuth = (overrides = {}) => ({
|
||||||
user: { id: '1', name: 'Test User', email: 'test@example.com', created_at: '', updated_at: '' },
|
user: { id: '1', name: 'Test User', email: 'test@example.com', created_at: '', updated_at: '' },
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
isAuthenticating: false,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
login: vi.fn(),
|
login: vi.fn(),
|
||||||
register: vi.fn(),
|
register: vi.fn(),
|
||||||
|
|||||||
@@ -16,21 +16,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { deploymentsApi } from '@/lib/api';
|
import { deploymentsApi } from '@/lib/api';
|
||||||
|
import type { Deployment } from '@/types';
|
||||||
interface Deployment {
|
|
||||||
id: string;
|
|
||||||
service_id: string;
|
|
||||||
commit_hash: string | null;
|
|
||||||
status: 'pending' | 'building' | 'deploying' | 'deployed' | 'failed' | 'rolling_back';
|
|
||||||
image_name: string;
|
|
||||||
image_tag: string;
|
|
||||||
build_log: string;
|
|
||||||
runtime_log: string;
|
|
||||||
error: string | null;
|
|
||||||
started_at: string | null;
|
|
||||||
completed_at: string | null;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DeploymentsPanelProps {
|
interface DeploymentsPanelProps {
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
@@ -53,7 +39,7 @@ const statusConfig: Record<string, StatusConfig> = {
|
|||||||
rolling_back: { color: 'bg-orange-500', icon: RotateCcw, label: 'Rolling Back', animate: true },
|
rolling_back: { color: 'bg-orange-500', icon: RotateCcw, label: 'Rolling Back', animate: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
function _DeploymentsPanel({ serviceId, serviceName: _serviceName }: DeploymentsPanelProps) {
|
export default function DeploymentsPanel({ serviceId, serviceName }: DeploymentsPanelProps) {
|
||||||
const [expandedDeployment, setExpandedDeployment] = useState<string | null>(null);
|
const [expandedDeployment, setExpandedDeployment] = useState<string | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -61,7 +47,7 @@ function _DeploymentsPanel({ serviceId, serviceName: _serviceName }: Deployments
|
|||||||
queryKey: ['deployments', serviceId],
|
queryKey: ['deployments', serviceId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await deploymentsApi.getDeployments(serviceId);
|
const response = await deploymentsApi.getDeployments(serviceId);
|
||||||
return response.deployments as Deployment[];
|
return response.deployments;
|
||||||
},
|
},
|
||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
});
|
});
|
||||||
@@ -104,7 +90,7 @@ function _DeploymentsPanel({ serviceId, serviceName: _serviceName }: Deployments
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold">Deployments</h3>
|
<h3 className="text-lg font-semibold">Deployments · {serviceName}</h3>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => createDeployment.mutate({})}
|
onClick={() => createDeployment.mutate({})}
|
||||||
disabled={createDeployment.isPending}
|
disabled={createDeployment.isPending}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Trash2, Save, Eye, EyeOff, Key, Loader2 } from 'lucide-react';
|
import { Plus, Trash2, Save, Eye, EyeOff, Key, Loader2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -20,7 +20,7 @@ interface EnvVariablesEditorProps {
|
|||||||
serviceId: string;
|
serviceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _EnvVariablesEditor({ serviceId }: EnvVariablesEditorProps) {
|
export default function EnvVariablesEditor({ serviceId }: EnvVariablesEditorProps) {
|
||||||
const [variables, setVariables] = useState<{ key: string; value: string; is_secret: boolean }[]>([]);
|
const [variables, setVariables] = useState<{ key: string; value: string; is_secret: boolean }[]>([]);
|
||||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
@@ -34,17 +34,19 @@ function _EnvVariablesEditor({ serviceId }: EnvVariablesEditorProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useState(() => {
|
useEffect(() => {
|
||||||
if (existingVars) {
|
if (!existingVars) {
|
||||||
setVariables(
|
return;
|
||||||
existingVars.map((v) => ({
|
|
||||||
key: v.key,
|
|
||||||
value: v.is_secret ? '' : v.value,
|
|
||||||
is_secret: v.is_secret,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
setVariables(
|
||||||
|
existingVars.map((v) => ({
|
||||||
|
key: v.key,
|
||||||
|
value: v.is_secret ? '' : v.value,
|
||||||
|
is_secret: v.is_secret,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}, [existingVars]);
|
||||||
|
|
||||||
const updateVariables = useMutation({
|
const updateVariables = useMutation({
|
||||||
mutationFn: async (vars: { key: string; value: string; is_secret: boolean }[]) => {
|
mutationFn: async (vars: { key: string; value: string; is_secret: boolean }[]) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Play, Pause, Download, Trash2, Loader2, Terminal } from 'lucide-react';
|
import { Play, Pause, Download, Trash2, Loader2, Terminal, RefreshCw } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { logsApi } from '@/lib/api';
|
import { logsApi } from '@/lib/api';
|
||||||
@@ -16,30 +16,30 @@ interface ServiceLogsProps {
|
|||||||
serviceName: string;
|
serviceName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _ServiceLogs({ serviceId, serviceName }: ServiceLogsProps) {
|
export default function ServiceLogs({ serviceId, serviceName }: ServiceLogsProps) {
|
||||||
const [isStreaming, setIsStreaming] = useState(false);
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||||
const [autoScroll, setAutoScroll] = useState(true);
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
|
||||||
|
|
||||||
const { data: initialLogs, isLoading } = useQuery({
|
const { data: fetchedLogs, isLoading, isFetching, refetch } = useQuery({
|
||||||
queryKey: ['logs', serviceId],
|
queryKey: ['logs', serviceId, isStreaming],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await logsApi.getServiceLogs(serviceId, { lines: 100 });
|
const response = await logsApi.getServiceLogs(serviceId, { tail: 200, follow: false });
|
||||||
return response.logs.map((log) => ({
|
return response.logs.map((log) => ({
|
||||||
timestamp: log.timestamp,
|
timestamp: log.timestamp,
|
||||||
message: log.message,
|
message: log.message,
|
||||||
stream: log.stream as 'stdout' | 'stderr' | 'system',
|
stream: log.stream as 'stdout' | 'stderr' | 'system',
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
refetchInterval: isStreaming ? 3000 : false,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialLogs) {
|
if (fetchedLogs) {
|
||||||
setLogs(initialLogs);
|
setLogs(fetchedLogs);
|
||||||
}
|
}
|
||||||
}, [initialLogs]);
|
}, [fetchedLogs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoScroll && logContainerRef.current) {
|
if (autoScroll && logContainerRef.current) {
|
||||||
@@ -47,52 +47,6 @@ function _ServiceLogs({ serviceId, serviceName }: ServiceLogsProps) {
|
|||||||
}
|
}
|
||||||
}, [logs, autoScroll]);
|
}, [logs, autoScroll]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (eventSourceRef.current) {
|
|
||||||
eventSourceRef.current.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startStreaming = () => {
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080';
|
|
||||||
const _token = localStorage.getItem('auth_token');
|
|
||||||
|
|
||||||
const url = new URL(`${API_BASE_URL}/api/v1/services/${serviceId}/logs`);
|
|
||||||
url.searchParams.append('follow', 'true');
|
|
||||||
|
|
||||||
const eventSource = new EventSource(url.toString(), {
|
|
||||||
withCredentials: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const log: LogEntry = JSON.parse(event.data);
|
|
||||||
setLogs((prev) => [...prev.slice(-500), log]);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse log:', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
|
||||||
console.error('EventSource error');
|
|
||||||
eventSource.close();
|
|
||||||
setIsStreaming(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
eventSourceRef.current = eventSource;
|
|
||||||
setIsStreaming(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stopStreaming = () => {
|
|
||||||
if (eventSourceRef.current) {
|
|
||||||
eventSourceRef.current.close();
|
|
||||||
eventSourceRef.current = null;
|
|
||||||
}
|
|
||||||
setIsStreaming(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearLogs = () => {
|
const clearLogs = () => {
|
||||||
setLogs([]);
|
setLogs([]);
|
||||||
};
|
};
|
||||||
@@ -142,16 +96,24 @@ function _ServiceLogs({ serviceId, serviceName }: ServiceLogsProps) {
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isStreaming ? (
|
{isStreaming ? (
|
||||||
<Button variant="outline" size="sm" onClick={stopStreaming}>
|
<Button variant="outline" size="sm" onClick={() => setIsStreaming(false)}>
|
||||||
<Pause className="w-4 h-4 mr-1" />
|
<Pause className="w-4 h-4 mr-1" />
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="outline" size="sm" onClick={startStreaming}>
|
<Button variant="outline" size="sm" onClick={() => setIsStreaming(true)}>
|
||||||
<Play className="w-4 h-4 mr-1" />
|
<Play className="w-4 h-4 mr-1" />
|
||||||
Stream
|
Stream
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<Button variant="outline" size="sm" onClick={() => void refetch()} disabled={isFetching}>
|
||||||
|
{isFetching ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-4 h-4 mr-1" />
|
||||||
|
)}
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={downloadLogs}>
|
<Button variant="outline" size="sm" onClick={downloadLogs}>
|
||||||
<Download className="w-4 h-4 mr-1" />
|
<Download className="w-4 h-4 mr-1" />
|
||||||
Download
|
Download
|
||||||
@@ -199,14 +161,14 @@ function _ServiceLogs({ serviceId, serviceName }: ServiceLogsProps) {
|
|||||||
{logs.length} log entries
|
{logs.length} log entries
|
||||||
{autoScroll && ' • Auto-scroll enabled'}
|
{autoScroll && ' • Auto-scroll enabled'}
|
||||||
</span>
|
</span>
|
||||||
{isStreaming && (
|
{isStreaming && (
|
||||||
<span className="flex items-center gap-1 text-green-500">
|
<span className="flex items-center gap-1 text-green-500">
|
||||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||||
Streaming...
|
Polling...
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import type {
|
||||||
|
CreatePreviewEnvironmentRequest,
|
||||||
|
PreviewEnvironment,
|
||||||
|
PromotePreviewRequest,
|
||||||
|
Service,
|
||||||
|
} from '@/types';
|
||||||
import {
|
import {
|
||||||
TestTube,
|
TestTube,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -29,9 +35,16 @@ interface PreviewEnvironmentsProps {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CreatePreviewEnvironmentForm {
|
||||||
|
service_id: string;
|
||||||
|
branch_name: string;
|
||||||
|
pr_number: string;
|
||||||
|
ttl_hours: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function PreviewEnvironments({ projectId }: PreviewEnvironmentsProps) {
|
export default function PreviewEnvironments({ projectId }: PreviewEnvironmentsProps) {
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState<CreatePreviewEnvironmentForm>({
|
||||||
service_id: '',
|
service_id: '',
|
||||||
branch_name: '',
|
branch_name: '',
|
||||||
pr_number: '',
|
pr_number: '',
|
||||||
@@ -51,7 +64,7 @@ export default function PreviewEnvironments({ projectId }: PreviewEnvironmentsPr
|
|||||||
});
|
});
|
||||||
|
|
||||||
const createEnvironmentMutation = useMutation({
|
const createEnvironmentMutation = useMutation({
|
||||||
mutationFn: (data: any) => projectsApi.createPreviewEnvironment(projectId, data),
|
mutationFn: (data: CreatePreviewEnvironmentRequest) => projectsApi.createPreviewEnvironment(projectId, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['preview-environments', projectId] });
|
queryClient.invalidateQueries({ queryKey: ['preview-environments', projectId] });
|
||||||
setIsCreateModalOpen(false);
|
setIsCreateModalOpen(false);
|
||||||
@@ -67,20 +80,29 @@ export default function PreviewEnvironments({ projectId }: PreviewEnvironmentsPr
|
|||||||
});
|
});
|
||||||
|
|
||||||
const promoteEnvironmentMutation = useMutation({
|
const promoteEnvironmentMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: string; data: any }) =>
|
mutationFn: ({ id, data }: { id: string; data: PromotePreviewRequest }) =>
|
||||||
projectsApi.promotePreviewEnvironment(id, data),
|
projectsApi.promotePreviewEnvironment(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['preview-environments', projectId] });
|
queryClient.invalidateQueries({ queryKey: ['preview-environments', projectId] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const environments = environmentsData?.preview_environments || [];
|
const cleanupExpiredMutation = useMutation({
|
||||||
const services = servicesData?.services || [];
|
mutationFn: () => projectsApi.cleanupExpiredPreviewEnvironments(),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['preview-environments', projectId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const environments: PreviewEnvironment[] = environmentsData?.preview_environments || [];
|
||||||
|
const services: Service[] = servicesData?.services || [];
|
||||||
|
|
||||||
const handleCreateEnvironment = () => {
|
const handleCreateEnvironment = () => {
|
||||||
const data = {
|
const data: CreatePreviewEnvironmentRequest = {
|
||||||
...formData,
|
service_id: formData.service_id,
|
||||||
pr_number: formData.pr_number ? parseInt(formData.pr_number) : undefined,
|
branch_name: formData.branch_name,
|
||||||
|
ttl_hours: formData.ttl_hours,
|
||||||
|
pr_number: formData.pr_number ? parseInt(formData.pr_number, 10) : undefined,
|
||||||
};
|
};
|
||||||
createEnvironmentMutation.mutate(data);
|
createEnvironmentMutation.mutate(data);
|
||||||
};
|
};
|
||||||
@@ -91,7 +113,7 @@ export default function PreviewEnvironments({ projectId }: PreviewEnvironmentsPr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePromoteEnvironment = (id: string, targetEnvironment: string) => {
|
const handlePromoteEnvironment = (id: string, targetEnvironment: PromotePreviewRequest['target_environment']) => {
|
||||||
promoteEnvironmentMutation.mutate({
|
promoteEnvironmentMutation.mutate({
|
||||||
id,
|
id,
|
||||||
data: {
|
data: {
|
||||||
@@ -135,11 +157,18 @@ export default function PreviewEnvironments({ projectId }: PreviewEnvironmentsPr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isExpired = (expiresAt: string) => {
|
const isExpired = (expiresAt?: string | null) => {
|
||||||
|
if (!expiresAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return new Date(expiresAt) < new Date();
|
return new Date(expiresAt) < new Date();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTimeRemaining = (expiresAt: string) => {
|
const getTimeRemaining = (expiresAt?: string | null) => {
|
||||||
|
if (!expiresAt) {
|
||||||
|
return 'No expiration';
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const expires = new Date(expiresAt);
|
const expires = new Date(expiresAt);
|
||||||
const diff = expires.getTime() - now.getTime();
|
const diff = expires.getTime() - now.getTime();
|
||||||
@@ -185,9 +214,14 @@ export default function PreviewEnvironments({ projectId }: PreviewEnvironmentsPr
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => cleanupExpiredMutation.mutate()}
|
||||||
|
disabled={cleanupExpiredMutation.isPending}
|
||||||
|
>
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
Cleanup Expired
|
{cleanupExpiredMutation.isPending ? 'Cleaning...' : 'Cleanup Expired'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setIsCreateModalOpen(true)}>
|
<Button onClick={() => setIsCreateModalOpen(true)}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
@@ -215,7 +249,7 @@ export default function PreviewEnvironments({ projectId }: PreviewEnvironmentsPr
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{environments.map((env: any) => (
|
{environments.map((env) => (
|
||||||
<Card key={env.id} className={`border-l-4 ${
|
<Card key={env.id} className={`border-l-4 ${
|
||||||
isExpired(env.expires_at) ? 'border-l-orange-500' :
|
isExpired(env.expires_at) ? 'border-l-orange-500' :
|
||||||
env.status === 'running' ? 'border-l-green-500' :
|
env.status === 'running' ? 'border-l-green-500' :
|
||||||
@@ -373,7 +407,7 @@ export default function PreviewEnvironments({ projectId }: PreviewEnvironmentsPr
|
|||||||
className="mt-1 w-full p-2 border rounded-md"
|
className="mt-1 w-full p-2 border rounded-md"
|
||||||
>
|
>
|
||||||
<option value="">Select service</option>
|
<option value="">Select service</option>
|
||||||
{services.map((service: any) => (
|
{services.map((service) => (
|
||||||
<option key={service.id} value={service.id}>
|
<option key={service.id} value={service.id}>
|
||||||
{service.name} ({service.type})
|
{service.name} ({service.type})
|
||||||
</option>
|
</option>
|
||||||
@@ -409,7 +443,7 @@ export default function PreviewEnvironments({ projectId }: PreviewEnvironmentsPr
|
|||||||
<select
|
<select
|
||||||
id="ttl"
|
id="ttl"
|
||||||
value={formData.ttl_hours}
|
value={formData.ttl_hours}
|
||||||
onChange={(e) => setFormData({ ...formData, ttl_hours: parseInt(e.target.value) })}
|
onChange={(e) => setFormData({ ...formData, ttl_hours: parseInt(e.target.value, 10) })}
|
||||||
className="mt-1 w-full p-2 border rounded-md"
|
className="mt-1 w-full p-2 border rounded-md"
|
||||||
>
|
>
|
||||||
<option value={6}>6 hours</option>
|
<option value={6}>6 hours</option>
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { GripVertical } from "lucide-react"
|
import { GripVertical } from "lucide-react"
|
||||||
import * as ResizablePrimitive from "react-resizable-panels"
|
import {
|
||||||
|
Group as ResizablePrimitiveGroup,
|
||||||
|
Panel as ResizablePrimitivePanel,
|
||||||
|
Separator as ResizablePrimitiveSeparator,
|
||||||
|
} from "react-resizable-panels"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const ResizablePanelGroup = ({
|
const ResizablePanelGroup = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
}: React.ComponentProps<typeof ResizablePrimitiveGroup>) => (
|
||||||
<ResizablePrimitive.PanelGroup
|
<ResizablePrimitiveGroup
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
className
|
className
|
||||||
@@ -17,16 +21,16 @@ const ResizablePanelGroup = ({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
const ResizablePanel = ResizablePrimitive.Panel
|
const ResizablePanel = ResizablePrimitivePanel
|
||||||
|
|
||||||
const ResizableHandle = ({
|
const ResizableHandle = ({
|
||||||
withHandle,
|
withHandle,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
}: React.ComponentProps<typeof ResizablePrimitiveSeparator> & {
|
||||||
withHandle?: boolean
|
withHandle?: boolean
|
||||||
}) => (
|
}) => (
|
||||||
<ResizablePrimitive.PanelResizeHandle
|
<ResizablePrimitiveSeparator
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-px items-center justify-center bg-border/50 after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90 hover:bg-primary/30 transition-colors",
|
"relative flex w-px items-center justify-center bg-border/50 after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90 hover:bg-primary/30 transition-colors",
|
||||||
className
|
className
|
||||||
@@ -38,7 +42,7 @@ const ResizableHandle = ({
|
|||||||
<GripVertical className="h-2.5 w-2.5" />
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ResizablePrimitive.PanelResizeHandle>
|
</ResizablePrimitiveSeparator>
|
||||||
)
|
)
|
||||||
|
|
||||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||||
|
|||||||
+17
-6
@@ -1,10 +1,12 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { authApi } from '@/lib/api';
|
import { authApi } from '@/lib/api';
|
||||||
import { createContext, useContext, useEffect, type ReactNode } from 'react';
|
import { createContext, useCallback, useContext, useEffect, type ReactNode } from 'react';
|
||||||
|
import type { User } from '@/types';
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: any | null;
|
user: User | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
isAuthenticating: boolean;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
login: (email: string, password: string) => Promise<void>;
|
login: (email: string, password: string) => Promise<void>;
|
||||||
register: (email: string, password: string, name: string) => Promise<void>;
|
register: (email: string, password: string, name: string) => Promise<void>;
|
||||||
@@ -26,7 +28,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
// Demo mode check
|
// Demo mode check
|
||||||
const isDemoMode = !!localStorage.getItem('demoMode');
|
const isDemoMode = !!localStorage.getItem('demoMode');
|
||||||
const demoUser = isDemoMode ? { id: 'demo', name: 'Demo User', email: 'demo@example.com' } : null;
|
const demoUser: User | null = isDemoMode
|
||||||
|
? {
|
||||||
|
id: 'demo',
|
||||||
|
name: 'Demo User',
|
||||||
|
email: 'demo@example.com',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
const loginMutation = useMutation({
|
const loginMutation = useMutation({
|
||||||
mutationFn: ({ email, password }: { email: string; password: string }) =>
|
mutationFn: ({ email, password }: { email: string; password: string }) =>
|
||||||
@@ -54,23 +64,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
await registerMutation.mutateAsync({ email, password, name });
|
await registerMutation.mutateAsync({ email, password, name });
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = useCallback(() => {
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
localStorage.removeItem('demoMode');
|
localStorage.removeItem('demoMode');
|
||||||
queryClient.setQueryData(['auth', 'profile'], null);
|
queryClient.setQueryData(['auth', 'profile'], null);
|
||||||
queryClient.clear();
|
queryClient.clear();
|
||||||
};
|
}, [queryClient]);
|
||||||
|
|
||||||
// Auto-logout if token is invalid
|
// Auto-logout if token is invalid
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error && !isLoading) {
|
if (error && !isLoading) {
|
||||||
logout();
|
logout();
|
||||||
}
|
}
|
||||||
}, [error, isLoading]);
|
}, [error, isLoading, logout]);
|
||||||
|
|
||||||
const value: AuthContextType = {
|
const value: AuthContextType = {
|
||||||
user: isDemoMode ? demoUser : (user || null),
|
user: isDemoMode ? demoUser : (user || null),
|
||||||
isLoading: isDemoMode ? false : isLoading,
|
isLoading: isDemoMode ? false : isLoading,
|
||||||
|
isAuthenticating: loginMutation.isPending || registerMutation.isPending,
|
||||||
isAuthenticated: isDemoMode || (!!user && !error),
|
isAuthenticated: isDemoMode || (!!user && !error),
|
||||||
login,
|
login,
|
||||||
register,
|
register,
|
||||||
|
|||||||
+23
-10
@@ -163,21 +163,24 @@ export const projectsApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getProject: async (id: string): Promise<{ project: Project }> => {
|
getProject: async (id: string): Promise<{ project: Project }> => {
|
||||||
return apiCall<{ project: Project }>(`/api/v1/projects/${id}`);
|
const response = await apiCall<Project | { project: Project }>(`/api/v1/projects/${id}`);
|
||||||
|
return 'project' in response ? response : { project: response };
|
||||||
},
|
},
|
||||||
|
|
||||||
createProject: async (data: { name: string; description?: string }): Promise<{ project: Project }> => {
|
createProject: async (data: { name: string; description?: string }): Promise<{ project: Project }> => {
|
||||||
return apiCall<{ project: Project }>('/api/v1/projects', {
|
const response = await apiCall<Project | { project: Project }>('/api/v1/projects', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
return 'project' in response ? response : { project: response };
|
||||||
},
|
},
|
||||||
|
|
||||||
updateProject: async (id: string, data: { name?: string; description?: string }): Promise<{ project: Project }> => {
|
updateProject: async (id: string, data: { name?: string; description?: string }): Promise<{ project: Project }> => {
|
||||||
return apiCall<{ project: Project }>(`/api/v1/projects/${id}`, {
|
const response = await apiCall<Project | { project: Project }>(`/api/v1/projects/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
return 'project' in response ? response : { project: response };
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteProject: async (id: string): Promise<{ message: string }> => {
|
deleteProject: async (id: string): Promise<{ message: string }> => {
|
||||||
@@ -197,7 +200,7 @@ export const projectsApi = {
|
|||||||
createPreviewEnvironment: async (projectId: string, data: CreatePreviewEnvironmentRequest): Promise<{ preview_environment: PreviewEnvironment }> => {
|
createPreviewEnvironment: async (projectId: string, data: CreatePreviewEnvironmentRequest): Promise<{ preview_environment: PreviewEnvironment }> => {
|
||||||
return apiCall<{ preview_environment: PreviewEnvironment }>(`/api/v1/projects/${projectId}/preview-environments`, {
|
return apiCall<{ preview_environment: PreviewEnvironment }>(`/api/v1/projects/${projectId}/preview-environments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify({ ...data, project_id: projectId }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -266,10 +269,11 @@ export const deploymentsApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
createDeployment: async (serviceId: string, data: CreateDeploymentRequest): Promise<{ deployment: Deployment }> => {
|
createDeployment: async (serviceId: string, data: CreateDeploymentRequest): Promise<{ deployment: Deployment }> => {
|
||||||
return apiCall<{ deployment: Deployment }>(`/api/v1/services/${serviceId}/deployments`, {
|
const response = await apiCall<Deployment | { deployment: Deployment }>(`/api/v1/services/${serviceId}/deployments`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
return 'deployment' in response ? response : { deployment: response };
|
||||||
},
|
},
|
||||||
|
|
||||||
getDeployment: async (id: string): Promise<{ deployment: Deployment }> => {
|
getDeployment: async (id: string): Promise<{ deployment: Deployment }> => {
|
||||||
@@ -299,9 +303,9 @@ export const variablesApi = {
|
|||||||
|
|
||||||
// Logs API
|
// Logs API
|
||||||
export const logsApi = {
|
export const logsApi = {
|
||||||
getServiceLogs: async (serviceId: string, options?: { lines?: number; follow?: boolean }) => {
|
getServiceLogs: async (serviceId: string, options?: { tail?: number; follow?: boolean }) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (options?.lines) params.append('lines', options.lines.toString());
|
if (options?.tail) params.append('tail', options.tail.toString());
|
||||||
if (options?.follow) params.append('follow', 'true');
|
if (options?.follow) params.append('follow', 'true');
|
||||||
|
|
||||||
return apiCall<{ logs: Array<{ timestamp: string; message: string; stream: string }> }>(`/api/v1/services/${serviceId}/logs?${params}`);
|
return apiCall<{ logs: Array<{ timestamp: string; message: string; stream: string }> }>(`/api/v1/services/${serviceId}/logs?${params}`);
|
||||||
@@ -329,10 +333,11 @@ export const gitApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
createProvider: async (data: CreateProviderRequest): Promise<{ provider: GitProvider }> => {
|
createProvider: async (data: CreateProviderRequest): Promise<{ provider: GitProvider }> => {
|
||||||
return apiCall<{ provider: GitProvider }>('/api/v1/git/providers', {
|
const response = await apiCall<GitProvider | { provider: GitProvider }>('/api/v1/git/providers', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
return 'provider' in response ? response : { provider: response };
|
||||||
},
|
},
|
||||||
|
|
||||||
getProviderRepositories: async (providerId: string): Promise<{ repositories: GitRepository[] }> => {
|
getProviderRepositories: async (providerId: string): Promise<{ repositories: GitRepository[] }> => {
|
||||||
@@ -340,10 +345,11 @@ export const gitApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
connectRepository: async (data: ConnectRepositoryRequest): Promise<{ repository: GitRepository }> => {
|
connectRepository: async (data: ConnectRepositoryRequest): Promise<{ repository: GitRepository }> => {
|
||||||
return apiCall<{ repository: GitRepository }>('/api/v1/git/repositories/connect', {
|
const response = await apiCall<GitRepository | { repository: GitRepository }>('/api/v1/git/repositories/connect', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
return 'repository' in response ? response : { repository: response };
|
||||||
},
|
},
|
||||||
|
|
||||||
getConnectedRepositories: async (params?: { page?: number; limit?: number }): Promise<{
|
getConnectedRepositories: async (params?: { page?: number; limit?: number }): Promise<{
|
||||||
@@ -355,10 +361,17 @@ export const gitApi = {
|
|||||||
if (params?.limit) searchParams.append('limit', params.limit.toString());
|
if (params?.limit) searchParams.append('limit', params.limit.toString());
|
||||||
|
|
||||||
const queryString = searchParams.toString();
|
const queryString = searchParams.toString();
|
||||||
return apiCall<{
|
const response = await apiCall<{
|
||||||
repositories: GitRepository[];
|
repositories: GitRepository[];
|
||||||
pagination: Pagination
|
pagination: Pagination
|
||||||
}>(`/api/v1/git/repositories${queryString ? '?' + queryString : ''}`);
|
}>(`/api/v1/git/repositories${queryString ? '?' + queryString : ''}`);
|
||||||
|
|
||||||
|
if (typeof response.pagination.pages !== 'number') {
|
||||||
|
const { total, limit } = response.pagination;
|
||||||
|
response.pagination.pages = limit > 0 ? Math.ceil(total / limit) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
},
|
},
|
||||||
|
|
||||||
createWebhook: async (data: CreateWebhookRequest): Promise<{
|
createWebhook: async (data: CreateWebhookRequest): Promise<{
|
||||||
|
|||||||
+2
-14
@@ -1,22 +1,10 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App'
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
retry: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<App />
|
||||||
<App />
|
|
||||||
</QueryClientProvider>
|
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { ProjectCanvas } from '@/components/dashboard/ProjectCanvas';
|
||||||
|
import { PageHeader } from '@/components/ui/page-header';
|
||||||
|
|
||||||
|
export default function CanvasPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-6 lg:p-8 space-y-8">
|
||||||
|
<PageHeader
|
||||||
|
title="Canvas"
|
||||||
|
description="Visualize your services and deployment graph."
|
||||||
|
/>
|
||||||
|
<ProjectCanvas />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -262,7 +262,7 @@ export default function GitIntegrationPage() {
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
{getProviderIcon(repo.provider.name)}
|
{getProviderIcon(repo.provider?.name ?? 'github')}
|
||||||
<CardTitle className="text-lg font-semibold truncate">
|
<CardTitle className="text-lg font-semibold truncate">
|
||||||
{repo.name}
|
{repo.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
|
|||||||
+349
-95
@@ -1,52 +1,152 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { projectsApi } from '@/lib/api';
|
import { deploymentsApi, projectsApi, servicesApi } from '@/lib/api';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Settings,
|
Settings,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
Database,
|
Database,
|
||||||
Activity,
|
Activity,
|
||||||
Users,
|
|
||||||
Calendar,
|
Calendar,
|
||||||
Plus,
|
Plus,
|
||||||
TestTube
|
TestTube,
|
||||||
|
Server,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import PreviewEnvironments from '@/components/preview/PreviewEnvironments';
|
import PreviewEnvironments from '@/components/preview/PreviewEnvironments';
|
||||||
import { ProjectCanvas } from '@/components/dashboard/ProjectCanvas';
|
import DeploymentsPanel from '@/components/deployments/DeploymentsPanel';
|
||||||
|
import EnvVariablesEditor from '@/components/deployments/EnvVariablesEditor';
|
||||||
|
import ServiceLogs from '@/components/deployments/ServiceLogs';
|
||||||
|
import type { CreateServiceRequest, Project, Service } from '@/types';
|
||||||
|
|
||||||
interface Project {
|
type Tab = 'overview' | 'services' | 'preview' | 'settings';
|
||||||
id: string;
|
|
||||||
name: string;
|
const serviceTypeOptions: Array<CreateServiceRequest['type']> = [
|
||||||
description?: string;
|
'web',
|
||||||
owner_id: string;
|
'worker',
|
||||||
created_at: string;
|
'database',
|
||||||
updated_at: string;
|
'cron',
|
||||||
}
|
];
|
||||||
|
|
||||||
|
const serviceEnvOptions: Array<CreateServiceRequest['environment']> = [
|
||||||
|
'production',
|
||||||
|
'preview',
|
||||||
|
'development',
|
||||||
|
];
|
||||||
|
|
||||||
export default function ProjectDetailPage() {
|
export default function ProjectDetailPage() {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activeTab, setActiveTab] = useState<'overview' | 'services' | 'preview' | 'settings'>('overview');
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: projectData, isLoading, error } = useQuery({
|
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||||
|
const [selectedServiceId, setSelectedServiceId] = useState<string | null>(null);
|
||||||
|
const [isCreateServiceOpen, setIsCreateServiceOpen] = useState(false);
|
||||||
|
const [serviceForm, setServiceForm] = useState({
|
||||||
|
name: '',
|
||||||
|
type: 'web' as CreateServiceRequest['type'],
|
||||||
|
environment: 'production' as CreateServiceRequest['environment'],
|
||||||
|
image: '',
|
||||||
|
command: '',
|
||||||
|
git_repo: '',
|
||||||
|
git_branch: 'main',
|
||||||
|
build_path: '.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: projectData, isLoading: projectLoading, error } = useQuery({
|
||||||
queryKey: ['project', projectId],
|
queryKey: ['project', projectId],
|
||||||
queryFn: () => projectId ? projectsApi.getProject(projectId) : Promise.reject('No project ID'),
|
queryFn: () => (projectId ? projectsApi.getProject(projectId) : Promise.reject(new Error('No project ID'))),
|
||||||
enabled: !!projectId,
|
enabled: !!projectId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const project = projectData?.project;
|
const { data: servicesData, isLoading: servicesLoading } = useQuery({
|
||||||
|
queryKey: ['services', projectId],
|
||||||
|
queryFn: () => (projectId ? projectsApi.getServices(projectId) : Promise.reject(new Error('No project ID'))),
|
||||||
|
enabled: !!projectId,
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
const services = servicesData?.services ?? [];
|
||||||
|
const project: Project | undefined = projectData?.project;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (services.length === 0) {
|
||||||
|
setSelectedServiceId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedServiceId || !services.some((service) => service.id === selectedServiceId)) {
|
||||||
|
setSelectedServiceId(services[0].id);
|
||||||
|
}
|
||||||
|
}, [selectedServiceId, services]);
|
||||||
|
|
||||||
|
const selectedService = useMemo(
|
||||||
|
() => services.find((service) => service.id === selectedServiceId) ?? null,
|
||||||
|
[selectedServiceId, services],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: deploymentCount } = useQuery({
|
||||||
|
queryKey: ['project-deployment-count', projectId, services.map((service) => service.id).join(',')],
|
||||||
|
queryFn: async () => {
|
||||||
|
const counts = await Promise.all(
|
||||||
|
services.map(async (service) => {
|
||||||
|
const response = await deploymentsApi.getDeployments(service.id);
|
||||||
|
return response.deployments.length;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return counts.reduce((sum, count) => sum + count, 0);
|
||||||
|
},
|
||||||
|
enabled: !!projectId && services.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createServiceMutation = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
if (!project) {
|
||||||
|
throw new Error('Project not loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
return servicesApi.createService(project.id, {
|
||||||
|
project_id: project.id,
|
||||||
|
name: serviceForm.name.trim(),
|
||||||
|
type: serviceForm.type,
|
||||||
|
environment: serviceForm.environment,
|
||||||
|
image: serviceForm.image.trim() || undefined,
|
||||||
|
command: serviceForm.command.trim() || undefined,
|
||||||
|
git_repo: serviceForm.git_repo.trim() || undefined,
|
||||||
|
git_branch: serviceForm.git_branch.trim() || undefined,
|
||||||
|
build_path: serviceForm.build_path.trim() || undefined,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['services', projectId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
|
setIsCreateServiceOpen(false);
|
||||||
|
setServiceForm({
|
||||||
|
name: '',
|
||||||
|
type: 'web',
|
||||||
|
environment: 'production',
|
||||||
|
image: '',
|
||||||
|
command: '',
|
||||||
|
git_repo: '',
|
||||||
|
git_branch: 'main',
|
||||||
|
build_path: '.',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const runningServices = services.filter((service) => service.status === 'running').length;
|
||||||
|
|
||||||
|
if (projectLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="animate-pulse space-y-4">
|
<div className="animate-pulse space-y-4">
|
||||||
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
|
<div className="h-8 bg-gray-200 rounded w-1/4" />
|
||||||
<div className="h-32 bg-gray-200 rounded-lg"></div>
|
<div className="h-32 bg-gray-200 rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -57,7 +157,9 @@ export default function ProjectDetailPage() {
|
|||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<h2 className="text-2xl font-semibold text-gray-900">Project not found</h2>
|
<h2 className="text-2xl font-semibold text-gray-900">Project not found</h2>
|
||||||
<p className="text-gray-600 mt-2">The project you're looking for doesn't exist or you don't have access to it.</p>
|
<p className="text-gray-600 mt-2">
|
||||||
|
The project you're looking for doesn't exist or you don't have access to it.
|
||||||
|
</p>
|
||||||
<Button onClick={() => navigate('/projects')} className="mt-4">
|
<Button onClick={() => navigate('/projects')} className="mt-4">
|
||||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
Back to Projects
|
Back to Projects
|
||||||
@@ -67,7 +169,7 @@ export default function ProjectDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs: Array<{ id: Tab; label: string; icon: typeof Activity }> = [
|
||||||
{ id: 'overview', label: 'Overview', icon: Activity },
|
{ id: 'overview', label: 'Overview', icon: Activity },
|
||||||
{ id: 'services', label: 'Services', icon: Database },
|
{ id: 'services', label: 'Services', icon: Database },
|
||||||
{ id: 'preview', label: 'Preview Environments', icon: TestTube },
|
{ id: 'preview', label: 'Preview Environments', icon: TestTube },
|
||||||
@@ -76,43 +178,35 @@ export default function ProjectDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button
|
<Button variant="ghost" size="icon" onClick={() => navigate('/projects')}>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => navigate('/projects')}
|
|
||||||
>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl md:text-3xl font-bold text-foreground">{project.name}</h1>
|
<h1 className="text-2xl md:text-3xl font-bold text-foreground">{project.name}</h1>
|
||||||
<p className="text-sm md:text-base text-muted-foreground">
|
<p className="text-sm md:text-base text-muted-foreground">{project.description || 'No description'}</p>
|
||||||
{project.description || 'No description'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline">
|
<Button variant="outline" onClick={() => setActiveTab('settings')}>
|
||||||
<Settings className="w-4 h-4 mr-2" />
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
Settings
|
Settings
|
||||||
</Button>
|
</Button>
|
||||||
<Button>
|
<Button onClick={() => setIsCreateServiceOpen(true)}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Add Service
|
Add Service
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Project Stats */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Database className="w-5 h-5 text-blue-500" />
|
<Database className="w-5 h-5 text-blue-500" />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-bold">3</div>
|
<div className="text-2xl font-bold">{services.length}</div>
|
||||||
<div className="text-sm text-muted-foreground">Services</div>
|
<div className="text-sm text-muted-foreground">Services</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,7 +217,7 @@ export default function ProjectDetailPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<GitBranch className="w-5 h-5 text-green-500" />
|
<GitBranch className="w-5 h-5 text-green-500" />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-bold">12</div>
|
<div className="text-2xl font-bold">{deploymentCount ?? 0}</div>
|
||||||
<div className="text-sm text-muted-foreground">Deployments</div>
|
<div className="text-sm text-muted-foreground">Deployments</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,10 +226,10 @@ export default function ProjectDetailPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Users className="w-5 h-5 text-purple-500" />
|
<Activity className="w-5 h-5 text-violet-500" />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-bold">2</div>
|
<div className="text-2xl font-bold">{runningServices}</div>
|
||||||
<div className="text-sm text-muted-foreground">Members</div>
|
<div className="text-sm text-muted-foreground">Running Services</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -145,7 +239,7 @@ export default function ProjectDetailPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Calendar className="w-5 h-5 text-orange-500" />
|
<Calendar className="w-5 h-5 text-orange-500" />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-2xl font-bold">
|
<div className="text-sm font-bold">
|
||||||
{formatDistanceToNow(new Date(project.created_at), { addSuffix: true })}
|
{formatDistanceToNow(new Date(project.created_at), { addSuffix: true })}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground">Created</div>
|
<div className="text-sm text-muted-foreground">Created</div>
|
||||||
@@ -155,7 +249,6 @@ export default function ProjectDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation Tabs */}
|
|
||||||
<div className="border-b">
|
<div className="border-b">
|
||||||
<nav className="flex space-x-8">
|
<nav className="flex space-x-8">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
@@ -163,7 +256,7 @@ export default function ProjectDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id as any)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={`flex items-center gap-2 py-2 px-1 border-b-2 font-medium text-sm ${
|
className={`flex items-center gap-2 py-2 px-1 border-b-2 font-medium text-sm ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-blue-500 text-blue-600'
|
? 'border-blue-500 text-blue-600'
|
||||||
@@ -178,71 +271,232 @@ export default function ProjectDetailPage() {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Content */}
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{activeTab === 'overview' && (
|
{activeTab === 'overview' && (
|
||||||
<div className="space-y-6">
|
<Card>
|
||||||
<Card>
|
<CardHeader>
|
||||||
<CardHeader>
|
<CardTitle>Project Overview</CardTitle>
|
||||||
<CardTitle>Project Overview</CardTitle>
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent>
|
||||||
<CardContent>
|
<p className="text-sm text-muted-foreground">
|
||||||
<div className="space-y-4">
|
{services.length > 0
|
||||||
<div>
|
? `This project currently has ${services.length} service(s), with ${runningServices} running.`
|
||||||
<h4 className="font-medium mb-2">Recent Activity</h4>
|
: 'No services yet. Use "Add Service" to create your first service.'}
|
||||||
<div className="space-y-2">
|
</p>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
</CardContent>
|
||||||
<GitBranch className="w-4 h-4" />
|
</Card>
|
||||||
<span>Deployed main branch to production</span>
|
|
||||||
<span>• 2 hours ago</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<TestTube className="w-4 h-4" />
|
|
||||||
<span>Created preview environment for feature/new-ui</span>
|
|
||||||
<span>• 5 hours ago</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Database className="w-4 h-4" />
|
|
||||||
<span>Added PostgreSQL database</span>
|
|
||||||
<span>• 1 day ago</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<ProjectCanvas />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'services' && (
|
{activeTab === 'services' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<ProjectCanvas />
|
{servicesLoading ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-10 flex items-center justify-center">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : services.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-10 text-center text-muted-foreground">
|
||||||
|
No services configured yet.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Services</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
{services.map((service: Service) => (
|
||||||
|
<button
|
||||||
|
key={service.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedServiceId(service.id)}
|
||||||
|
className={`w-full text-left p-3 rounded-md border transition-colors ${
|
||||||
|
selectedServiceId === service.id
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:bg-muted/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Server className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{service.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{service.type} · {service.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{selectedService && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<DeploymentsPanel serviceId={selectedService.id} serviceName={selectedService.name} />
|
||||||
|
<EnvVariablesEditor serviceId={selectedService.id} />
|
||||||
|
<ServiceLogs serviceId={selectedService.id} serviceName={selectedService.name} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'preview' && (
|
{activeTab === 'preview' && <PreviewEnvironments projectId={project.id} />}
|
||||||
<PreviewEnvironments projectId={project.id} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'settings' && (
|
{activeTab === 'settings' && (
|
||||||
<div className="space-y-6">
|
<Card>
|
||||||
<Card>
|
<CardHeader>
|
||||||
<CardHeader>
|
<CardTitle>Project Settings</CardTitle>
|
||||||
<CardTitle>Project Settings</CardTitle>
|
</CardHeader>
|
||||||
</CardHeader>
|
<CardContent>
|
||||||
<CardContent>
|
<p className="text-muted-foreground">
|
||||||
<div className="space-y-4">
|
Project settings and configuration options will be available here.
|
||||||
<p className="text-muted-foreground">
|
</p>
|
||||||
Project settings and configuration options will be available here.
|
</CardContent>
|
||||||
</p>
|
</Card>
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isCreateServiceOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
|
<Card className="w-full max-w-lg">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create Service</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="service-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="service-name"
|
||||||
|
value={serviceForm.name}
|
||||||
|
onChange={(e) => setServiceForm((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
placeholder="api-service"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="service-type">Type</Label>
|
||||||
|
<select
|
||||||
|
id="service-type"
|
||||||
|
className="mt-1 w-full p-2 border rounded-md bg-background"
|
||||||
|
value={serviceForm.type}
|
||||||
|
onChange={(e) =>
|
||||||
|
setServiceForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
type: e.target.value as CreateServiceRequest['type'],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{serviceTypeOptions.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{type}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="service-env">Environment</Label>
|
||||||
|
<select
|
||||||
|
id="service-env"
|
||||||
|
className="mt-1 w-full p-2 border rounded-md bg-background"
|
||||||
|
value={serviceForm.environment}
|
||||||
|
onChange={(e) =>
|
||||||
|
setServiceForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
environment: e.target.value as CreateServiceRequest['environment'],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{serviceEnvOptions.map((env) => (
|
||||||
|
<option key={env} value={env}>
|
||||||
|
{env}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="service-image">Image (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="service-image"
|
||||||
|
value={serviceForm.image}
|
||||||
|
onChange={(e) => setServiceForm((prev) => ({ ...prev, image: e.target.value }))}
|
||||||
|
placeholder="nginx:latest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="service-command">Command (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="service-command"
|
||||||
|
value={serviceForm.command}
|
||||||
|
onChange={(e) => setServiceForm((prev) => ({ ...prev, command: e.target.value }))}
|
||||||
|
placeholder="npm start"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="service-repo">Git Repository (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="service-repo"
|
||||||
|
value={serviceForm.git_repo}
|
||||||
|
onChange={(e) => setServiceForm((prev) => ({ ...prev, git_repo: e.target.value }))}
|
||||||
|
placeholder="org/repo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="service-branch">Git Branch</Label>
|
||||||
|
<Input
|
||||||
|
id="service-branch"
|
||||||
|
value={serviceForm.git_branch}
|
||||||
|
onChange={(e) => setServiceForm((prev) => ({ ...prev, git_branch: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="service-build-path">Build Path</Label>
|
||||||
|
<Input
|
||||||
|
id="service-build-path"
|
||||||
|
value={serviceForm.build_path}
|
||||||
|
onChange={(e) => setServiceForm((prev) => ({ ...prev, build_path: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="ghost" onClick={() => setIsCreateServiceOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => createServiceMutation.mutate()}
|
||||||
|
disabled={!serviceForm.name.trim() || createServiceMutation.isPending}
|
||||||
|
>
|
||||||
|
{createServiceMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Create Service
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { projectsApi, api } from '@/lib/api';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { PageHeader } from '@/components/ui/page-header';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Shield, AlertTriangle, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SecurityMetrics {
|
||||||
|
vulnerabilities: {
|
||||||
|
total: number;
|
||||||
|
critical: number;
|
||||||
|
high: number;
|
||||||
|
medium: number;
|
||||||
|
low: number;
|
||||||
|
open: number;
|
||||||
|
resolved: number;
|
||||||
|
};
|
||||||
|
latest_scan: {
|
||||||
|
id: string;
|
||||||
|
score: number;
|
||||||
|
scanned_at: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
compliance: {
|
||||||
|
overall_status: string;
|
||||||
|
score: number;
|
||||||
|
last_assessed?: string;
|
||||||
|
};
|
||||||
|
security_score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Vulnerability {
|
||||||
|
id: string;
|
||||||
|
severity: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
found_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SecurityPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [projectId, setProjectId] = useState('');
|
||||||
|
|
||||||
|
const { data: projectsData } = useQuery({
|
||||||
|
queryKey: ['projects', 'security-selector'],
|
||||||
|
queryFn: () => projectsApi.getProjects({ page: 1, limit: 100 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const projects = projectsData?.projects ?? [];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId && projects.length > 0) {
|
||||||
|
setProjectId(projects[0].id);
|
||||||
|
}
|
||||||
|
}, [projectId, projects]);
|
||||||
|
|
||||||
|
const metricsQuery = useQuery({
|
||||||
|
queryKey: ['security-metrics', projectId],
|
||||||
|
queryFn: () => api.get<SecurityMetrics>(`/api/v1/projects/${projectId}/security/metrics`),
|
||||||
|
enabled: !!projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const vulnerabilitiesQuery = useQuery({
|
||||||
|
queryKey: ['security-vulnerabilities', projectId],
|
||||||
|
queryFn: () =>
|
||||||
|
api.get<{ vulnerabilities: Vulnerability[] }>(
|
||||||
|
`/api/v1/projects/${projectId}/vulnerabilities`,
|
||||||
|
),
|
||||||
|
enabled: !!projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const startScanMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
api.post<{ id: string }>('/api/v1/security/scans', {
|
||||||
|
project_id: projectId,
|
||||||
|
scan_type: 'comprehensive',
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['security-metrics', projectId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['security-vulnerabilities', projectId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentVulnerabilities = useMemo(
|
||||||
|
() => (vulnerabilitiesQuery.data?.vulnerabilities ?? []).slice(0, 8),
|
||||||
|
[vulnerabilitiesQuery.data?.vulnerabilities],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 md:p-6 lg:p-8 space-y-8">
|
||||||
|
<PageHeader
|
||||||
|
title="Security"
|
||||||
|
description="Project-level vulnerability and security health overview."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Project Scope</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col md:flex-row gap-4 items-start md:items-end">
|
||||||
|
<div className="w-full md:max-w-sm space-y-2">
|
||||||
|
<Label htmlFor="security-project">Project</Label>
|
||||||
|
<select
|
||||||
|
id="security-project"
|
||||||
|
className="w-full h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
value={projectId}
|
||||||
|
onChange={(e) => setProjectId(e.target.value)}
|
||||||
|
>
|
||||||
|
{projects.length === 0 && <option value="">No projects available</option>}
|
||||||
|
{projects.map((project) => (
|
||||||
|
<option key={project.id} value={project.id}>
|
||||||
|
{project.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => startScanMutation.mutate()}
|
||||||
|
disabled={!projectId || startScanMutation.isPending}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
{startScanMutation.isPending ? 'Starting Scan...' : 'Start Comprehensive Scan'}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{metricsQuery.isError && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-6 text-sm text-destructive">
|
||||||
|
Failed to load security metrics for the selected project.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metricsQuery.data && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Security Score</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-2xl font-bold">
|
||||||
|
{metricsQuery.data.security_score}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Open Vulnerabilities</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-2xl font-bold">
|
||||||
|
{metricsQuery.data.vulnerabilities.open}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Critical / High</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-2xl font-bold">
|
||||||
|
{metricsQuery.data.vulnerabilities.critical} / {metricsQuery.data.vulnerabilities.high}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">Compliance Score</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-2xl font-bold">
|
||||||
|
{metricsQuery.data.compliance.score}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Recent Vulnerabilities</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{vulnerabilitiesQuery.isLoading && <div className="text-sm text-muted-foreground">Loading...</div>}
|
||||||
|
{!vulnerabilitiesQuery.isLoading && recentVulnerabilities.length === 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Shield className="w-4 h-4" />
|
||||||
|
No vulnerabilities found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{recentVulnerabilities.map((vuln) => (
|
||||||
|
<div key={vuln.id} className="flex items-center justify-between p-3 rounded-md border">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{vuln.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Found {new Date(vuln.found_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-3">
|
||||||
|
<Badge variant={vuln.status === 'resolved' ? 'default' : 'secondary'}>
|
||||||
|
{vuln.status}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant={vuln.severity === 'critical' || vuln.severity === 'high' ? 'destructive' : 'outline'}
|
||||||
|
>
|
||||||
|
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||||
|
{vuln.severity}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
+46
-16
@@ -158,12 +158,17 @@ export interface UpdateServiceRequest {
|
|||||||
export interface Deployment {
|
export interface Deployment {
|
||||||
id: string;
|
id: string;
|
||||||
service_id: string;
|
service_id: string;
|
||||||
commit_hash?: string;
|
commit_hash?: string | null;
|
||||||
status: 'building' | 'deployed' | 'failed' | 'rolling_back';
|
status: 'pending' | 'building' | 'deploying' | 'deployed' | 'failed' | 'rolling_back';
|
||||||
|
image_name: string;
|
||||||
|
image_tag: string;
|
||||||
|
build_log: string;
|
||||||
|
runtime_log: string;
|
||||||
|
error?: string | null;
|
||||||
|
started_at?: string | null;
|
||||||
|
completed_at?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
build_logs?: string[];
|
|
||||||
runtime_logs?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnvironmentVariable {
|
export interface EnvironmentVariable {
|
||||||
@@ -171,6 +176,7 @@ export interface EnvironmentVariable {
|
|||||||
service_id: string;
|
service_id: string;
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
|
is_secret?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -179,6 +185,7 @@ export interface Pagination {
|
|||||||
page: number;
|
page: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
pages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaginatedResponse<T> {
|
interface PaginatedResponse<T> {
|
||||||
@@ -194,28 +201,41 @@ export interface AuthResponse {
|
|||||||
export interface PreviewEnvironment {
|
export interface PreviewEnvironment {
|
||||||
id: string;
|
id: string;
|
||||||
project_id: string;
|
project_id: string;
|
||||||
name: string;
|
service_id: string;
|
||||||
branch: string;
|
branch_name: string;
|
||||||
commit_hash: string;
|
pr_number?: number | null;
|
||||||
status: 'building' | 'running' | 'failed' | 'stopped';
|
environment: string;
|
||||||
|
status: 'building' | 'running' | 'failed' | 'stopped' | 'expired';
|
||||||
url: string;
|
url: string;
|
||||||
expires_at: string;
|
expires_at?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
service?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'web' | 'worker' | 'database' | 'cron';
|
||||||
|
};
|
||||||
|
deployment_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePreviewEnvironmentRequest {
|
export interface CreatePreviewEnvironmentRequest {
|
||||||
branch: string;
|
project_id?: string;
|
||||||
commit_hash: string;
|
service_id: string;
|
||||||
name?: string;
|
branch_name: string;
|
||||||
|
pr_number?: number;
|
||||||
|
ttl_hours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdatePreviewEnvironmentRequest {
|
export interface UpdatePreviewEnvironmentRequest {
|
||||||
name?: string;
|
status?: 'building' | 'running' | 'failed' | 'stopped' | 'expired';
|
||||||
|
url?: string;
|
||||||
expires_at?: string;
|
expires_at?: string;
|
||||||
|
ttl_hours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromotePreviewRequest {
|
export interface PromotePreviewRequest {
|
||||||
target_environment: 'production' | 'staging';
|
target_environment: 'production' | 'development';
|
||||||
|
create_backup?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitProvider {
|
export interface GitProvider {
|
||||||
@@ -228,10 +248,18 @@ export interface GitProvider {
|
|||||||
export interface GitRepository {
|
export interface GitRepository {
|
||||||
id: string;
|
id: string;
|
||||||
provider_id: string;
|
provider_id: string;
|
||||||
|
name: string;
|
||||||
full_name: string;
|
full_name: string;
|
||||||
|
description?: string;
|
||||||
clone_url: string;
|
clone_url: string;
|
||||||
connected: boolean;
|
default_branch: string;
|
||||||
|
is_private: boolean;
|
||||||
|
provider?: {
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
};
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateWebhookRequest {
|
export interface CreateWebhookRequest {
|
||||||
@@ -334,7 +362,9 @@ export interface DeployFromTemplateRequest {
|
|||||||
|
|
||||||
export interface CreateDeploymentRequest {
|
export interface CreateDeploymentRequest {
|
||||||
commit_hash?: string;
|
commit_hash?: string;
|
||||||
message?: string;
|
branch?: string;
|
||||||
|
trigger?: 'manual' | 'webhook' | 'api' | string;
|
||||||
|
env_vars?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateProviderRequest {
|
export interface CreateProviderRequest {
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": false,
|
"noEmit": true,
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"files": ["vite.config.ts"],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": false,
|
"noEmit": true,
|
||||||
"composite": true,
|
"composite": true,
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
|
|||||||
Reference in New Issue
Block a user