Add S3/MinIO storage for deployment previews

- Add S3 client helper (src/lib/s3.ts) with upload/download functions
- Add /api/deployments/[uuid]/preview endpoint for presigned URLs
- Update DeploymentDashboard to fetch and display preview images
- Install @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner

Storage: MinIO bucket nuc-portal-previews with dedicated service account

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-06 18:11:50 +01:00
parent efc7a8392b
commit 91624fd6de
5 changed files with 2022 additions and 4 deletions

1750
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,8 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.984.0",
"@aws-sdk/s3-request-presigner": "^3.984.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"next": "16.1.6", "next": "16.1.6",
"pg": "^8.18.0", "pg": "^8.18.0",

View File

@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { getPreviewUrl, previewExists } from '@/lib/s3';
import { fetchDeploymentDetail } from '@/lib/coolify-db';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ uuid: string }> }
) {
const { uuid } = await params;
try {
// Get deployment to find application UUID
const deployment = await fetchDeploymentDetail(uuid);
if (!deployment) {
return NextResponse.json({ error: 'Deployment not found' }, { status: 404 });
}
const appUuid = deployment.application_uuid;
// Check if preview exists
const exists = await previewExists(appUuid, uuid);
if (!exists) {
return NextResponse.json({
exists: false,
message: 'Preview not available',
hint: 'Screenshot will be captured after successful deployment',
});
}
// Get presigned URL (valid for 1 hour)
const url = await getPreviewUrl(appUuid, uuid);
if (!url) {
return NextResponse.json(
{ error: 'Failed to generate preview URL' },
{ status: 500 }
);
}
return NextResponse.json({
exists: true,
url,
expiresIn: 3600,
});
} catch (error) {
console.error('Preview fetch error:', error);
return NextResponse.json(
{ error: 'Failed to fetch preview', details: String(error) },
{ status: 500 }
);
}
}

View File

@@ -47,6 +47,14 @@ interface StatsResponse {
timestamp: string; timestamp: string;
} }
interface PreviewResponse {
exists: boolean;
url?: string;
expiresIn?: number;
message?: string;
hint?: string;
}
interface DeploymentDashboardProps { interface DeploymentDashboardProps {
deployment: Deployment; deployment: Deployment;
} }
@@ -476,6 +484,13 @@ export function DeploymentDashboard({ deployment }: DeploymentDashboardProps) {
{ refreshInterval: 10000 } { refreshInterval: 10000 }
); );
// Preview image (only fetch once, no refresh needed)
const { data: preview, isLoading: previewLoading } = useSWR<PreviewResponse>(
`/api/deployments/${deployment.deployment_uuid}/preview`,
fetcher,
{ revalidateOnFocus: false }
);
const coolifyUrl = `${clientConfig.coolifyUrl}/project/${clientConfig.coolifyProjectUuid}/production/application/${deployment.application_uuid}/deployment/${deployment.deployment_uuid}`; const coolifyUrl = `${clientConfig.coolifyUrl}/project/${clientConfig.coolifyProjectUuid}/production/application/${deployment.application_uuid}/deployment/${deployment.deployment_uuid}`;
// Dozzle URL for runtime logs (uses IP since no domain configured) // Dozzle URL for runtime logs (uses IP since no domain configured)
@@ -632,10 +647,37 @@ export function DeploymentDashboard({ deployment }: DeploymentDashboardProps) {
<div className="flex gap-6"> <div className="flex gap-6">
{/* Preview thumbnail (left side) */} {/* Preview thumbnail (left side) */}
<div className="flex-shrink-0 w-80"> <div className="flex-shrink-0 w-80">
<div className="aspect-[16/10] bg-slate-900 dark:bg-stone-950 rounded-lg flex items-center justify-center overflow-hidden border border-slate-200 dark:border-stone-700"> <div className="aspect-[16/10] bg-slate-900 dark:bg-stone-950 rounded-lg flex items-center justify-center overflow-hidden border border-slate-200 dark:border-stone-700 relative">
{previewLoading ? (
<div className="text-center">
<Icon name="loader" size={32} className="text-slate-600 dark:text-stone-600 mx-auto mb-2 animate-spin" />
<span className="text-xs text-slate-500 dark:text-stone-500">Loading preview...</span>
</div>
) : preview?.exists && preview.url ? (
<img
src={preview.url}
alt={`Preview of ${deployment.application_name}`}
className="w-full h-full object-cover"
onError={(e) => {
// Hide image on error and show placeholder
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
) : (
<div className="text-center"> <div className="text-center">
<Icon name="image" size={48} className="text-slate-600 dark:text-stone-600 mx-auto mb-2" /> <Icon name="image" size={48} className="text-slate-600 dark:text-stone-600 mx-auto mb-2" />
<span className="text-xs text-slate-500 dark:text-stone-500">Preview</span> <span className="text-xs text-slate-500 dark:text-stone-500">
{deployment.status === 'in_progress' ? 'Preview after deploy' : 'No preview'}
</span>
</div>
)}
{/* Fallback placeholder (hidden by default, shown on image error) */}
<div className="hidden absolute inset-0 flex items-center justify-center bg-slate-900 dark:bg-stone-950">
<div className="text-center">
<Icon name="image" size={48} className="text-slate-600 dark:text-stone-600 mx-auto mb-2" />
<span className="text-xs text-slate-500 dark:text-stone-500">Preview unavailable</span>
</div>
</div> </div>
</div> </div>
</div> </div>

170
src/lib/s3.ts Normal file
View File

@@ -0,0 +1,170 @@
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
HeadObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
// S3/MinIO client configuration
const s3Client = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION || 'us-east-1',
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY || '',
secretAccessKey: process.env.S3_SECRET_KEY || '',
},
forcePathStyle: true, // Required for MinIO
});
const BUCKET = process.env.S3_BUCKET || 'nuc-portal-previews';
/**
* Upload a file to S3/MinIO
*/
export async function uploadFile(
key: string,
body: Buffer | Uint8Array | string,
contentType: string = 'application/octet-stream'
): Promise<{ success: boolean; key: string; error?: string }> {
try {
await s3Client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: body,
ContentType: contentType,
})
);
return { success: true, key };
} catch (error) {
console.error('S3 upload error:', error);
return { success: false, key, error: String(error) };
}
}
/**
* Upload a deployment preview screenshot
*/
export async function uploadPreviewScreenshot(
appUuid: string,
deploymentUuid: string,
imageBuffer: Buffer
): Promise<{ success: boolean; key: string; url?: string; error?: string }> {
const key = `previews/${appUuid}/${deploymentUuid}.png`;
const result = await uploadFile(key, imageBuffer, 'image/png');
if (result.success) {
// Return the direct URL (MinIO serves files directly if bucket is public)
// Or use presigned URL for private buckets
const url = `${process.env.S3_ENDPOINT}/${BUCKET}/${key}`;
return { ...result, url };
}
return result;
}
/**
* Get a presigned URL for reading a file (valid for 1 hour by default)
*/
export async function getPresignedUrl(
key: string,
expiresIn: number = 3600
): Promise<string | null> {
try {
const command = new GetObjectCommand({
Bucket: BUCKET,
Key: key,
});
return await getSignedUrl(s3Client, command, { expiresIn });
} catch (error) {
console.error('S3 presigned URL error:', error);
return null;
}
}
/**
* Get presigned URL for a deployment preview
*/
export async function getPreviewUrl(
appUuid: string,
deploymentUuid: string
): Promise<string | null> {
const key = `previews/${appUuid}/${deploymentUuid}.png`;
return getPresignedUrl(key);
}
/**
* Check if a file exists in S3
*/
export async function fileExists(key: string): Promise<boolean> {
try {
await s3Client.send(
new HeadObjectCommand({
Bucket: BUCKET,
Key: key,
})
);
return true;
} catch {
return false;
}
}
/**
* Check if a preview exists for a deployment
*/
export async function previewExists(
appUuid: string,
deploymentUuid: string
): Promise<boolean> {
const key = `previews/${appUuid}/${deploymentUuid}.png`;
return fileExists(key);
}
/**
* Delete a file from S3
*/
export async function deleteFile(key: string): Promise<boolean> {
try {
await s3Client.send(
new DeleteObjectCommand({
Bucket: BUCKET,
Key: key,
})
);
return true;
} catch (error) {
console.error('S3 delete error:', error);
return false;
}
}
/**
* Get file as Buffer
*/
export async function getFile(key: string): Promise<Buffer | null> {
try {
const response = await s3Client.send(
new GetObjectCommand({
Bucket: BUCKET,
Key: key,
})
);
if (response.Body) {
const chunks: Uint8Array[] = [];
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
return null;
} catch (error) {
console.error('S3 get error:', error);
return null;
}
}
export { s3Client, BUCKET };