Add global recent-deployments feed for the projects dashboard

The RECENT DEPLOYMENTS section queried the builds table, so
image-sourced services — which deploy without building — never
appeared and the dashboard reported "no deployments yet" while
containers were running.

- GET /api/v1/deployments lists latest deployments across the
  caller's services, joined with service + project names, ordered
  newest-first with a bounded limit
- ProjectsPage consumes it via listRecentDeployments; the DEPLOYS
  stat card now counts real deployments
- is_admin is now projected in register/upsert user responses, and
  the Better Auth mirror grants admin to the first mirrored account
  (previously only the legacy register path did)

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-21 21:07:01 +02:00
1 parent b7e2e93261
commit 3bf44dbcd5
8 files changed
+258 -21

No files matched your search

+15 -9
View File
@@ -256,17 +256,23 @@ func createLocalUserFromBetterAuth(db *database.DB, authUser betterAuthLoginUser
return User{}, err 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 var user User
err = db.QueryRow(` err = db.QueryRow(`
INSERT INTO users (email, password_hash, name, avatar_url) INSERT INTO users (email, password_hash, name, avatar_url, is_admin)
VALUES ($1, $2, $3, NULLIF($4, '')) VALUES ($1, $2, $3, NULLIF($4, ''), $5)
ON CONFLICT (email) DO UPDATE ON CONFLICT (email) DO UPDATE
SET name = EXCLUDED.name, SET name = EXCLUDED.name,
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url), avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
updated_at = NOW() updated_at = NOW()
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at RETURNING id, email, name, COALESCE(avatar_url, ''), is_admin, created_at
`, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image)). `, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image), total == 0).
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt) Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
if err != nil { if err != nil {
return User{}, err return User{}, err
} }
@@ -320,8 +326,8 @@ func handleRegister(c *gin.Context) {
err = db.QueryRow(` err = db.QueryRow(`
INSERT INTO users (email, password_hash, name, is_admin) INSERT INTO users (email, password_hash, name, is_admin)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at 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.CreatedAt) `, req.Email, string(hashedPassword), req.Name, total == 0).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user"})
@@ -388,8 +394,8 @@ func handleCreateUser(c *gin.Context) {
err = db.QueryRow(` err = db.QueryRow(`
INSERT INTO users (email, password_hash, name) INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at 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.CreatedAt) `, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.IsAdmin, &user.CreatedAt)
if err != nil { if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"}) c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
return return
+67
View File
@@ -6,6 +6,7 @@ import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -120,6 +121,72 @@ func handleGetDeployments(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"deployments": deployments}) 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) { func handleCreateDeployment(c *gin.Context) {
db, exists := c.Get("db") db, exists := c.Get("db")
if !exists { if !exists {
@@ -6,6 +6,8 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"containr/internal/database"
"github.com/gin-gonic/gin" "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) 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)
}
}
+1
View File
@@ -219,6 +219,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
// Deployment routes // Deployment routes
protected.GET("/services/:id/deployments", handleGetDeployments) protected.GET("/services/:id/deployments", handleGetDeployments)
protected.POST("/services/:id/deployments", handleCreateDeployment) protected.POST("/services/:id/deployments", handleCreateDeployment)
protected.GET("/deployments", handleGetRecentDeployments)
protected.GET("/deployments/:id", handleGetDeployment) protected.GET("/deployments/:id", handleGetDeployment)
protected.POST("/deployments/:id/rollback", handleRollbackDeployment) protected.POST("/deployments/:id/rollback", handleRollbackDeployment)
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
createProject, createProject,
getHostMonitoring, getHostMonitoring,
listBuilds, listRecentDeployments,
listProjects, listProjects,
type ProjectEntity, type ProjectEntity,
type ProjectStats, type ProjectStats,
@@ -156,10 +156,10 @@ export function ProjectsPage() {
queryFn: getHostMonitoring, queryFn: getHostMonitoring,
refetchInterval: 30_000, refetchInterval: 30_000,
}); });
const buildsQuery = useQuery({ const deploysQuery = useQuery({
queryKey: ['recent-deploys'], queryKey: ['recent-deploys'],
enabled: !isDemoMode, enabled: !isDemoMode,
queryFn: () => listBuilds({ page: 1, limit: 5 }), queryFn: () => listRecentDeployments(10),
refetchInterval: 30_000, refetchInterval: 30_000,
}); });
@@ -193,16 +193,16 @@ export function ProjectsPage() {
const memPct = isDemoMode ? 72 : host ? Math.round(host.memory.usagePercent) : null; const memPct = isDemoMode ? 72 : host ? Math.round(host.memory.usagePercent) : null;
const diskPct = isDemoMode ? 73 : host ? Math.round(host.storage.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 fmtGB = (v: number) => `${(v / (1024 * 1024 * 1024)).toFixed(0)}G`;
const builds = isDemoMode ? [] : buildsQuery.data?.builds ?? []; const deployments = isDemoMode ? [] : deploysQuery.data ?? [];
const deployCount = buildsQuery.data?.total ?? builds.length; const deployCount = deployments.length;
const feed = isDemoMode const feed = isDemoMode
? demoDeploys ? demoDeploys
: builds.map((b) => ({ : deployments.slice(0, 5).map((d) => ({
id: b.id, id: d.id,
name: b.imageName || b.serviceId || b.id, name: d.serviceName || d.imageName || d.serviceId || d.id,
project: '', project: d.projectName,
status: (b.status || 'queued').toUpperCase(), status: (d.status || 'queued').toUpperCase(),
when: formatRelative(b.startedAt ?? b.completedAt), when: formatRelative(d.startedAt ?? d.completedAt ?? d.createdAt),
})); }));
const statusClass = (status: string) => const statusClass = (status: string) =>
@@ -278,7 +278,7 @@ export function ProjectsPage() {
<div className="v-k"><span>DEPLOYS</span></div> <div className="v-k"><span>DEPLOYS</span></div>
<div className="v-v">{isDemoMode ? 18 : deployCount}</div> <div className="v-v">{isDemoMode ? 18 : deployCount}</div>
<div className="v-meter"><i style={{ width: '94%' }} /></div> <div className="v-meter"><i style={{ width: '94%' }} /></div>
<div className="v-d"><b style={{ color: 'var(--success)' }}>{isDemoMode ? '17 ok' : `${builds.filter((b) => (b.status ?? '').toLowerCase() !== 'failed').length} recent`}</b></div> <div className="v-d"><b style={{ color: 'var(--success)' }}>{isDemoMode ? '17 ok' : `${deployments.filter((d) => (d.status ?? '').toLowerCase() !== 'failed').length} recent`}</b></div>
</div> </div>
</div> </div>
+53
View File
@@ -886,6 +886,59 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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}": { "/deployments/{id}": {
parameters: { parameters: {
query?: never; query?: never;
+31
View File
@@ -1146,6 +1146,37 @@ export async function listDeployments(serviceId: string): Promise<DeploymentEnti
return normalizeDeploymentArray(payload.deployments); return normalizeDeploymentArray(payload.deployments);
} }
export type RecentDeploymentEntity = {
id: string;
serviceId: string;
serviceName: string;
projectName: string;
status: string;
imageName?: string;
startedAt?: string;
completedAt?: string;
createdAt?: string;
};
export async function listRecentDeployments(limit = 10): Promise<RecentDeploymentEntity[]> {
const payload = await requestJson<{ deployments?: Array<Record<string, unknown>> }>(
`/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( export async function createDeployment(
serviceId: string, serviceId: string,
input: CreateDeploymentInput = {}, input: CreateDeploymentInput = {},
+50
View File
@@ -595,6 +595,56 @@ paths:
schema: schema:
$ref: '#/components/schemas/Deployment' $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}: /deployments/{id}:
get: get:
tags: tags: