This commit is contained in:
Tomas Dvorak
2026-02-26 09:41:42 +01:00
parent fc57db2217
commit 08bd0c6e5c
37 changed files with 1471 additions and 529 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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
+23
View File
@@ -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
View File
@@ -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 {
+13
View File
@@ -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 ""
}
+43 -14
View File
@@ -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,
}
} }
} }
+6 -1
View File
@@ -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
View File
@@ -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 {
+17 -3
View File
@@ -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)
+8 -7
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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"`
} }
+12
View File
@@ -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)
+11 -6
View File
@@ -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),
+1 -1
View File
@@ -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
+2 -22
View File
@@ -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": {
-1
View File
@@ -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",
+1
View File
@@ -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(),
+4
View File
@@ -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>
+1
View File
@@ -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 }[]) => {
+29 -67
View File
@@ -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>
); );
} }
+50 -16
View File
@@ -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>
+11 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>,
) )
+15
View File
@@ -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>
);
}
+1 -1
View File
@@ -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
View File
@@ -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&apos;re looking for doesn&apos;t exist or you don&apos;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>
); );
} }
+214
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 */