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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.984.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.984.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"next": "16.1.6",
|
||||
"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;
|
||||
}
|
||||
|
||||
interface PreviewResponse {
|
||||
exists: boolean;
|
||||
url?: string;
|
||||
expiresIn?: number;
|
||||
message?: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
interface DeploymentDashboardProps {
|
||||
deployment: Deployment;
|
||||
}
|
||||
@@ -476,6 +484,13 @@ export function DeploymentDashboard({ deployment }: DeploymentDashboardProps) {
|
||||
{ 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}`;
|
||||
|
||||
// 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">
|
||||
{/* Preview thumbnail (left side) */}
|
||||
<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="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</span>
|
||||
<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">
|
||||
<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">
|
||||
{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>
|
||||
|
||||
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