mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +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
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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() {
|
||||
<div className="v-k"><span>DEPLOYS</span></div>
|
||||
<div className="v-v">{isDemoMode ? 18 : deployCount}</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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1146,6 +1146,37 @@ export async function listDeployments(serviceId: string): Promise<DeploymentEnti
|
||||
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(
|
||||
serviceId: string,
|
||||
input: CreateDeploymentInput = {},
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user