diff --git a/app/backend/internal/api/auth.go b/app/backend/internal/api/auth.go index 813b21f..eba13b5 100644 --- a/app/backend/internal/api/auth.go +++ b/app/backend/internal/api/auth.go @@ -256,17 +256,23 @@ func createLocalUserFromBetterAuth(db *database.DB, authUser betterAuthLoginUser return User{}, err } + // A mirrored first account owns the platform, same as handleRegister. + var total int + if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&total); err != nil { + return User{}, err + } + var user User err = db.QueryRow(` - INSERT INTO users (email, password_hash, name, avatar_url) - VALUES ($1, $2, $3, NULLIF($4, '')) + INSERT INTO users (email, password_hash, name, avatar_url, is_admin) + VALUES ($1, $2, $3, NULLIF($4, ''), $5) ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name, avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url), updated_at = NOW() - RETURNING id, email, name, COALESCE(avatar_url, ''), created_at - `, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image)). - Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt) + RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at + `, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image), total == 0). + Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt) if err != nil { return User{}, err } @@ -320,8 +326,8 @@ func handleRegister(c *gin.Context) { err = db.QueryRow(` INSERT INTO users (email, password_hash, name, is_admin) VALUES ($1, $2, $3, $4) - RETURNING id, email, name, COALESCE(avatar_url, ''), created_at - `, req.Email, string(hashedPassword), req.Name, total == 0).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt) + RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at + `, req.Email, string(hashedPassword), req.Name, total == 0).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) @@ -388,8 +394,8 @@ func handleCreateUser(c *gin.Context) { err = db.QueryRow(` INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) - RETURNING id, email, name, COALESCE(avatar_url, ''), created_at - `, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt) + RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at + `, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt) if err != nil { c.JSON(http.StatusConflict, gin.H{"error": "User already exists"}) return diff --git a/app/backend/internal/api/deployments.go b/app/backend/internal/api/deployments.go index 4bc5892..d63a372 100644 --- a/app/backend/internal/api/deployments.go +++ b/app/backend/internal/api/deployments.go @@ -6,6 +6,7 @@ import ( "context" "fmt" "net/http" + "strconv" "strings" "time" @@ -120,6 +121,72 @@ func handleGetDeployments(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"deployments": deployments}) } +// RecentDeployment is a deployment row joined with its service and project names. +type RecentDeployment struct { + ID uuid.UUID `json:"id"` + ServiceID uuid.UUID `json:"service_id"` + ServiceName string `json:"service_name"` + ProjectName string `json:"project_name"` + Status string `json:"status"` + ImageName string `json:"image_name"` + StartedAt *time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at"` + CreatedAt time.Time `json:"created_at"` +} + +// handleGetRecentDeployments lists the latest deployments across all of the +// caller's services — feeds the dashboard deploy feed. +func handleGetRecentDeployments(c *gin.Context) { + db, exists := c.Get("db") + if !exists { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"}) + return + } + + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + limit := 10 + if v := c.Query("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 50 { + limit = n + } + } + + rows, err := db.(*database.DB).Query( + `SELECT d.id, d.service_id, s.name, p.name, d.status, + COALESCE(d.image_name, ''), d.started_at, d.completed_at, d.created_at + FROM deployments d + JOIN services s ON s.id = d.service_id + JOIN projects p ON p.id = s.project_id + WHERE p.owner_id = $1 + ORDER BY d.created_at DESC + LIMIT $2`, + userID.(string), limit, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve deployments"}) + return + } + defer rows.Close() + + deployments := []RecentDeployment{} + for rows.Next() { + var d RecentDeployment + if err := rows.Scan(&d.ID, &d.ServiceID, &d.ServiceName, &d.ProjectName, + &d.Status, &d.ImageName, &d.StartedAt, &d.CompletedAt, &d.CreatedAt); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan deployment"}) + return + } + deployments = append(deployments, d) + } + + c.JSON(http.StatusOK, gin.H{"deployments": deployments}) +} + func handleCreateDeployment(c *gin.Context) { db, exists := c.Get("db") if !exists { diff --git a/app/backend/internal/api/production_behavior_test.go b/app/backend/internal/api/production_behavior_test.go index 8dd31e0..b05accf 100644 --- a/app/backend/internal/api/production_behavior_test.go +++ b/app/backend/internal/api/production_behavior_test.go @@ -6,6 +6,8 @@ import ( "net/http/httptest" "testing" + "containr/internal/database" + "github.com/gin-gonic/gin" ) @@ -86,3 +88,30 @@ func TestRequireAuthenticatedUserIDRejectsMissingUserContext(t *testing.T) { t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code) } } + +func TestRecentDeploymentsRequireAuthentication(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.GET("/api/v1/deployments", handleGetRecentDeployments) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/deployments", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + // No db in context → dependency failure surfaces before auth. + if rec.Code != http.StatusInternalServerError { + t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, rec.Code) + } + + // With db present but no user context → 401. + router2 := gin.New() + router2.GET("/api/v1/deployments", func(c *gin.Context) { + c.Set("db", &database.DB{}) + handleGetRecentDeployments(c) + }) + rec2 := httptest.NewRecorder() + router2.ServeHTTP(rec2, httptest.NewRequest(http.MethodGet, "/api/v1/deployments", nil)) + if rec2.Code != http.StatusUnauthorized { + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec2.Code) + } +} diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 2f93e39..3cbb142 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -219,6 +219,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // Deployment routes protected.GET("/services/:id/deployments", handleGetDeployments) protected.POST("/services/:id/deployments", handleCreateDeployment) + protected.GET("/deployments", handleGetRecentDeployments) protected.GET("/deployments/:id", handleGetDeployment) protected.POST("/deployments/:id/rollback", handleRollbackDeployment) diff --git a/app/frontend/src/features/projects/pages/ProjectsPage.tsx b/app/frontend/src/features/projects/pages/ProjectsPage.tsx index 106a679..a0e82d8 100644 --- a/app/frontend/src/features/projects/pages/ProjectsPage.tsx +++ b/app/frontend/src/features/projects/pages/ProjectsPage.tsx @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { createProject, getHostMonitoring, - listBuilds, + listRecentDeployments, listProjects, type ProjectEntity, type ProjectStats, @@ -156,10 +156,10 @@ export function ProjectsPage() { queryFn: getHostMonitoring, refetchInterval: 30_000, }); - const buildsQuery = useQuery({ + const deploysQuery = useQuery({ queryKey: ['recent-deploys'], enabled: !isDemoMode, - queryFn: () => listBuilds({ page: 1, limit: 5 }), + queryFn: () => listRecentDeployments(10), refetchInterval: 30_000, }); @@ -193,16 +193,16 @@ export function ProjectsPage() { const memPct = isDemoMode ? 72 : host ? Math.round(host.memory.usagePercent) : null; const diskPct = isDemoMode ? 73 : host ? Math.round(host.storage.usagePercent) : null; const fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`; - const builds = isDemoMode ? [] : buildsQuery.data?.builds ?? []; - const deployCount = buildsQuery.data?.total ?? builds.length; + const deployments = isDemoMode ? [] : deploysQuery.data ?? []; + const deployCount = deployments.length; const feed = isDemoMode ? demoDeploys - : builds.map((b) => ({ - id: b.id, - name: b.imageName || b.serviceId || b.id, - project: '', - status: (b.status || 'queued').toUpperCase(), - when: formatRelative(b.startedAt ?? b.completedAt), + : deployments.slice(0, 5).map((d) => ({ + id: d.id, + name: d.serviceName || d.imageName || d.serviceId || d.id, + project: d.projectName, + status: (d.status || 'queued').toUpperCase(), + when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt), })); const statusClass = (status: string) => @@ -278,7 +278,7 @@ export function ProjectsPage() {