Open deploy progress immediately and improve delete row glass overlay.

Track in-progress deploys in the client store so the progress modal opens on click without waiting for the applications list refetch. Show deleting state as a blurred glass overlay on table rows and service cards instead of replacing row content.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-25 23:34:24 +03:30
parent dc9830383b
commit 1ade52825c
11 changed files with 266 additions and 59 deletions
+22 -18
View File
@@ -9,7 +9,12 @@ import type { Application, AppLifecycleStatus, ApplicationMigrationEvent, Applic
import { Search, X, Package, Hexagon, User, Database, Box, AlertTriangle, Clock, ArrowRightLeft, RefreshCw } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal';
import { DeleteButtonLabel } from '@/components/delete-button-label';
import { DeletingCardOverlay, DeletingTableRowCell, deletingResourceMessage } from '@/components/deleting-overlay';
import {
DeletingCardOverlay,
DeletingTableRowOverlay,
deletingResourceMessage,
deletingRowContentClass,
} from '@/components/deleting-overlay';
import { TruncatedText } from '@/components/truncated-text';
import { useDebounce } from '@/hooks/useDebounce';
import { useApplicationDelete } from '@/lib/use-application-delete';
@@ -311,16 +316,9 @@ export default function AdminAppsPage() {
return (
<tr
key={app.id}
className={`hover:bg-gray-50/50 transition-colors ${lifecycle === 'suspended' ? 'bg-amber-50/30' : lifecycle === 'pending_deletion' ? 'bg-red-50/30' : ''} ${rowDeleting ? 'bg-gray-50' : ''}`}
className={`hover:bg-gray-50/50 transition-colors ${lifecycle === 'suspended' ? 'bg-amber-50/30' : lifecycle === 'pending_deletion' ? 'bg-red-50/30' : ''} ${rowDeleting ? 'relative bg-gray-50/80' : ''}`}
>
{rowDeleting ? (
<DeletingTableRowCell
colSpan={8}
message={deletingResourceMessage('application', app.name)}
/>
) : (
<>
<td className="px-6 py-4 min-w-[300px]">
<td className={`px-6 py-4 min-w-[300px] ${rowDeleting ? deletingRowContentClass : ''}`}>
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 min-w-[300px] group">
<div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center shrink-0">
<Hexagon className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : app.runtime === 'wordpress' ? 'text-blue-600' : 'text-orange-500'}`} />
@@ -333,7 +331,7 @@ export default function AdminAppsPage() {
</div>
</Link>
</td>
<td className="px-6 py-4 max-w-[200px]">
<td className={`px-6 py-4 max-w-[200px] ${rowDeleting ? deletingRowContentClass : ''}`}>
{app.user ? (
<div className="min-w-0">
<TruncatedText className="text-sm font-medium text-gray-900">
@@ -346,12 +344,12 @@ export default function AdminAppsPage() {
<TruncatedText className="text-xs text-gray-400 font-mono">{app.userId}</TruncatedText>
)}
</td>
<td className="px-6 py-4 max-w-[120px] text-sm text-gray-600 capitalize">
<td className={`px-6 py-4 max-w-[120px] text-sm text-gray-600 capitalize ${rowDeleting ? deletingRowContentClass : ''}`}>
<span className={`badge max-w-full truncate ${statusColors[latestStatus] || 'badge-gray'}`} title={latestStatus}>
{latestStatus}
</span>
</td>
<td className="px-6 py-4 max-w-[180px]">
<td className={`px-6 py-4 max-w-[180px] ${rowDeleting ? deletingRowContentClass : ''}`}>
<span
className={`inline-flex max-w-full items-center gap-1 truncate px-2 py-1 rounded-full text-xs font-semibold ${lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'}`}
title={lifecycleLabels[lifecycle] || lifecycle}
@@ -365,7 +363,7 @@ export default function AdminAppsPage() {
</TruncatedText>
)}
</td>
<td className="px-6 py-4 max-w-[200px]">
<td className={`px-6 py-4 max-w-[200px] ${rowDeleting ? deletingRowContentClass : ''}`}>
{assignedCluster ? (
<div className="min-w-0">
<TruncatedText className="text-sm font-medium text-gray-900">{assignedCluster.name}</TruncatedText>
@@ -382,7 +380,7 @@ export default function AdminAppsPage() {
<TruncatedText className="text-xs text-gray-400">Not assigned</TruncatedText>
)}
</td>
<td className="px-6 py-4 max-w-[140px]">
<td className={`px-6 py-4 max-w-[140px] ${rowDeleting ? deletingRowContentClass : ''}`}>
{latestMigration ? (
<span
className={`badge text-xs capitalize max-w-full truncate ${migrationStatusBadgeClass(latestMigration.status)}`}
@@ -394,7 +392,7 @@ export default function AdminAppsPage() {
<span className="text-xs text-gray-400"></span>
)}
</td>
<td className="px-6 py-4 max-w-[140px]">
<td className={`px-6 py-4 max-w-[140px] ${rowDeleting ? deletingRowContentClass : ''}`}>
{app.billingCycle && (
<span
className="badge badge-purple text-xs max-w-full truncate"
@@ -411,7 +409,7 @@ export default function AdminAppsPage() {
<TruncatedText className="text-xs text-gray-400">No plan</TruncatedText>
)}
</td>
<td className="px-6 py-4 text-right">
<td className={`px-6 py-4 text-right ${rowDeleting ? deletingRowContentClass : ''}`}>
<div className="flex flex-wrap items-center justify-end gap-1.5">
<Link href={`/dashboard/apps/${app.id}`} className="btn-ghost text-xs px-3 py-1.5">
View
@@ -441,7 +439,11 @@ export default function AdminAppsPage() {
</button>
</div>
</td>
</>
{rowDeleting && (
<DeletingTableRowOverlay
colSpan={8}
message={deletingResourceMessage('application', app.name)}
/>
)}
</tr>
);
@@ -467,6 +469,7 @@ export default function AdminAppsPage() {
{cardDeleting && (
<DeletingCardOverlay message={deletingResourceMessage('application', app.name)} />
)}
<div className={cardDeleting ? deletingRowContentClass : undefined}>
<div className="flex items-start justify-between gap-3">
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 min-w-0 flex-1">
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center shrink-0">
@@ -570,6 +573,7 @@ export default function AdminAppsPage() {
<DeleteButtonLabel loading={isDeleting(app.id)} />
</button>
</div>
</div>
</div>
);
})}
+17 -2
View File
@@ -15,6 +15,8 @@ import { useApplicationDelete } from '@/lib/use-application-delete';
import { isApplicationProduct, isManagedProduct } from '@/lib/product-type';
import { useConfirm } from '@/components/confirm-modal';
import { useAuthStore } from '@/lib/store';
import { useDeployProgressActions } from '@/lib/use-deploy-progress-actions';
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
/** Matches backend multipart limit for POST /applications/:id/upload */
const MAX_SOURCE_ARCHIVE_BYTES = 10 * 1024 ** 3;
@@ -82,6 +84,7 @@ export default function AppDetailPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const { notifyDeployStarted } = useDeployProgressActions();
const confirm = useConfirm();
const appId = params.id as string;
const user = useAuthStore((s) => s.user);
@@ -545,11 +548,17 @@ export default function AppDetailPage() {
const deployMutation = useMutation({
mutationFn: () => api.post(`/deployments/applications/${appId}/deploy`),
onMutate: () => {
notifyDeployStarted(appId, app?.name);
},
onSuccess: () => {
invalidateAll();
toast.success('Deployment triggered!');
},
onError: () => toast.error('Failed to trigger deployment'),
onError: () => {
useDeployProgressStore.getState().stopTracking(appId);
toast.error('Failed to trigger deployment');
},
});
const stopMutation = useMutation({
@@ -581,11 +590,17 @@ export default function AppDetailPage() {
const redeployMutation = useMutation({
mutationFn: () => api.post(`/deployments/applications/${appId}/redeploy`),
onMutate: () => {
notifyDeployStarted(appId, app?.name);
},
onSuccess: () => {
invalidateAll();
toast.success('Redeploy started');
},
onError: () => toast.error('Failed to trigger redeploy'),
onError: () => {
useDeployProgressStore.getState().stopTracking(appId);
toast.error('Failed to trigger redeploy');
},
});
const { deleteApplication, isAnyDeleting } = useApplicationDelete({
+17 -16
View File
@@ -7,7 +7,11 @@ import type { Application } from '@/types';
import { Rocket, Package, Hexagon, Database, Box, AlertTriangle, Clock } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal';
import { DeleteButtonLabel } from '@/components/delete-button-label';
import { DeletingTableRowCell, deletingResourceMessage } from '@/components/deleting-overlay';
import {
DeletingTableRowOverlay,
deletingResourceMessage,
deletingRowContentClass,
} from '@/components/deleting-overlay';
import { filterApplications } from '@/lib/product-type';
import { useApplicationDelete } from '@/lib/use-application-delete';
@@ -132,16 +136,9 @@ export default function AppsPage() {
return (
<tr
key={app.id}
className={`hover:bg-gray-50/50 transition-colors ${lifecycle === 'suspended' ? 'bg-amber-50/30' : lifecycle === 'pending_deletion' ? 'bg-red-50/30' : ''} ${rowDeleting ? 'bg-gray-50' : ''}`}
className={`hover:bg-gray-50/50 transition-colors ${lifecycle === 'suspended' ? 'bg-amber-50/30' : lifecycle === 'pending_deletion' ? 'bg-red-50/30' : ''} ${rowDeleting ? 'relative bg-gray-50/80' : ''}`}
>
{rowDeleting ? (
<DeletingTableRowCell
colSpan={6}
message={deletingResourceMessage('application', app.name)}
/>
) : (
<>
<td className="px-6 py-4 min-w-[300px]">
<td className={`px-6 py-4 min-w-[300px] ${rowDeleting ? deletingRowContentClass : ''}`}>
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 group min-w-[300px]">
<div className="w-9 h-9 rounded-lg bg-primary-50 flex items-center justify-center shrink-0">
<Hexagon className={`w-5 h-5 ${app.runtime === 'nodejs' ? 'text-green-600' : app.runtime === 'wordpress' ? 'text-blue-600' : 'text-orange-500'}`} />
@@ -149,17 +146,17 @@ export default function AppsPage() {
<span className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors">{app.name}</span>
</Link>
</td>
<td className="px-6 py-4 text-sm text-gray-600 capitalize">{app.runtime}</td>
<td className="px-6 py-4">
<td className={`px-6 py-4 text-sm text-gray-600 capitalize ${rowDeleting ? deletingRowContentClass : ''}`}>{app.runtime}</td>
<td className={`px-6 py-4 ${rowDeleting ? deletingRowContentClass : ''}`}>
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>{latestStatus}</span>
</td>
<td className="px-6 py-4">
<td className={`px-6 py-4 ${rowDeleting ? deletingRowContentClass : ''}`}>
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold ${lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'}`}>
{lifecycle === 'suspended' && <AlertTriangle className="w-3 h-3" />}
{lifecycleLabels[lifecycle] || lifecycle}
</span>
</td>
<td className="px-6 py-4">
<td className={`px-6 py-4 ${rowDeleting ? deletingRowContentClass : ''}`}>
{app.planExpiresAt ? (
<span className={`inline-flex items-center gap-1 text-xs font-medium ${expiry.urgent ? 'text-red-600' : 'text-gray-600'}`}>
<Clock className="w-3 h-3" />
@@ -169,7 +166,7 @@ export default function AppsPage() {
<span className="text-xs text-gray-400">No plan</span>
)}
</td>
<td className="px-6 py-4 text-right">
<td className={`px-6 py-4 text-right ${rowDeleting ? deletingRowContentClass : ''}`}>
<div className="flex items-center justify-end gap-2">
<Link href={`/dashboard/apps/${app.id}`} className="btn-ghost text-xs px-3 py-1.5">
View
@@ -192,7 +189,11 @@ export default function AppsPage() {
</button>
</div>
</td>
</>
{rowDeleting && (
<DeletingTableRowOverlay
colSpan={6}
message={deletingResourceMessage('application', app.name)}
/>
)}
</tr>
);
+15 -3
View File
@@ -6,6 +6,8 @@ import { useMutation, useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import { parseDotenv } from '@/lib/parseDotenv';
import { useAuthStore } from '@/lib/store';
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
import { useDeployProgressActions } from '@/lib/use-deploy-progress-actions';
import { toast } from 'react-toastify';
import type {
CreateApplicationDto,
@@ -216,7 +218,17 @@ function minGiToFitFileBytes(bytes: number): number {
export default function DeployPage() {
const router = useRouter();
const { notifyDeployStarted } = useDeployProgressActions();
const user = useAuthStore((s) => s.user);
const triggerAppDeploy = async (appId: string, appName?: string) => {
notifyDeployStarted(appId, appName);
try {
await api.post(`/deployments/applications/${appId}/deploy`);
} catch {
useDeployProgressStore.getState().stopTracking(appId);
}
};
const isAdmin = user?.role === 'admin';
const [step, setStep] = useState(0);
const [form, setForm] = useState<CreateApplicationDto>({
@@ -429,7 +441,7 @@ export default function DeployPage() {
onSuccess: (res) => {
setDeployStage('deploying');
toast.success('Payment successful! Deploying...');
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
void triggerAppDeploy(res.data.id, res.data.name);
setDeployStage('done');
router.push(`/dashboard/apps/${res.data.id}`);
},
@@ -511,7 +523,7 @@ export default function DeployPage() {
onSuccess: (res) => {
setDeployStage('deploying');
toast.success('Payment successful! Deploying...');
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
void triggerAppDeploy(res.data.id, res.data.name);
setDeployStage('done');
router.push(`/dashboard/apps/${res.data.id}`);
},
@@ -566,7 +578,7 @@ export default function DeployPage() {
onSuccess: (res) => {
setDeployStage('deploying');
toast.success('Application created! Triggering deployment...');
api.post(`/deployments/applications/${res.data.id}/deploy`).catch(() => {});
void triggerAppDeploy(res.data.id, res.data.name);
setDeployStage('done');
router.push(`/dashboard/apps/${res.data.id}`);
},
@@ -34,6 +34,8 @@ import {
import { useConfirm } from '@/components/confirm-modal';
import { DeletingModal } from '@/components/deleting-modal';
import { useApplicationDelete } from '@/lib/use-application-delete';
import { useDeployProgressActions } from '@/lib/use-deploy-progress-actions';
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
import { ServiceExternalAccessPanel } from '@/components/service-external-access-panel';
import { WorkloadLogsPanel } from '@/components/workload-logs-panel';
import { ManagedServiceResourcesPanel } from '@/components/managed-service-resources-panel';
@@ -62,6 +64,7 @@ export default function ManagedServiceDetailPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const { notifyDeployStarted } = useDeployProgressActions();
const confirm = useConfirm();
const serviceId = params.id as string;
@@ -114,22 +117,34 @@ export default function ManagedServiceDetailPage() {
const deployMutation = useMutation({
mutationFn: () => api.post(`/deployments/applications/${serviceId}/deploy`),
onMutate: () => {
notifyDeployStarted(serviceId, app?.name);
},
onSuccess: () => {
toast.success('Provisioning started');
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
queryClient.invalidateQueries({ queryKey: ['applications'] });
},
onError: () => {
useDeployProgressStore.getState().stopTracking(serviceId);
toast.error('Failed to start provisioning');
},
onError: () => toast.error('Failed to start provisioning'),
});
const redeployMutation = useMutation({
mutationFn: () => api.post(`/deployments/applications/${serviceId}/redeploy`),
onMutate: () => {
notifyDeployStarted(serviceId, app?.name);
},
onSuccess: () => {
toast.success('Re-provisioning started');
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
queryClient.invalidateQueries({ queryKey: ['applications'] });
},
onError: (err: unknown) => {
useDeployProgressStore.getState().stopTracking(serviceId);
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Failed to re-provision service');
},
@@ -15,6 +15,8 @@ import type {
ProductType,
} from '@/types';
import { optionalDefaultsFromCatalog } from '@/lib/optional-service-defaults';
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
import { useDeployProgressActions } from '@/lib/use-deploy-progress-actions';
import {
ManagedDatabaseConfig,
validateDbDumpStorage,
@@ -38,6 +40,7 @@ type ServiceKind = 'managed_database' | 'managed_redis' | 'managed_rabbitmq';
const steps = ['Service type', 'Configuration', 'Review & pay'];
export default function NewManagedServicePage() {
const { notifyDeployStarted } = useDeployProgressActions();
const router = useRouter();
const [step, setStep] = useState(0);
const [kind, setKind] = useState<ServiceKind | null>(null);
@@ -171,9 +174,15 @@ export default function NewManagedServicePage() {
return payload;
};
const finishDeploy = async (appId: string) => {
const finishDeploy = async (appId: string, appName?: string) => {
setDeployStage('deploying');
await api.post(`/deployments/applications/${appId}/deploy`);
notifyDeployStarted(appId, appName);
try {
await api.post(`/deployments/applications/${appId}/deploy`);
} catch {
useDeployProgressStore.getState().stopTracking(appId);
throw new Error('Deploy failed');
}
setDeployStage('done');
toast.success('Service provisioned successfully');
router.push(`/dashboard/services/${appId}`);
@@ -206,7 +215,7 @@ export default function NewManagedServicePage() {
return appId;
},
onSuccess: (appId) =>
finishDeploy(appId).catch(() => {
finishDeploy(appId, form.name).catch(() => {
setDeployStage('error');
toast.error('Payment succeeded but deployment failed');
}),
@@ -242,7 +251,7 @@ export default function NewManagedServicePage() {
return appId;
},
onSuccess: (appId) =>
finishDeploy(appId).catch(() => {
finishDeploy(appId, form.name).catch(() => {
setDeployStage('error');
toast.error('Payment succeeded but deployment failed');
}),
+9 -1
View File
@@ -8,7 +8,11 @@ import { managedServiceTypeLabel } from '@/lib/optional-service-defaults';
import { Database, Plus, AlertTriangle, Clock } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal';
import { DeleteButtonLabel } from '@/components/delete-button-label';
import { DeletingCardOverlay, deletingResourceMessage } from '@/components/deleting-overlay';
import {
DeletingCardOverlay,
deletingResourceMessage,
deletingRowContentClass,
} from '@/components/deleting-overlay';
import { filterManagedServices } from '@/lib/product-type';
import { useApplicationDelete } from '@/lib/use-application-delete';
@@ -130,6 +134,9 @@ export default function ServicesPage() {
{cardDeleting && (
<DeletingCardOverlay message={deletingResourceMessage('service', svc.name)} />
)}
<div
className={`flex flex-col sm:flex-row sm:items-center gap-4 flex-1 w-full min-w-0 ${cardDeleting ? deletingRowContentClass : ''}`}
>
<Link href={`/dashboard/services/${svc.id}`} className="flex items-center gap-3 flex-1 min-w-0 group">
<div className="w-10 h-10 rounded-lg bg-indigo-50 flex items-center justify-center shrink-0">
<Database className="w-5 h-5 text-indigo-600" />
@@ -173,6 +180,7 @@ export default function ServicesPage() {
<DeleteButtonLabel loading={isDeleting(svc.id)} />
</button>
</div>
</div>
</div>
);
})}