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() {
DEPLOYS
{isDemoMode ? 18 : deployCount}
-
{isDemoMode ? '17 ok' : `${builds.filter((b) => (b.status ?? '').toLowerCase() !== 'failed').length} recent`}
+
{isDemoMode ? '17 ok' : `${deployments.filter((d) => (d.status ?? '').toLowerCase() !== 'failed').length} recent`}
diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index b6e383e..91231b7 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -886,6 +886,59 @@ export interface paths { patch?: never; trace?: never; }; + "/deployments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List recent deployments + * @description Latest deployments across all services owned by the caller — feeds the dashboard deploy feed + */ + get: { + parameters: { + query?: { + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Recent deployments */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + deployments?: { + id?: string; + service_id?: string; + service_name?: string; + project_name?: string; + status?: string; + image_name?: string; + started_at?: string | null; + completed_at?: string | null; + created_at?: string; + }[]; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/deployments/{id}": { parameters: { query?: never; diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index ed73559..2e6793c 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1146,6 +1146,37 @@ export async function listDeployments(serviceId: string): Promise { + const payload = await requestJson<{ deployments?: Array> }>( + `/deployments?limit=${limit}`, + ); + return (payload.deployments ?? []) + .map((d) => ({ + id: String(d.id ?? ''), + serviceId: String(d.service_id ?? ''), + serviceName: String(d.service_name ?? ''), + projectName: String(d.project_name ?? ''), + status: String(d.status ?? ''), + imageName: d.image_name ? String(d.image_name) : undefined, + startedAt: d.started_at ? String(d.started_at) : undefined, + completedAt: d.completed_at ? String(d.completed_at) : undefined, + createdAt: d.created_at ? String(d.created_at) : undefined, + })) + .filter((d) => d.id !== ''); +} + export async function createDeployment( serviceId: string, input: CreateDeploymentInput = {}, diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 23fa18b..a19da39 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -595,6 +595,56 @@ paths: schema: $ref: '#/components/schemas/Deployment' + /deployments: + get: + tags: + - Deployments + summary: List recent deployments + description: Latest deployments across all services owned by the caller — feeds the dashboard deploy feed + security: + - bearerAuth: [] + parameters: + - name: limit + in: query + required: false + schema: + type: integer + default: 10 + maximum: 50 + responses: + '200': + description: Recent deployments + content: + application/json: + schema: + type: object + properties: + deployments: + type: array + items: + type: object + properties: + id: + type: string + service_id: + type: string + service_name: + type: string + project_name: + type: string + status: + type: string + image_name: + type: string + started_at: + type: string + nullable: true + completed_at: + type: string + nullable: true + created_at: + type: string + /deployments/{id}: get: tags: