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:
1750
package-lock.json
generated
1750
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
54
src/app/api/deployments/[uuid]/preview/route.ts
Normal file
54
src/app/api/deployments/[uuid]/preview/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
170
src/lib/s3.ts
Normal 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 };
|
||||||
Reference in New Issue
Block a user