From 1ade52825c66a3325ea470fa264717ef0a45df00 Mon Sep 17 00:00:00 2001 From: keyhan Date: Mon, 25 May 2026 23:34:24 +0330 Subject: [PATCH] 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 --- .../src/app/dashboard/admin/apps/page.tsx | 40 +++++----- frontend/src/app/dashboard/apps/[id]/page.tsx | 19 ++++- frontend/src/app/dashboard/apps/page.tsx | 33 ++++---- frontend/src/app/dashboard/deploy/page.tsx | 18 ++++- .../src/app/dashboard/services/[id]/page.tsx | 17 ++++- .../src/app/dashboard/services/new/page.tsx | 17 ++++- frontend/src/app/dashboard/services/page.tsx | 10 ++- frontend/src/components/deleting-overlay.tsx | 38 ++++++++-- .../deployment-progress-manager.tsx | 75 +++++++++++++++++-- frontend/src/lib/deploy-progress-store.ts | 29 +++++++ .../src/lib/use-deploy-progress-actions.ts | 29 +++++++ 11 files changed, 266 insertions(+), 59 deletions(-) create mode 100644 frontend/src/lib/use-deploy-progress-actions.ts diff --git a/frontend/src/app/dashboard/admin/apps/page.tsx b/frontend/src/app/dashboard/admin/apps/page.tsx index 22f53e0..11eb705 100644 --- a/frontend/src/app/dashboard/admin/apps/page.tsx +++ b/frontend/src/app/dashboard/admin/apps/page.tsx @@ -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 ( - {rowDeleting ? ( - - ) : ( - <> - +
@@ -333,7 +331,7 @@ export default function AdminAppsPage() {
- + {app.user ? (
@@ -346,12 +344,12 @@ export default function AdminAppsPage() { {app.userId} )} - + {latestStatus} - + )} - + {assignedCluster ? (
{assignedCluster.name} @@ -382,7 +380,7 @@ export default function AdminAppsPage() { Not assigned )} - + {latestMigration ? ( )} - + {app.billingCycle && ( No plan )} - +
View @@ -441,7 +439,11 @@ export default function AdminAppsPage() {
- + {rowDeleting && ( + )} ); @@ -467,6 +469,7 @@ export default function AdminAppsPage() { {cardDeleting && ( )} +
@@ -570,6 +573,7 @@ export default function AdminAppsPage() {
+
); })} diff --git a/frontend/src/app/dashboard/apps/[id]/page.tsx b/frontend/src/app/dashboard/apps/[id]/page.tsx index 72ca3a5..e3b7aea 100644 --- a/frontend/src/app/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/dashboard/apps/[id]/page.tsx @@ -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({ diff --git a/frontend/src/app/dashboard/apps/page.tsx b/frontend/src/app/dashboard/apps/page.tsx index 44e1cb3..b45ce89 100644 --- a/frontend/src/app/dashboard/apps/page.tsx +++ b/frontend/src/app/dashboard/apps/page.tsx @@ -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 ( - {rowDeleting ? ( - - ) : ( - <> - +
@@ -149,17 +146,17 @@ export default function AppsPage() { {app.name} - {app.runtime} - + {app.runtime} + {latestStatus} - + {lifecycle === 'suspended' && } {lifecycleLabels[lifecycle] || lifecycle} - + {app.planExpiresAt ? ( @@ -169,7 +166,7 @@ export default function AppsPage() { No plan )} - +
View @@ -192,7 +189,11 @@ export default function AppsPage() {
- + {rowDeleting && ( + )} ); diff --git a/frontend/src/app/dashboard/deploy/page.tsx b/frontend/src/app/dashboard/deploy/page.tsx index d8c029d..9d4bc8c 100644 --- a/frontend/src/app/dashboard/deploy/page.tsx +++ b/frontend/src/app/dashboard/deploy/page.tsx @@ -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({ @@ -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}`); }, diff --git a/frontend/src/app/dashboard/services/[id]/page.tsx b/frontend/src/app/dashboard/services/[id]/page.tsx index aaf8dda..bdf2284 100644 --- a/frontend/src/app/dashboard/services/[id]/page.tsx +++ b/frontend/src/app/dashboard/services/[id]/page.tsx @@ -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'); }, diff --git a/frontend/src/app/dashboard/services/new/page.tsx b/frontend/src/app/dashboard/services/new/page.tsx index 9bd4687..0738544 100644 --- a/frontend/src/app/dashboard/services/new/page.tsx +++ b/frontend/src/app/dashboard/services/new/page.tsx @@ -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(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'); }), diff --git a/frontend/src/app/dashboard/services/page.tsx b/frontend/src/app/dashboard/services/page.tsx index 03f7752..8863135 100644 --- a/frontend/src/app/dashboard/services/page.tsx +++ b/frontend/src/app/dashboard/services/page.tsx @@ -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 && ( )} +
@@ -173,6 +180,7 @@ export default function ServicesPage() {
+
); })} diff --git a/frontend/src/components/deleting-overlay.tsx b/frontend/src/components/deleting-overlay.tsx index 07afe0a..de0ba2c 100644 --- a/frontend/src/components/deleting-overlay.tsx +++ b/frontend/src/components/deleting-overlay.tsx @@ -12,8 +12,18 @@ export function deletingResourceMessage( return name ? `Deleting “${name}”…` : 'Deleting application…'; } -/** Single table cell spanning the full row — content centered in the row. */ -export function DeletingTableRowCell({ +/** Applied to row cells under the glass overlay so content stays visible but blurred. */ +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 `` must be `relative`; render all `` cells first, + * then this as the last child while deleting. + */ +export function DeletingTableRowOverlay({ colSpan, message = 'Deleting…', }: { @@ -21,8 +31,13 @@ export function DeletingTableRowCell({ message?: string; }) { return ( - -
+ +
{message}
@@ -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 ; +} + +/** Full-card glass overlay while delete is in progress. Parent must be `relative`. */ export function DeletingCardOverlay({ message = 'Deleting…' }: { message?: string }) { return (
diff --git a/frontend/src/components/deployment-progress-manager.tsx b/frontend/src/components/deployment-progress-manager.tsx index 5f476db..cc53998 100644 --- a/frontend/src/components/deployment-progress-manager.tsx +++ b/frontend/src/components/deployment-progress-manager.tsx @@ -12,26 +12,57 @@ import { filterApplications, filterManagedServices } from '@/lib/product-type'; import { BuildProgressModal, type BuildProgress } from '@/components/build-progress-modal'; 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() { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const pathname = usePathname(); - const { minimized, focusedAppId, minimize, expand } = useDeployProgressStore(); + const { minimized, focusedAppId, tracked, minimize, expand, stopTracking } = + useDeployProgressStore(); - const refetchWhileDeploying = (list: Application[] | undefined) => - getAppsInProgress(list ?? []).length > 0 ? 3000 : false; + const pollAppsList = (list: Application[] | undefined) => { + const pending = useDeployProgressStore.getState().tracked.length; + return getAppsInProgress(list ?? []).length > 0 || pending > 0 ? 3000 : false; + }; const { data: appsList = [] } = useQuery({ queryKey: ['applications', 'application'], queryFn: () => api.get('/applications', { params: { productType: 'application' } }).then((r) => r.data), enabled: isAuthenticated, - refetchInterval: (query) => refetchWhileDeploying(query.state.data), + refetchInterval: (query) => pollAppsList(query.state.data), }); const { data: servicesList = [] } = useQuery({ queryKey: ['applications', 'managed'], queryFn: () => api.get('/applications', { params: { productType: 'managed' } }).then((r) => r.data), enabled: isAuthenticated, - refetchInterval: (query) => refetchWhileDeploying(query.state.data), + refetchInterval: (query) => pollAppsList(query.state.data), }); const allResources = useMemo( @@ -39,7 +70,21 @@ export function DeploymentProgressManager() { [appsList, servicesList], ); - const deployingApps = useMemo(() => getAppsInProgress(allResources), [allResources]); + const deployingFromList = useMemo(() => getAppsInProgress(allResources), [allResources]); + + const deployingApps = useMemo(() => { + const byId = new Map(); + 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({ queries: deployingApps.map((app) => ({ @@ -50,6 +95,7 @@ export function DeploymentProgressManager() { .then((r) => r.data.progress), refetchInterval: 1500, placeholderData: keepPreviousData, + enabled: isAuthenticated, })), }); @@ -66,7 +112,7 @@ export function DeploymentProgressManager() { useEffect(() => { if (deployingApps.length === 0) { - useDeployProgressStore.setState({ minimized: false, focusedAppId: null }); + useDeployProgressStore.setState({ minimized: false, focusedAppId: null, tracked: [] }); } }, [deployingApps.length]); @@ -80,12 +126,25 @@ export function DeploymentProgressManager() { useDeployProgressStore.setState({ focusedAppId: preferred }); }, [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 = activeItems.find((item) => item.app.id === focusedAppId) ?? activeItems[0]; const focusedProgress = focusedItem?.progress ?? - ({ phase: 'building', percent: 0, message: 'Loading progress…' } satisfies BuildProgress); + ({ phase: 'building', percent: 0, message: 'Starting deployment…' } satisfies BuildProgress); + const showModal = !minimized && !!focusedItem && diff --git a/frontend/src/lib/deploy-progress-store.ts b/frontend/src/lib/deploy-progress-store.ts index d2117ed..97d93b1 100644 --- a/frontend/src/lib/deploy-progress-store.ts +++ b/frontend/src/lib/deploy-progress-store.ts @@ -2,9 +2,19 @@ import { create } from 'zustand'; +export type TrackedDeploy = { + appId: string; + appName: string; + startedAt: number; +}; + interface DeployProgressState { minimized: boolean; 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; expand: (appId?: string) => void; } @@ -12,6 +22,25 @@ interface DeployProgressState { export const useDeployProgressStore = create((set) => ({ minimized: false, 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 }), expand: (appId) => set((state) => ({ diff --git a/frontend/src/lib/use-deploy-progress-actions.ts b/frontend/src/lib/use-deploy-progress-actions.ts new file mode 100644 index 0000000..c5f3152 --- /dev/null +++ b/frontend/src/lib/use-deploy-progress-actions.ts @@ -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 }; +}