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:
@@ -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 { Search, X, Package, Hexagon, User, Database, Box, AlertTriangle, Clock, ArrowRightLeft, RefreshCw } from 'lucide-react';
|
||||||
import { useConfirm } from '@/components/confirm-modal';
|
import { useConfirm } from '@/components/confirm-modal';
|
||||||
import { DeleteButtonLabel } from '@/components/delete-button-label';
|
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 { TruncatedText } from '@/components/truncated-text';
|
||||||
import { useDebounce } from '@/hooks/useDebounce';
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
import { useApplicationDelete } from '@/lib/use-application-delete';
|
import { useApplicationDelete } from '@/lib/use-application-delete';
|
||||||
@@ -311,16 +316,9 @@ export default function AdminAppsPage() {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={app.id}
|
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 ? (
|
<td className={`px-6 py-4 min-w-[300px] ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||||
<DeletingTableRowCell
|
|
||||||
colSpan={8}
|
|
||||||
message={deletingResourceMessage('application', app.name)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<td className="px-6 py-4 min-w-[300px]">
|
|
||||||
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 min-w-[300px] group">
|
<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">
|
<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'}`} />
|
<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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 max-w-[200px]">
|
<td className={`px-6 py-4 max-w-[200px] ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||||
{app.user ? (
|
{app.user ? (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<TruncatedText className="text-sm font-medium text-gray-900">
|
<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>
|
<TruncatedText className="text-xs text-gray-400 font-mono">{app.userId}</TruncatedText>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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}>
|
<span className={`badge max-w-full truncate ${statusColors[latestStatus] || 'badge-gray'}`} title={latestStatus}>
|
||||||
{latestStatus}
|
{latestStatus}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 max-w-[180px]">
|
<td className={`px-6 py-4 max-w-[180px] ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||||
<span
|
<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'}`}
|
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}
|
title={lifecycleLabels[lifecycle] || lifecycle}
|
||||||
@@ -365,7 +363,7 @@ export default function AdminAppsPage() {
|
|||||||
</TruncatedText>
|
</TruncatedText>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 max-w-[200px]">
|
<td className={`px-6 py-4 max-w-[200px] ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||||
{assignedCluster ? (
|
{assignedCluster ? (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<TruncatedText className="text-sm font-medium text-gray-900">{assignedCluster.name}</TruncatedText>
|
<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>
|
<TruncatedText className="text-xs text-gray-400">Not assigned</TruncatedText>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 max-w-[140px]">
|
<td className={`px-6 py-4 max-w-[140px] ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||||
{latestMigration ? (
|
{latestMigration ? (
|
||||||
<span
|
<span
|
||||||
className={`badge text-xs capitalize max-w-full truncate ${migrationStatusBadgeClass(latestMigration.status)}`}
|
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>
|
<span className="text-xs text-gray-400">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 max-w-[140px]">
|
<td className={`px-6 py-4 max-w-[140px] ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||||
{app.billingCycle && (
|
{app.billingCycle && (
|
||||||
<span
|
<span
|
||||||
className="badge badge-purple text-xs max-w-full truncate"
|
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>
|
<TruncatedText className="text-xs text-gray-400">No plan</TruncatedText>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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">
|
<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">
|
<Link href={`/dashboard/apps/${app.id}`} className="btn-ghost text-xs px-3 py-1.5">
|
||||||
View
|
View
|
||||||
@@ -441,7 +439,11 @@ export default function AdminAppsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</>
|
{rowDeleting && (
|
||||||
|
<DeletingTableRowOverlay
|
||||||
|
colSpan={8}
|
||||||
|
message={deletingResourceMessage('application', app.name)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -467,6 +469,7 @@ export default function AdminAppsPage() {
|
|||||||
{cardDeleting && (
|
{cardDeleting && (
|
||||||
<DeletingCardOverlay message={deletingResourceMessage('application', app.name)} />
|
<DeletingCardOverlay message={deletingResourceMessage('application', app.name)} />
|
||||||
)}
|
)}
|
||||||
|
<div className={cardDeleting ? deletingRowContentClass : undefined}>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<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">
|
<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">
|
<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)} />
|
<DeleteButtonLabel loading={isDeleting(app.id)} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { useApplicationDelete } from '@/lib/use-application-delete';
|
|||||||
import { isApplicationProduct, isManagedProduct } from '@/lib/product-type';
|
import { isApplicationProduct, isManagedProduct } from '@/lib/product-type';
|
||||||
import { useConfirm } from '@/components/confirm-modal';
|
import { useConfirm } from '@/components/confirm-modal';
|
||||||
import { useAuthStore } from '@/lib/store';
|
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 */
|
/** Matches backend multipart limit for POST /applications/:id/upload */
|
||||||
const MAX_SOURCE_ARCHIVE_BYTES = 10 * 1024 ** 3;
|
const MAX_SOURCE_ARCHIVE_BYTES = 10 * 1024 ** 3;
|
||||||
@@ -82,6 +84,7 @@ export default function AppDetailPage() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { notifyDeployStarted } = useDeployProgressActions();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const appId = params.id as string;
|
const appId = params.id as string;
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
@@ -545,11 +548,17 @@ export default function AppDetailPage() {
|
|||||||
|
|
||||||
const deployMutation = useMutation({
|
const deployMutation = useMutation({
|
||||||
mutationFn: () => api.post(`/deployments/applications/${appId}/deploy`),
|
mutationFn: () => api.post(`/deployments/applications/${appId}/deploy`),
|
||||||
|
onMutate: () => {
|
||||||
|
notifyDeployStarted(appId, app?.name);
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
toast.success('Deployment triggered!');
|
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({
|
const stopMutation = useMutation({
|
||||||
@@ -581,11 +590,17 @@ export default function AppDetailPage() {
|
|||||||
|
|
||||||
const redeployMutation = useMutation({
|
const redeployMutation = useMutation({
|
||||||
mutationFn: () => api.post(`/deployments/applications/${appId}/redeploy`),
|
mutationFn: () => api.post(`/deployments/applications/${appId}/redeploy`),
|
||||||
|
onMutate: () => {
|
||||||
|
notifyDeployStarted(appId, app?.name);
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
toast.success('Redeploy started');
|
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({
|
const { deleteApplication, isAnyDeleting } = useApplicationDelete({
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import type { Application } from '@/types';
|
|||||||
import { Rocket, Package, Hexagon, Database, Box, AlertTriangle, Clock } from 'lucide-react';
|
import { Rocket, Package, Hexagon, Database, Box, AlertTriangle, Clock } from 'lucide-react';
|
||||||
import { useConfirm } from '@/components/confirm-modal';
|
import { useConfirm } from '@/components/confirm-modal';
|
||||||
import { DeleteButtonLabel } from '@/components/delete-button-label';
|
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 { filterApplications } from '@/lib/product-type';
|
||||||
import { useApplicationDelete } from '@/lib/use-application-delete';
|
import { useApplicationDelete } from '@/lib/use-application-delete';
|
||||||
|
|
||||||
@@ -132,16 +136,9 @@ export default function AppsPage() {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={app.id}
|
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 ? (
|
<td className={`px-6 py-4 min-w-[300px] ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||||
<DeletingTableRowCell
|
|
||||||
colSpan={6}
|
|
||||||
message={deletingResourceMessage('application', app.name)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<td className="px-6 py-4 min-w-[300px]">
|
|
||||||
<Link href={`/dashboard/apps/${app.id}`} className="flex items-center gap-3 group min-w-[300px]">
|
<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">
|
<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'}`} />
|
<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>
|
<span className="font-semibold text-gray-900 group-hover:text-primary-600 transition-colors">{app.name}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-600 capitalize">{app.runtime}</td>
|
<td className={`px-6 py-4 text-sm text-gray-600 capitalize ${rowDeleting ? deletingRowContentClass : ''}`}>{app.runtime}</td>
|
||||||
<td className="px-6 py-4">
|
<td className={`px-6 py-4 ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>{latestStatus}</span>
|
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>{latestStatus}</span>
|
||||||
</td>
|
</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'}`}>
|
<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" />}
|
{lifecycle === 'suspended' && <AlertTriangle className="w-3 h-3" />}
|
||||||
{lifecycleLabels[lifecycle] || lifecycle}
|
{lifecycleLabels[lifecycle] || lifecycle}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className={`px-6 py-4 ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||||
{app.planExpiresAt ? (
|
{app.planExpiresAt ? (
|
||||||
<span className={`inline-flex items-center gap-1 text-xs font-medium ${expiry.urgent ? 'text-red-600' : 'text-gray-600'}`}>
|
<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" />
|
<Clock className="w-3 h-3" />
|
||||||
@@ -169,7 +166,7 @@ export default function AppsPage() {
|
|||||||
<span className="text-xs text-gray-400">No plan</span>
|
<span className="text-xs text-gray-400">No plan</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</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">
|
<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">
|
<Link href={`/dashboard/apps/${app.id}`} className="btn-ghost text-xs px-3 py-1.5">
|
||||||
View
|
View
|
||||||
@@ -192,7 +189,11 @@ export default function AppsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</>
|
{rowDeleting && (
|
||||||
|
<DeletingTableRowOverlay
|
||||||
|
colSpan={6}
|
||||||
|
message={deletingResourceMessage('application', app.name)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useMutation, useQuery } from '@tanstack/react-query';
|
|||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { parseDotenv } from '@/lib/parseDotenv';
|
import { parseDotenv } from '@/lib/parseDotenv';
|
||||||
import { useAuthStore } from '@/lib/store';
|
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 { toast } from 'react-toastify';
|
||||||
import type {
|
import type {
|
||||||
CreateApplicationDto,
|
CreateApplicationDto,
|
||||||
@@ -216,7 +218,17 @@ function minGiToFitFileBytes(bytes: number): number {
|
|||||||
|
|
||||||
export default function DeployPage() {
|
export default function DeployPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { notifyDeployStarted } = useDeployProgressActions();
|
||||||
const user = useAuthStore((s) => s.user);
|
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 isAdmin = user?.role === 'admin';
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [form, setForm] = useState<CreateApplicationDto>({
|
const [form, setForm] = useState<CreateApplicationDto>({
|
||||||
@@ -429,7 +441,7 @@ export default function DeployPage() {
|
|||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
setDeployStage('deploying');
|
setDeployStage('deploying');
|
||||||
toast.success('Payment successful! 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');
|
setDeployStage('done');
|
||||||
router.push(`/dashboard/apps/${res.data.id}`);
|
router.push(`/dashboard/apps/${res.data.id}`);
|
||||||
},
|
},
|
||||||
@@ -511,7 +523,7 @@ export default function DeployPage() {
|
|||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
setDeployStage('deploying');
|
setDeployStage('deploying');
|
||||||
toast.success('Payment successful! 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');
|
setDeployStage('done');
|
||||||
router.push(`/dashboard/apps/${res.data.id}`);
|
router.push(`/dashboard/apps/${res.data.id}`);
|
||||||
},
|
},
|
||||||
@@ -566,7 +578,7 @@ export default function DeployPage() {
|
|||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
setDeployStage('deploying');
|
setDeployStage('deploying');
|
||||||
toast.success('Application created! Triggering deployment...');
|
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');
|
setDeployStage('done');
|
||||||
router.push(`/dashboard/apps/${res.data.id}`);
|
router.push(`/dashboard/apps/${res.data.id}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import {
|
|||||||
import { useConfirm } from '@/components/confirm-modal';
|
import { useConfirm } from '@/components/confirm-modal';
|
||||||
import { DeletingModal } from '@/components/deleting-modal';
|
import { DeletingModal } from '@/components/deleting-modal';
|
||||||
import { useApplicationDelete } from '@/lib/use-application-delete';
|
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 { ServiceExternalAccessPanel } from '@/components/service-external-access-panel';
|
||||||
import { WorkloadLogsPanel } from '@/components/workload-logs-panel';
|
import { WorkloadLogsPanel } from '@/components/workload-logs-panel';
|
||||||
import { ManagedServiceResourcesPanel } from '@/components/managed-service-resources-panel';
|
import { ManagedServiceResourcesPanel } from '@/components/managed-service-resources-panel';
|
||||||
@@ -62,6 +64,7 @@ export default function ManagedServiceDetailPage() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { notifyDeployStarted } = useDeployProgressActions();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const serviceId = params.id as string;
|
const serviceId = params.id as string;
|
||||||
|
|
||||||
@@ -114,22 +117,34 @@ export default function ManagedServiceDetailPage() {
|
|||||||
|
|
||||||
const deployMutation = useMutation({
|
const deployMutation = useMutation({
|
||||||
mutationFn: () => api.post(`/deployments/applications/${serviceId}/deploy`),
|
mutationFn: () => api.post(`/deployments/applications/${serviceId}/deploy`),
|
||||||
|
onMutate: () => {
|
||||||
|
notifyDeployStarted(serviceId, app?.name);
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Provisioning started');
|
toast.success('Provisioning started');
|
||||||
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['application', 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({
|
const redeployMutation = useMutation({
|
||||||
mutationFn: () => api.post(`/deployments/applications/${serviceId}/redeploy`),
|
mutationFn: () => api.post(`/deployments/applications/${serviceId}/redeploy`),
|
||||||
|
onMutate: () => {
|
||||||
|
notifyDeployStarted(serviceId, app?.name);
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Re-provisioning started');
|
toast.success('Re-provisioning started');
|
||||||
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['deployments', serviceId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
queryClient.invalidateQueries({ queryKey: ['application', serviceId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||||
},
|
},
|
||||||
onError: (err: unknown) => {
|
onError: (err: unknown) => {
|
||||||
|
useDeployProgressStore.getState().stopTracking(serviceId);
|
||||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
toast.error(msg || 'Failed to re-provision service');
|
toast.error(msg || 'Failed to re-provision service');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import type {
|
|||||||
ProductType,
|
ProductType,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import { optionalDefaultsFromCatalog } from '@/lib/optional-service-defaults';
|
import { optionalDefaultsFromCatalog } from '@/lib/optional-service-defaults';
|
||||||
|
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
|
||||||
|
import { useDeployProgressActions } from '@/lib/use-deploy-progress-actions';
|
||||||
import {
|
import {
|
||||||
ManagedDatabaseConfig,
|
ManagedDatabaseConfig,
|
||||||
validateDbDumpStorage,
|
validateDbDumpStorage,
|
||||||
@@ -38,6 +40,7 @@ type ServiceKind = 'managed_database' | 'managed_redis' | 'managed_rabbitmq';
|
|||||||
const steps = ['Service type', 'Configuration', 'Review & pay'];
|
const steps = ['Service type', 'Configuration', 'Review & pay'];
|
||||||
|
|
||||||
export default function NewManagedServicePage() {
|
export default function NewManagedServicePage() {
|
||||||
|
const { notifyDeployStarted } = useDeployProgressActions();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const [kind, setKind] = useState<ServiceKind | null>(null);
|
const [kind, setKind] = useState<ServiceKind | null>(null);
|
||||||
@@ -171,9 +174,15 @@ export default function NewManagedServicePage() {
|
|||||||
return payload;
|
return payload;
|
||||||
};
|
};
|
||||||
|
|
||||||
const finishDeploy = async (appId: string) => {
|
const finishDeploy = async (appId: string, appName?: string) => {
|
||||||
setDeployStage('deploying');
|
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');
|
setDeployStage('done');
|
||||||
toast.success('Service provisioned successfully');
|
toast.success('Service provisioned successfully');
|
||||||
router.push(`/dashboard/services/${appId}`);
|
router.push(`/dashboard/services/${appId}`);
|
||||||
@@ -206,7 +215,7 @@ export default function NewManagedServicePage() {
|
|||||||
return appId;
|
return appId;
|
||||||
},
|
},
|
||||||
onSuccess: (appId) =>
|
onSuccess: (appId) =>
|
||||||
finishDeploy(appId).catch(() => {
|
finishDeploy(appId, form.name).catch(() => {
|
||||||
setDeployStage('error');
|
setDeployStage('error');
|
||||||
toast.error('Payment succeeded but deployment failed');
|
toast.error('Payment succeeded but deployment failed');
|
||||||
}),
|
}),
|
||||||
@@ -242,7 +251,7 @@ export default function NewManagedServicePage() {
|
|||||||
return appId;
|
return appId;
|
||||||
},
|
},
|
||||||
onSuccess: (appId) =>
|
onSuccess: (appId) =>
|
||||||
finishDeploy(appId).catch(() => {
|
finishDeploy(appId, form.name).catch(() => {
|
||||||
setDeployStage('error');
|
setDeployStage('error');
|
||||||
toast.error('Payment succeeded but deployment failed');
|
toast.error('Payment succeeded but deployment failed');
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import { managedServiceTypeLabel } from '@/lib/optional-service-defaults';
|
|||||||
import { Database, Plus, AlertTriangle, Clock } from 'lucide-react';
|
import { Database, Plus, AlertTriangle, Clock } from 'lucide-react';
|
||||||
import { useConfirm } from '@/components/confirm-modal';
|
import { useConfirm } from '@/components/confirm-modal';
|
||||||
import { DeleteButtonLabel } from '@/components/delete-button-label';
|
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 { filterManagedServices } from '@/lib/product-type';
|
||||||
import { useApplicationDelete } from '@/lib/use-application-delete';
|
import { useApplicationDelete } from '@/lib/use-application-delete';
|
||||||
|
|
||||||
@@ -130,6 +134,9 @@ export default function ServicesPage() {
|
|||||||
{cardDeleting && (
|
{cardDeleting && (
|
||||||
<DeletingCardOverlay message={deletingResourceMessage('service', svc.name)} />
|
<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">
|
<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">
|
<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" />
|
<Database className="w-5 h-5 text-indigo-600" />
|
||||||
@@ -173,6 +180,7 @@ export default function ServicesPage() {
|
|||||||
<DeleteButtonLabel loading={isDeleting(svc.id)} />
|
<DeleteButtonLabel loading={isDeleting(svc.id)} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -12,8 +12,18 @@ export function deletingResourceMessage(
|
|||||||
return name ? `Deleting “${name}”…` : 'Deleting application…';
|
return name ? `Deleting “${name}”…` : 'Deleting application…';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Single table cell spanning the full row — content centered in the row. */
|
/** Applied to row cells under the glass overlay so content stays visible but blurred. */
|
||||||
export function DeletingTableRowCell({
|
export const deletingRowContentClass =
|
||||||
|
'opacity-45 blur-[2px] pointer-events-none select-none transition-[filter,opacity] duration-200';
|
||||||
|
|
||||||
|
const glassPanelClass =
|
||||||
|
'flex min-h-[52px] h-full w-full items-center justify-center gap-2 bg-white/45 backdrop-blur-md text-sm font-semibold text-gray-900 ring-1 ring-inset ring-white/50';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Glass overlay on a table row. Parent `<tr>` must be `relative`; render all `<td>` cells first,
|
||||||
|
* then this as the last child while deleting.
|
||||||
|
*/
|
||||||
|
export function DeletingTableRowOverlay({
|
||||||
colSpan,
|
colSpan,
|
||||||
message = 'Deleting…',
|
message = 'Deleting…',
|
||||||
}: {
|
}: {
|
||||||
@@ -21,8 +31,13 @@ export function DeletingTableRowCell({
|
|||||||
message?: string;
|
message?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<td colSpan={colSpan} className="px-6 py-4 bg-white/80 backdrop-blur-sm" aria-live="polite" aria-busy="true">
|
<td
|
||||||
<div className="flex min-h-[52px] w-full items-center justify-center gap-2 text-sm font-semibold text-gray-800">
|
colSpan={colSpan}
|
||||||
|
className="!absolute inset-0 z-10 border-0 p-0 m-0 h-full w-full max-w-none bg-transparent"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-busy="true"
|
||||||
|
>
|
||||||
|
<div className={glassPanelClass}>
|
||||||
<Clock className="w-4 h-4 shrink-0 animate-spin text-red-600" />
|
<Clock className="w-4 h-4 shrink-0 animate-spin text-red-600" />
|
||||||
<span>{message}</span>
|
<span>{message}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,11 +45,22 @@ export function DeletingTableRowCell({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full-card overlay while delete is in progress. Parent must be `relative`. */
|
/** @deprecated Use DeletingTableRowOverlay on top of visible row cells */
|
||||||
|
export function DeletingTableRowCell({
|
||||||
|
colSpan,
|
||||||
|
message = 'Deleting…',
|
||||||
|
}: {
|
||||||
|
colSpan: number;
|
||||||
|
message?: string;
|
||||||
|
}) {
|
||||||
|
return <DeletingTableRowOverlay colSpan={colSpan} message={message} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full-card glass overlay while delete is in progress. Parent must be `relative`. */
|
||||||
export function DeletingCardOverlay({ message = 'Deleting…' }: { message?: string }) {
|
export function DeletingCardOverlay({ message = 'Deleting…' }: { message?: string }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-20 flex items-center justify-center gap-2 rounded-xl bg-white/75 backdrop-blur-sm text-sm font-semibold text-gray-800"
|
className="absolute inset-0 z-20 flex items-center justify-center gap-2 rounded-xl bg-white/45 backdrop-blur-md text-sm font-semibold text-gray-900 ring-1 ring-inset ring-white/50"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-busy="true"
|
aria-busy="true"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -12,26 +12,57 @@ import { filterApplications, filterManagedServices } from '@/lib/product-type';
|
|||||||
import { BuildProgressModal, type BuildProgress } from '@/components/build-progress-modal';
|
import { BuildProgressModal, type BuildProgress } from '@/components/build-progress-modal';
|
||||||
import { DeploymentProgressBar } from '@/components/deployment-progress-bar';
|
import { DeploymentProgressBar } from '@/components/deployment-progress-bar';
|
||||||
|
|
||||||
|
function stubAppFromTrack(appId: string, appName: string): Application {
|
||||||
|
return {
|
||||||
|
id: appId,
|
||||||
|
name: appName,
|
||||||
|
runtime: 'nodejs',
|
||||||
|
databaseType: 'none',
|
||||||
|
cpuRequest: '',
|
||||||
|
cpuLimit: '',
|
||||||
|
memoryRequest: '',
|
||||||
|
memoryLimit: '',
|
||||||
|
replicas: 1,
|
||||||
|
port: 3000,
|
||||||
|
userId: '',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
deployments: [
|
||||||
|
{
|
||||||
|
id: `pending-${appId}`,
|
||||||
|
status: 'building',
|
||||||
|
imageTag: '',
|
||||||
|
applicationId: appId,
|
||||||
|
triggeredBy: 'user',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as Application;
|
||||||
|
}
|
||||||
|
|
||||||
export function DeploymentProgressManager() {
|
export function DeploymentProgressManager() {
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { minimized, focusedAppId, minimize, expand } = useDeployProgressStore();
|
const { minimized, focusedAppId, tracked, minimize, expand, stopTracking } =
|
||||||
|
useDeployProgressStore();
|
||||||
|
|
||||||
const refetchWhileDeploying = (list: Application[] | undefined) =>
|
const pollAppsList = (list: Application[] | undefined) => {
|
||||||
getAppsInProgress(list ?? []).length > 0 ? 3000 : false;
|
const pending = useDeployProgressStore.getState().tracked.length;
|
||||||
|
return getAppsInProgress(list ?? []).length > 0 || pending > 0 ? 3000 : false;
|
||||||
|
};
|
||||||
|
|
||||||
const { data: appsList = [] } = useQuery<Application[]>({
|
const { data: appsList = [] } = useQuery<Application[]>({
|
||||||
queryKey: ['applications', 'application'],
|
queryKey: ['applications', 'application'],
|
||||||
queryFn: () => api.get('/applications', { params: { productType: 'application' } }).then((r) => r.data),
|
queryFn: () => api.get('/applications', { params: { productType: 'application' } }).then((r) => r.data),
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
refetchInterval: (query) => refetchWhileDeploying(query.state.data),
|
refetchInterval: (query) => pollAppsList(query.state.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: servicesList = [] } = useQuery<Application[]>({
|
const { data: servicesList = [] } = useQuery<Application[]>({
|
||||||
queryKey: ['applications', 'managed'],
|
queryKey: ['applications', 'managed'],
|
||||||
queryFn: () => api.get('/applications', { params: { productType: 'managed' } }).then((r) => r.data),
|
queryFn: () => api.get('/applications', { params: { productType: 'managed' } }).then((r) => r.data),
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
refetchInterval: (query) => refetchWhileDeploying(query.state.data),
|
refetchInterval: (query) => pollAppsList(query.state.data),
|
||||||
});
|
});
|
||||||
|
|
||||||
const allResources = useMemo(
|
const allResources = useMemo(
|
||||||
@@ -39,7 +70,21 @@ export function DeploymentProgressManager() {
|
|||||||
[appsList, servicesList],
|
[appsList, servicesList],
|
||||||
);
|
);
|
||||||
|
|
||||||
const deployingApps = useMemo(() => getAppsInProgress(allResources), [allResources]);
|
const deployingFromList = useMemo(() => getAppsInProgress(allResources), [allResources]);
|
||||||
|
|
||||||
|
const deployingApps = useMemo(() => {
|
||||||
|
const byId = new Map<string, Application>();
|
||||||
|
for (const app of deployingFromList) {
|
||||||
|
byId.set(app.id, app);
|
||||||
|
}
|
||||||
|
for (const t of tracked) {
|
||||||
|
if (!byId.has(t.appId)) {
|
||||||
|
const fromList = allResources.find((a) => a.id === t.appId);
|
||||||
|
byId.set(t.appId, fromList ?? stubAppFromTrack(t.appId, t.appName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(byId.values());
|
||||||
|
}, [deployingFromList, tracked, allResources]);
|
||||||
|
|
||||||
const progressQueries = useQueries({
|
const progressQueries = useQueries({
|
||||||
queries: deployingApps.map((app) => ({
|
queries: deployingApps.map((app) => ({
|
||||||
@@ -50,6 +95,7 @@ export function DeploymentProgressManager() {
|
|||||||
.then((r) => r.data.progress),
|
.then((r) => r.data.progress),
|
||||||
refetchInterval: 1500,
|
refetchInterval: 1500,
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
enabled: isAuthenticated,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,7 +112,7 @@ export function DeploymentProgressManager() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (deployingApps.length === 0) {
|
if (deployingApps.length === 0) {
|
||||||
useDeployProgressStore.setState({ minimized: false, focusedAppId: null });
|
useDeployProgressStore.setState({ minimized: false, focusedAppId: null, tracked: [] });
|
||||||
}
|
}
|
||||||
}, [deployingApps.length]);
|
}, [deployingApps.length]);
|
||||||
|
|
||||||
@@ -80,12 +126,25 @@ export function DeploymentProgressManager() {
|
|||||||
useDeployProgressStore.setState({ focusedAppId: preferred });
|
useDeployProgressStore.setState({ focusedAppId: preferred });
|
||||||
}, [activeItems, routeAppId]);
|
}, [activeItems, routeAppId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
for (const { app, progress } of activeItems) {
|
||||||
|
if (!progress) continue;
|
||||||
|
if (progress.phase === 'done' || progress.phase === 'failed' || progress.phase === 'cancelled') {
|
||||||
|
const stillBuilding = deployingFromList.some((a) => a.id === app.id);
|
||||||
|
if (!stillBuilding) {
|
||||||
|
stopTracking(app.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [activeItems, deployingFromList, stopTracking]);
|
||||||
|
|
||||||
const focusedItem =
|
const focusedItem =
|
||||||
activeItems.find((item) => item.app.id === focusedAppId) ?? activeItems[0];
|
activeItems.find((item) => item.app.id === focusedAppId) ?? activeItems[0];
|
||||||
|
|
||||||
const focusedProgress =
|
const focusedProgress =
|
||||||
focusedItem?.progress ??
|
focusedItem?.progress ??
|
||||||
({ phase: 'building', percent: 0, message: 'Loading progress…' } satisfies BuildProgress);
|
({ phase: 'building', percent: 0, message: 'Starting deployment…' } satisfies BuildProgress);
|
||||||
|
|
||||||
const showModal =
|
const showModal =
|
||||||
!minimized &&
|
!minimized &&
|
||||||
!!focusedItem &&
|
!!focusedItem &&
|
||||||
|
|||||||
@@ -2,9 +2,19 @@
|
|||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export type TrackedDeploy = {
|
||||||
|
appId: string;
|
||||||
|
appName: string;
|
||||||
|
startedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
interface DeployProgressState {
|
interface DeployProgressState {
|
||||||
minimized: boolean;
|
minimized: boolean;
|
||||||
focusedAppId: string | null;
|
focusedAppId: string | null;
|
||||||
|
/** Optimistic deploy tracking — modal opens before applications list refetches */
|
||||||
|
tracked: TrackedDeploy[];
|
||||||
|
startDeploy: (appId: string, appName?: string) => void;
|
||||||
|
stopTracking: (appId: string) => void;
|
||||||
minimize: (appId: string) => void;
|
minimize: (appId: string) => void;
|
||||||
expand: (appId?: string) => void;
|
expand: (appId?: string) => void;
|
||||||
}
|
}
|
||||||
@@ -12,6 +22,25 @@ interface DeployProgressState {
|
|||||||
export const useDeployProgressStore = create<DeployProgressState>((set) => ({
|
export const useDeployProgressStore = create<DeployProgressState>((set) => ({
|
||||||
minimized: false,
|
minimized: false,
|
||||||
focusedAppId: null,
|
focusedAppId: null,
|
||||||
|
tracked: [],
|
||||||
|
startDeploy: (appId, appName) =>
|
||||||
|
set((state) => ({
|
||||||
|
minimized: false,
|
||||||
|
focusedAppId: appId,
|
||||||
|
tracked: [
|
||||||
|
...state.tracked.filter((t) => t.appId !== appId),
|
||||||
|
{ appId, appName: appName?.trim() || 'Application', startedAt: Date.now() },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
stopTracking: (appId) =>
|
||||||
|
set((state) => {
|
||||||
|
const tracked = state.tracked.filter((t) => t.appId !== appId);
|
||||||
|
const focusedAppId =
|
||||||
|
state.focusedAppId === appId
|
||||||
|
? tracked[0]?.appId ?? null
|
||||||
|
: state.focusedAppId;
|
||||||
|
return { tracked, focusedAppId };
|
||||||
|
}),
|
||||||
minimize: (appId) => set({ minimized: true, focusedAppId: appId }),
|
minimize: (appId) => set({ minimized: true, focusedAppId: appId }),
|
||||||
expand: (appId) =>
|
expand: (appId) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import type { BuildProgress } from '@/components/build-progress-modal';
|
||||||
|
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
|
||||||
|
|
||||||
|
/** Open deploy progress UI immediately and warm build-progress cache. */
|
||||||
|
export function useDeployProgressActions() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const startDeploy = useDeployProgressStore((s) => s.startDeploy);
|
||||||
|
|
||||||
|
const notifyDeployStarted = (appId: string, appName?: string) => {
|
||||||
|
startDeploy(appId, appName);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['application', appId] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['deployments', appId] });
|
||||||
|
void queryClient.prefetchQuery({
|
||||||
|
queryKey: ['build-progress', appId],
|
||||||
|
queryFn: () =>
|
||||||
|
api
|
||||||
|
.get<{ progress: BuildProgress | null }>(`/deployments/applications/${appId}/build-progress`)
|
||||||
|
.then((r) => r.data.progress),
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return { notifyDeployStarted };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user