fix: reliable source upload, build cancel, WordPress port 80 default
- Replace port-forward/netcat PVC upload with kubectl cp for integrity - Add build cancellation API and session cleanup; deploy catches cancel - Default port 80 for WordPress, PHP, and Laravel on create - Build progress modal with cancel; Helm/K8s adjustments for deployments - Update build and kubernetes specs Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { Loader2, Upload, Hammer, Rocket, CheckCircle, XCircle, X } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export interface BuildProgress {
|
||||
phase: 'uploading' | 'building' | 'deploying' | 'done' | 'failed';
|
||||
percent: number;
|
||||
bytesUploaded?: number;
|
||||
totalBytes?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const phaseConfig = {
|
||||
uploading: { label: 'Uploading source to cluster', icon: Upload, bg: 'bg-blue-500' },
|
||||
building: { label: 'Building Docker image', icon: Hammer, bg: 'bg-amber-500' },
|
||||
deploying: { label: 'Deploying to Kubernetes', icon: Rocket, bg: 'bg-purple-500' },
|
||||
done: { label: 'Deployment complete', icon: CheckCircle, bg: 'bg-green-500' },
|
||||
failed: { label: 'Deployment failed', icon: XCircle, bg: 'bg-red-500' },
|
||||
};
|
||||
|
||||
export function BuildProgressModal({ appId, enabled }: { appId: string; enabled: boolean }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) setDismissed(false);
|
||||
}, [enabled]);
|
||||
|
||||
const { data } = useQuery<{ progress: BuildProgress | null }>({
|
||||
queryKey: ['build-progress', appId],
|
||||
queryFn: () => api.get(`/deployments/applications/${appId}/build-progress`).then((r) => r.data),
|
||||
enabled: enabled && !dismissed,
|
||||
refetchInterval: enabled && !dismissed ? 1500 : false,
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: () => api.post(`/deployments/applications/${appId}/cancel`),
|
||||
onSuccess: () => {
|
||||
toast.success('Deployment cancelled');
|
||||
setDismissed(true);
|
||||
queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['deployments', appId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['build-progress', appId] });
|
||||
},
|
||||
onError: () => toast.error('Failed to cancel deployment'),
|
||||
});
|
||||
|
||||
const progress = data?.progress;
|
||||
if (!enabled || dismissed || !progress || progress.phase === 'done') return null;
|
||||
|
||||
const cfg = phaseConfig[progress.phase];
|
||||
const PhaseIcon = cfg.icon;
|
||||
const isActive = progress.phase !== 'failed';
|
||||
const showBytes = progress.phase === 'uploading' && progress.totalBytes;
|
||||
const isCancelling = cancelMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl p-8 max-w-md w-full mx-4 space-y-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cancelMutation.mutate()}
|
||||
disabled={isCancelling || progress.phase === 'failed'}
|
||||
className="absolute top-4 right-4 p-1.5 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors disabled:opacity-50"
|
||||
aria-label="Cancel and close"
|
||||
title="Cancel deployment"
|
||||
>
|
||||
{isCancelling ? <Loader2 className="w-5 h-5 animate-spin" /> : <X className="w-5 h-5" />}
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
{isActive ? (
|
||||
<Loader2 className="w-12 h-12 text-primary-600 mx-auto mb-3 animate-spin" />
|
||||
) : (
|
||||
<PhaseIcon className="w-12 h-12 text-red-500 mx-auto mb-3" />
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-gray-900">{cfg.label}</h3>
|
||||
{progress.message && (
|
||||
<p className="text-sm text-gray-500 mt-1">{progress.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<PhaseIcon className="w-4 h-4" />
|
||||
{progress.percent}%
|
||||
</span>
|
||||
{showBytes && (
|
||||
<span>{formatBytes(progress.bytesUploaded)} / {formatBytes(progress.totalBytes)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${cfg.bg} rounded-full transition-all duration-500 ease-out`}
|
||||
style={{ width: `${progress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress.phase === 'failed' && progress.message && (
|
||||
<p className="text-sm text-red-600 text-center">{progress.message}</p>
|
||||
)}
|
||||
|
||||
{isActive && (
|
||||
<p className="text-xs text-gray-400 text-center">
|
||||
Click the close button to cancel and remove cluster build resources.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user