mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
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:
1 parent
b7e2e93261
commit
3bf44dbcd5
8 files changed
+258
-21
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = {},
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user