diff --git a/src/features/instance/applications/DESIGN.md b/src/features/instance/applications/DESIGN.md
index e58317fc0..b73a0b2c8 100644
--- a/src/features/instance/applications/DESIGN.md
+++ b/src/features/instance/applications/DESIGN.md
@@ -55,3 +55,15 @@ tell it from a gateway's. So [`onDeployComponentSubmit`](../../../integrations/a
turns those into `SSEInconclusiveError`, which every deploy caller already answers by checking rather than resending.
`streamOperation` stays neutral: it puts the `status` on `SSEUnsupportedError` and leaves the decision to the caller,
because a read (`get_deployment`) may fall back on anything.
+
+## Who reports a failed deploy
+
+Every caller of [`useDeployComponentMutation`](../../../integrations/api/instance/applications/deployComponent.ts)
+shows its own failure. The mutation opts out of the global error toast and reports the failure to RUM itself, with a
+mutation-level `console.error` that still runs after the caller unmounts. A new caller has to render or toast the
+rejection, or the person sees nothing.
+
+Callers await `mutateAsync` rather than reporting from `mutate()` callbacks, which React Query drops once the caller
+unmounts. Following a rollout keeps a deploy pending for up to 30 minutes, so its outcome often lands after the form
+or modal that started it is gone. A failure that lands while its progress panel or import form is still on screen is
+shown there only; one that lands later goes to a toast, with the deployment record when the failure named one.
diff --git a/src/features/instance/applications/components/DeployProgress/DeployFailure.tsx b/src/features/instance/applications/components/DeployProgress/DeployFailure.tsx
new file mode 100644
index 000000000..3ae037dca
--- /dev/null
+++ b/src/features/instance/applications/components/DeployProgress/DeployFailure.tsx
@@ -0,0 +1,53 @@
+import { deploymentRecordPath } from '@/features/instance/applications/hooks/useDeploymentRecordAction';
+import { buildAbsoluteLinkToPage } from '@/lib/urls/buildAbsoluteLinkToPage';
+import { Link, useParams } from '@tanstack/react-router';
+import { CircleIcon } from 'lucide-react';
+
+// The instance's own SSH keys fix this, so the failure links to them.
+const SSH_ACCESS_FAILURE = /Failed to deploy private repository\b|SSH access failed/i;
+
+export function DeployFailure({
+ title,
+ message,
+ deploymentId,
+ onNavigateAway,
+}: {
+ title?: string;
+ message: string;
+ /** Only where the host shows no record link of its own. */
+ deploymentId?: string;
+ /** Called when a guidance link navigates away — lets a hosting modal close itself first. */
+ onNavigateAway?: () => void;
+}) {
+ const params: { clusterId?: string; instanceId?: string; organizationId?: string } = useParams({ strict: false });
+
+ return (
+
+
+
+
+ {title && {title} }
+ {message}
+
+
+ {SSH_ACCESS_FAILURE.test(message) && (
+
+ Manage SSH keys
+
+ )}
+ {deploymentId && (
+
+ View this deployment's record
+
+ )}
+
+ );
+}
diff --git a/src/features/instance/applications/components/DeployProgress/DeployProgress.tsx b/src/features/instance/applications/components/DeployProgress/DeployProgress.tsx
index bec4f3674..eacadf535 100644
--- a/src/features/instance/applications/components/DeployProgress/DeployProgress.tsx
+++ b/src/features/instance/applications/components/DeployProgress/DeployProgress.tsx
@@ -7,18 +7,10 @@ import { CertificationDecision } from '@/integrations/api/instance/applications/
import { RolloutNode, RolloutSnapshot } from '@/integrations/api/instance/applications/restartJob';
import { cn } from '@/lib/cn';
import { errorText } from '@/lib/errorText';
-import { buildAbsoluteLinkToPage } from '@/lib/urls/buildAbsoluteLinkToPage';
import { Link, useParams } from '@tanstack/react-router';
-import {
- CheckIcon,
- CircleDashedIcon,
- CircleIcon,
- Loader2Icon,
- ShieldCheckIcon,
- TriangleAlertIcon,
- XIcon,
-} from 'lucide-react';
+import { CheckIcon, CircleDashedIcon, Loader2Icon, ShieldCheckIcon, TriangleAlertIcon, XIcon } from 'lucide-react';
import { useEffect, useRef } from 'react';
+import { DeployFailure } from './DeployFailure';
import {
describeCertification,
describeDecision,
@@ -36,16 +28,6 @@ const PHASE_LABELS: Record = {
staged: 'Staged',
};
-/**
- * A deploy that failed because the *instance* couldn't reach a private repository over SSH is
- * fixed in Config > SSH Keys (install a key, grant it access to the repo, add the git host to
- * `known_hosts`), so link the user there instead of leaving them to find it. Harper's message
- * already spells out the remediation, and this UI is now the only place the failure surfaces:
- * it's an instance configuration state, not a Studio bug, so `shouldKeepEvent` drops the RUM
- * event (which also kept the customer's private repo URL out of Error Tracking).
- */
-const SSH_ACCESS_FAILURE = /Failed to deploy private repository\b|SSH access failed/i;
-
function PhaseIcon({ status, isCurrent }: { status?: PhaseStatus; isCurrent: boolean }) {
if (status === 'done') {
return ;
@@ -164,21 +146,7 @@ export function DeployProgress({
{phasesFailed && state.decision && }
{phasesFailed && state.error && !state.decision && (
-
-
-
- {state.error}
-
- {SSH_ACCESS_FAILURE.test(state.error) && (
-
- Manage SSH keys
-
- )}
-
+
)}
{state.lifecycle === 'inconclusive' && !state.rollout && (
diff --git a/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx b/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx
index a63381c2d..168e481ee 100644
--- a/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx
+++ b/src/features/instance/applications/components/NewApplication/ImportInstructions.tsx
@@ -10,6 +10,7 @@ import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { DeployFailure } from '@/features/instance/applications/components/DeployProgress/DeployFailure';
import { useStagedDeploymentsAvailable } from '@/features/instance/applications/hooks/useSupportsDeploymentSSE';
import { GitMergeIcon, LinkIcon, PackageIcon, RocketIcon } from 'lucide-react';
import { useCallback } from 'react';
@@ -17,16 +18,19 @@ import { Control, FormState, UseFormSetValue, UseFormWatch } from 'react-hook-fo
import { z } from 'zod';
import { ImportAuthorization } from './ImportAuthorization';
import { NewApplicationSchema } from './schema';
+import { ImportFailure } from './useImportApplication';
export function ImportInstructions({
control,
formState,
+ importFailure,
isImportingApplication,
setValue,
watch,
}: {
control: Control>;
formState: FormState>;
+ importFailure?: ImportFailure;
isImportingApplication: boolean;
setValue: UseFormSetValue>;
watch: UseFormWatch>;
@@ -180,6 +184,14 @@ export function ImportInstructions({
+ {importFailure && (
+
+ )}
+
({
+ importFailure: undefined as { title: string; message: string; deploymentId?: string } | undefined,
+}));
+
vi.mock('@tanstack/react-router', async (importOriginal) => ({
...(await importOriginal()),
useRouteContext: () => ({ organization: { name: 'Acme' } }),
+ useParams: () => ({ organizationId: 'org-1', instanceId: 'ins-1' }),
+ Link: ({ children, to }: { children?: ReactNode; to?: string }) => {children} ,
}));
vi.mock('@/features/instance/applications/hooks/useEditorView', () => ({
useEditorView: () => ({ rootEntries: [{ name: 'taken-app', path: 'taken-app', project: 'taken-app', entries: [] }] }),
@@ -15,7 +22,11 @@ vi.mock('./useCreateFromTemplate', () => ({
useCreateFromTemplate: () => ({ isCreatingFromTemplate: false, createFromTemplate: vi.fn() }),
}));
vi.mock('./useImportApplication', () => ({
- useImportApplication: () => ({ isImportingApplication: false, importApplication: vi.fn() }),
+ useImportApplication: () => ({
+ isImportingApplication: false,
+ importApplication: vi.fn(),
+ importFailure: importState.importFailure,
+ }),
}));
vi.mock('./useCheckCLI', () => ({ useCheckCLI: () => ({ checkCLI: vi.fn() }) }));
// These read the instance's SSH keys and auth, which aren't what these tests exercise.
@@ -35,7 +46,10 @@ beforeAll(() => {
Element.prototype.scrollIntoView ??= () => undefined;
});
-afterEach(cleanup);
+afterEach(() => {
+ cleanup();
+ importState.importFailure = undefined;
+});
function button(name: RegExp) {
return screen.getByRole('button', { name }) as HTMLButtonElement;
@@ -113,4 +127,47 @@ describe('NewApplication', () => {
expect(await screen.findByText(message)).toBeTruthy();
expect((screen.getByRole('button', { name: /Create from Template/ }) as HTMLButtonElement).disabled).toBe(true);
});
+
+ it("keeps a failed import's reason beside the Import button", async () => {
+ importState.importFailure = {
+ title: 'Failed to import acme',
+ message: 'This repository has no package.json in its root folder.',
+ };
+ render( );
+
+ await switchTo(/Import/);
+
+ const alert = await screen.findByRole('alert');
+ expect(alert.textContent).toContain('Failed to import acme');
+ expect(alert.textContent).toContain('This repository has no package.json in its root folder.');
+ expect(screen.queryByRole('link', { name: 'Manage SSH keys' })).toBeNull();
+ });
+
+ it('links a failed private-repository import to SSH keys', async () => {
+ importState.importFailure = {
+ title: 'Failed to import acme',
+ message: 'Failed to deploy private repository ssh://github.com/acme/widgets.git: SSH access failed.',
+ };
+ render( );
+
+ await switchTo(/Import/);
+
+ expect((await screen.findByRole('link', { name: 'Manage SSH keys' })).getAttribute('href'))
+ .toBe('/org-1/instance/ins-1/config/ssh-keys');
+ });
+
+ it('links a failed import to the deployment record it left', async () => {
+ importState.importFailure = {
+ title: 'Failed to stage acme',
+ message: 'Not every node staged it',
+ deploymentId: 'd1',
+ };
+ render( );
+
+ await switchTo(/Import/);
+
+ expect((await screen.findByRole('alert')).textContent).toContain('Failed to stage acme');
+ expect(screen.getByRole('link', { name: "View this deployment's record" }).getAttribute('href'))
+ .toBe('/org-1/instance/ins-1/config/deployments/d1');
+ });
});
diff --git a/src/features/instance/applications/components/NewApplication/index.tsx b/src/features/instance/applications/components/NewApplication/index.tsx
index e49c2de81..da32dcefb 100644
--- a/src/features/instance/applications/components/NewApplication/index.tsx
+++ b/src/features/instance/applications/components/NewApplication/index.tsx
@@ -78,7 +78,7 @@ export function NewApplication() {
const [isReloading, setIsReloading] = useState(false);
const { isCreatingFromTemplate, createFromTemplate } = useCreateFromTemplate(setIsReloading);
- const { isImportingApplication, importApplication } = useImportApplication(setIsReloading);
+ const { isImportingApplication, importApplication, importFailure } = useImportApplication(setIsReloading);
const { checkCLI } = useCheckCLI(setIsReloading);
const submitForm = useCallback((formData: z.infer) => {
@@ -162,6 +162,7 @@ export function NewApplication() {
{
+ const post = vi.fn();
+ return {
+ post,
+ sse: { enabled: false },
+ instanceParams: { entityId: 'ins-1', entityType: 'instance', instanceClient: { post } } as {
+ entityId: string;
+ entityType: string;
+ instanceClient: { post: typeof post };
+ },
+ editorView: {
+ reloadRootEntries: vi.fn(),
+ setFocusedItem: vi.fn(),
+ setExpandedItems: vi.fn(),
+ setSelectedItems: vi.fn(),
+ },
+ runHealthCheck: vi.fn(),
+ canStage: { enabled: false },
+ recordAction: vi.fn((deploymentId: string | undefined) =>
+ deploymentId ? { label: 'View record', onClick: () => undefined } : undefined
+ ),
+ };
+});
+
+vi.mock('sonner', () => ({
+ toast: {
+ loading: vi.fn(() => 'loading-toast'),
+ dismiss: vi.fn(),
+ error: vi.fn(),
+ success: vi.fn(),
+ warning: vi.fn(),
+ },
+}));
+vi.mock('@/config/useInstanceClient', () => ({ useInstanceClientIdParams: () => mocks.instanceParams }));
+vi.mock('@/features/instance/applications/hooks/useSupportsDeploymentSSE', () => ({
+ useSupportsDeploymentSSE: () => mocks.sse.enabled,
+ useStagedDeploymentsAvailable: () => mocks.canStage.enabled,
+}));
+vi.mock('@/features/instance/applications/hooks/useDeploymentRecordAction', () => ({
+ useDeploymentRecordAction: () => mocks.recordAction,
+}));
+vi.mock('@/features/instance/applications/hooks/useComponentHealthCheck', () => ({
+ useComponentHealthCheck: () => mocks.runHealthCheck,
+}));
+vi.mock('@/features/instance/applications/hooks/useEditorView', () => ({ useEditorView: () => mocks.editorView }));
+vi.mock('@/integrations/api/instance/applications/deployComponentStream', () => ({ deployComponentStream: vi.fn() }));
+
+import { deployComponentStream } from '@/integrations/api/instance/applications/deployComponentStream';
+import { SSEInconclusiveError } from '@/integrations/api/sse/errors';
+import { mutationErrorHandler } from '@/react-query/queryClient';
+import { MutationCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { act, renderHook, waitFor } from '@testing-library/react';
+import { AxiosError, AxiosHeaders, InternalAxiosRequestConfig } from 'axios';
+import { createElement, ReactNode } from 'react';
+import { toast } from 'sonner';
+import { defaultImportOptions } from './schema';
+import { useImportApplication } from './useImportApplication';
+
+const contents = { ...defaultImportOptions, source: 'npm' as const, ref: 'my-app@1.0.0' };
+
+function serverFailure(data: unknown) {
+ const config = { headers: new AxiosHeaders() } as InternalAxiosRequestConfig;
+ return new AxiosError('Request failed with status code 500', AxiosError.ERR_BAD_RESPONSE, config, undefined, {
+ data,
+ status: 500,
+ statusText: 'Internal Server Error',
+ headers: {},
+ config,
+ });
+}
+
+function deferred() {
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((_, fail) => {
+ reject = fail;
+ });
+ return { promise, reject };
+}
+
+function renderImport() {
+ // The app's own routing, so the deploy mutation's opt-out from the global toast is what's tested.
+ const queryClient = new QueryClient({ mutationCache: new MutationCache({ onError: mutationErrorHandler }) });
+ return renderHook(() => useImportApplication(vi.fn()), {
+ wrapper: ({ children }: { children: ReactNode }) =>
+ createElement(QueryClientProvider, { client: queryClient }, children),
+ });
+}
+
+let consoleError: ReturnType;
+
+beforeEach(() => {
+ consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ mocks.sse.enabled = false;
+ mocks.canStage.enabled = false;
+ mocks.instanceParams = { ...mocks.instanceParams, entityId: 'ins-1' };
+});
+
+afterEach(() => {
+ consoleError?.mockRestore();
+ vi.clearAllMocks();
+});
+
+describe('useImportApplication', () => {
+ it("keeps a failed import's reason for the form to show, and toasts nothing", async () => {
+ const failure = serverFailure({ error: 'Component my-app could not be loaded' });
+ mocks.post.mockRejectedValue(failure);
+ const { result } = renderImport();
+
+ act(() => result.current.importApplication({ contents, project: 'my-app' }));
+
+ await waitFor(() =>
+ expect(result.current.importFailure).toEqual({
+ title: 'Failed to import my-app',
+ message: 'Component my-app could not be loaded',
+ })
+ );
+ expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
+ expect(vi.mocked(toast.dismiss)).toHaveBeenCalledWith('loading-toast');
+ expect(consoleError).toHaveBeenCalledOnce();
+ expect(consoleError).toHaveBeenCalledWith(failure);
+ });
+
+ it('clears the failure when the import is retried', async () => {
+ mocks.post.mockRejectedValueOnce(serverFailure({ error: 'Component my-app could not be loaded' }));
+ const { result } = renderImport();
+ act(() => result.current.importApplication({ contents, project: 'my-app' }));
+ await waitFor(() => expect(result.current.importFailure).toBeDefined());
+
+ mocks.post.mockReturnValueOnce(deferred().promise);
+ act(() => result.current.importApplication({ contents, project: 'my-app' }));
+
+ expect(result.current.importFailure).toBeUndefined();
+ });
+
+ it('toasts a failure that arrives after the form has gone, the only place left to show it', async () => {
+ const pending = deferred();
+ mocks.post.mockReturnValue(pending.promise);
+ const { result, unmount } = renderImport();
+ act(() => result.current.importApplication({ contents, project: 'my-app' }));
+
+ unmount();
+ pending.reject(serverFailure({ error: 'Component my-app could not be loaded' }));
+
+ await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalledOnce());
+ expect(vi.mocked(toast.error)).toHaveBeenCalledWith('Failed to import my-app', {
+ description: 'Component my-app could not be loaded',
+ });
+ expect(consoleError).toHaveBeenCalledOnce();
+ });
+
+ it("reports an import that finishes after the form has gone, without moving the editor's selection", async () => {
+ let succeed!: (value: unknown) => void;
+ mocks.post.mockReturnValue(
+ new Promise((resolve) => {
+ succeed = resolve;
+ }),
+ );
+ const { result, unmount } = renderImport();
+ act(() => result.current.importApplication({ contents, project: 'my-app' }));
+
+ unmount();
+ succeed({ data: { message: 'Successfully deployed: my-app' } });
+
+ await waitFor(() => expect(vi.mocked(toast.success)).toHaveBeenCalledOnce());
+ expect(vi.mocked(toast.dismiss)).toHaveBeenCalledWith('loading-toast');
+ expect(mocks.editorView.reloadRootEntries).toHaveBeenCalledOnce();
+ expect(mocks.editorView.setSelectedItems).not.toHaveBeenCalled();
+ expect(mocks.editorView.setFocusedItem).not.toHaveBeenCalled();
+ });
+
+ it('verifies an inconclusive stream instead of reporting it as a failure', async () => {
+ mocks.sse.enabled = true;
+ mocks.runHealthCheck.mockResolvedValue({ level: 'healthy' });
+ vi.mocked(deployComponentStream).mockRejectedValue(new SSEInconclusiveError('stream ended'));
+ onTestFinished(() => {
+ vi.mocked(deployComponentStream).mockReset();
+ });
+ const { result } = renderImport();
+
+ act(() => result.current.importApplication({ contents, project: 'my-app' }));
+
+ await waitFor(() => expect(vi.mocked(toast.success)).toHaveBeenCalledWith('my-app deployed and healthy', {}));
+ expect(result.current.importFailure).toBeUndefined();
+ expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
+ });
+
+ it("keeps a failure off another instance's form, and toasts it with its own record", async () => {
+ const pending = deferred();
+ mocks.post.mockReturnValue(pending.promise);
+ const { result, rerender } = renderImport();
+ act(() => result.current.importApplication({ contents, project: 'my-app' }));
+
+ mocks.instanceParams = { ...mocks.instanceParams, entityId: 'ins-2' };
+ rerender();
+ await act(async () =>
+ pending.reject(serverFailure({ error: 'Component my-app could not be loaded', deployment_id: 'd1' }))
+ );
+
+ await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalledOnce());
+ expect(vi.mocked(toast.error)).toHaveBeenCalledWith('Failed to import my-app', {
+ description: 'Component my-app could not be loaded',
+ action: { label: 'View record', onClick: expect.any(Function) },
+ });
+ expect(result.current.importFailure).toBeUndefined();
+ });
+
+ it("leaves another instance's editor selection alone when the import finishes there", async () => {
+ let succeed!: (value: unknown) => void;
+ mocks.post.mockReturnValue(
+ new Promise((resolve) => {
+ succeed = resolve;
+ }),
+ );
+ const { result, rerender } = renderImport();
+ act(() => result.current.importApplication({ contents, project: 'my-app' }));
+
+ mocks.instanceParams = { ...mocks.instanceParams, entityId: 'ins-2' };
+ rerender();
+ await act(async () => succeed({ data: { message: 'Successfully deployed: my-app' } }));
+
+ await waitFor(() => expect(vi.mocked(toast.success)).toHaveBeenCalledOnce());
+ expect(mocks.editorView.setSelectedItems).not.toHaveBeenCalled();
+ });
+
+ it("shows an instance's failure on its own form only", async () => {
+ mocks.post.mockRejectedValue(serverFailure({ error: 'Component my-app could not be loaded' }));
+ const { result, rerender } = renderImport();
+ act(() => result.current.importApplication({ contents, project: 'my-app' }));
+ await waitFor(() => expect(result.current.importFailure).toBeDefined());
+
+ const first = mocks.instanceParams;
+ mocks.instanceParams = { ...first, entityId: 'ins-2' };
+ rerender();
+ expect(result.current.importFailure).toBeUndefined();
+
+ mocks.instanceParams = first;
+ rerender();
+ expect(result.current.importFailure?.title).toBe('Failed to import my-app');
+ });
+
+ it('names a failed stage as a stage, and keeps the record it left', async () => {
+ mocks.canStage.enabled = true;
+ mocks.post.mockRejectedValue(serverFailure({ error: 'Not every node staged it', deployment_id: 'd1' }));
+ const { result } = renderImport();
+
+ act(() => result.current.importApplication({ contents: { ...contents, stageOnly: true }, project: 'my-app' }));
+
+ await waitFor(() =>
+ expect(result.current.importFailure).toEqual({
+ title: 'Failed to stage my-app',
+ message: 'Not every node staged it',
+ deploymentId: 'd1',
+ })
+ );
+ expect(mocks.post).toHaveBeenCalledWith('/', expect.objectContaining({ activate: false }), expect.anything());
+ });
+
+ describe('a rollout that settles after the form has gone', () => {
+ function deployThenFollow(job: () => Promise) {
+ mocks.post.mockImplementation((_url: string, body: { operation: string }) =>
+ body.operation === 'deploy_component'
+ ? Promise.resolve({ data: { message: 'ok', deployment_id: 'd1', restartJobId: 'job-1' } })
+ : job()
+ );
+ }
+
+ it('still replaces the progress toast with the outcome', async () => {
+ let finish!: () => void;
+ deployThenFollow(() =>
+ new Promise((resolve) => {
+ finish = () =>
+ resolve({
+ data: [{ id: 'job-1', status: 'COMPLETE', result: { replicated: [] } }],
+ });
+ })
+ );
+ mocks.runHealthCheck.mockResolvedValue({ level: 'healthy' });
+ const { result, unmount } = renderImport();
+ act(() => result.current.importApplication({ contents, project: 'my-app' }));
+ await waitFor(() => expect(finish).toBeDefined());
+
+ unmount();
+ finish();
+
+ await waitFor(() =>
+ expect(vi.mocked(toast.success)).toHaveBeenCalledWith('my-app deployed and healthy', expect.anything())
+ );
+ expect(vi.mocked(toast.dismiss)).toHaveBeenCalledWith('loading-toast');
+ expect(mocks.editorView.setSelectedItems).not.toHaveBeenCalled();
+ });
+
+ it('still says which nodes did not take it, once', async () => {
+ let fail!: () => void;
+ deployThenFollow(() =>
+ new Promise((resolve) => {
+ fail = () => resolve({ data: [{ id: 'job-1', status: 'ERROR', message: 'node-c did not restart' }] });
+ })
+ );
+ const { result, unmount } = renderImport();
+ act(() => result.current.importApplication({ contents, project: 'my-app' }));
+ await waitFor(() => expect(fail).toBeDefined());
+
+ unmount();
+ fail();
+
+ await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalledOnce());
+ expect(vi.mocked(toast.error)).toHaveBeenCalledWith('my-app did not reach every node', {
+ description: 'node-c did not restart',
+ action: { label: 'View record', onClick: expect.any(Function) },
+ });
+ expect(vi.mocked(toast.dismiss)).toHaveBeenCalledWith('loading-toast');
+ expect(consoleError).toHaveBeenCalledOnce();
+ });
+ });
+});
diff --git a/src/features/instance/applications/components/NewApplication/useImportApplication.ts b/src/features/instance/applications/components/NewApplication/useImportApplication.ts
index 156156d32..83ab4cd48 100644
--- a/src/features/instance/applications/components/NewApplication/useImportApplication.ts
+++ b/src/features/instance/applications/components/NewApplication/useImportApplication.ts
@@ -8,12 +8,14 @@ import {
} from '@/features/instance/applications/hooks/useSupportsDeploymentSSE';
import { reportDeployOutcome, reportStaged } from '@/features/instance/applications/modals/reportDeployHealth';
import { useDeployComponentMutation } from '@/integrations/api/instance/applications/deployComponent';
+import { DeployComponentResult } from '@/integrations/api/instance/applications/deployComponentStream';
import { deployFailureDetails } from '@/integrations/api/instance/applications/deployOutcome';
import { DeployRolloutError } from '@/integrations/api/instance/applications/restartJob';
import { SSEInconclusiveError } from '@/integrations/api/sse/errors';
import { invalidateEntityQueries } from '@/react-query/invalidateEntityQueries';
+import { describeError } from '@/react-query/queryClient';
import { useQueryClient } from '@tanstack/react-query';
-import { useCallback, useMemo } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'sonner';
import { z } from 'zod';
import { ImportSchema } from './schema';
@@ -26,29 +28,52 @@ const PHASE_LABELS: Record = {
success: 'Finishing…',
};
+export interface ImportFailure {
+ title: string;
+ message: string;
+ deploymentId?: string;
+}
+
+interface ImportArgs {
+ contents: z.infer;
+ project: string;
+}
+
export function useImportApplication(
setIsReloading: (isReloading: boolean) => void,
) {
- const { mutate, isPending: isImportingApplication } = useDeployComponentMutation();
-
+ const { mutateAsync, isPending: isImportingApplication } = useDeployComponentMutation();
const queryClient = useQueryClient();
const instanceParams = useInstanceClientIdParams();
+ type EntityId = typeof instanceParams.entityId;
+ const [importFailure, setImportFailure] = useState<{ entityId: EntityId; failure: ImportFailure }>();
+ // The instance whose form is on screen, if any: this hook outlives a switch to another instance's form.
+ const formEntity = useRef(undefined);
+ useEffect(() => {
+ formEntity.current = instanceParams.entityId;
+ return () => {
+ formEntity.current = undefined;
+ };
+ }, [instanceParams.entityId]);
const sseDeploy = useSupportsDeploymentSSE();
const canStage = useStagedDeploymentsAvailable();
const runHealthCheck = useComponentHealthCheck(instanceParams);
const recordAction = useDeploymentRecordAction();
const { reloadRootEntries, setFocusedItem, setExpandedItems, setSelectedItems } = useEditorView();
- const onImported = useCallback((project: string) => {
- setIsReloading(true);
- void invalidateEntityQueries(queryClient, instanceParams.entityId);
+ const onImported = useCallback((project: string, entityId: EntityId) => {
+ void invalidateEntityQueries(queryClient, entityId);
void reloadRootEntries();
+ // Someone who left the form mid-import is looking at something else; leave their selection be.
+ if (formEntity.current !== entityId) {
+ return;
+ }
+ setIsReloading(true);
setFocusedItem(project);
setSelectedItems([project]);
setExpandedItems([project]);
}, [
queryClient,
- instanceParams.entityId,
reloadRootEntries,
setFocusedItem,
setSelectedItems,
@@ -56,101 +81,107 @@ export function useImportApplication(
setIsReloading,
]);
- const callback = useCallback(({
- contents,
- project,
- }: {
- contents: z.infer;
- project: string;
- }) => {
+ const runImport = useCallback(async ({ contents, project }: ImportArgs) => {
+ const { entityId } = instanceParams;
+ setImportFailure(undefined);
const stage = canStage && contents.stageOnly;
+ const progressTitle = `${stage ? 'Staging' : 'Importing'} ${project}…`;
const toastId = toast.loading(stage ? 'Staging application...' : 'Importing application...', {
description: 'This may take a bit.',
duration: 300_000,
});
- mutate({
- request: {
- kind: stage ? 'stage' : 'deploy',
- project,
- package: contents.ref,
- installCommand: contents.installCommand,
- },
- useSSE: sseDeploy,
- onEvent: (event) => {
- // Stream live progress into the loading toast: phase transitions, and the latest
- // install output line during the install/load phase (so npm output shows through).
- if (event.type === 'phase' && event.data.status === 'start') {
- toast.loading(`${stage ? 'Staging' : 'Importing'} ${project}…`, {
- id: toastId,
- description: PHASE_LABELS[event.data.phase] ?? event.data.phase,
- duration: 300_000,
- });
- } else if (event.type === 'install' && event.data.line?.trim()) {
- toast.loading(`${stage ? 'Staging' : 'Importing'} ${project}…`, {
- id: toastId,
- description: event.data.line.trim().slice(0, 140),
- duration: 300_000,
- });
- } else if (event.type === 'rollout' && event.data.state === 'following') {
- toast.loading(`${stage ? 'Staging' : 'Importing'} ${project}…`, {
- id: toastId,
- description: 'Rolling out to the other nodes…',
- duration: Infinity,
- });
- }
- },
- ...instanceParams,
- }, {
- onSuccess: async (result) => {
- toast.dismiss(toastId);
- if (stage) {
- // Nothing went live, so there is no new application in the tree to open.
- reportStaged(project, result, { action: recordAction(result?.deployment_id) });
- return;
- }
- onImported(project);
- // A followed rollout restarted the other nodes, so there is a health to check however the deploy was sent.
- if (sseDeploy || result?.rollout) {
- await reportDeployOutcome(project, result, runHealthCheck, {
- action: recordAction(result?.deployment_id),
- });
- } else {
- toast.success(`Imported successfully`, {
- description: `${project} will be available once you restart your ${instanceParams.entityType}!`,
- duration: 5_000,
- });
- }
- },
- onError: async (error) => {
- toast.dismiss(toastId);
- // Live stream dropped after the deploy started — verify rather than assume failure.
- if (error instanceof SSEInconclusiveError && stage) {
- toast.warning(`${project} may not be staged`, {
- description: 'Live updates ended before the stage finished. Check Deployments for its record.',
- });
- return;
- }
- if (error instanceof SSEInconclusiveError) {
- onImported(project);
- await reportDeployOutcome(project, undefined, runHealthCheck);
- return;
- }
- const action = recordAction(deployFailureDetails(error).deploymentId);
- if (error instanceof DeployRolloutError) {
- onImported(project);
- toast.error(`${project} did not reach every node`, { description: error.message, action });
- return;
- }
- toast.error(`Failed to ${stage ? 'stage' : 'import'} ${project}`, {
- description: error instanceof Error ? error.message : undefined,
- action,
+ let result: DeployComponentResult;
+ try {
+ result = await mutateAsync({
+ request: {
+ kind: stage ? 'stage' : 'deploy',
+ project,
+ package: contents.ref,
+ installCommand: contents.installCommand,
+ },
+ useSSE: sseDeploy,
+ onEvent: (event) => {
+ // Stream live progress into the loading toast: phase transitions, and the latest
+ // install output line during the install/load phase (so npm output shows through).
+ if (event.type === 'phase' && event.data.status === 'start') {
+ toast.loading(progressTitle, {
+ id: toastId,
+ description: PHASE_LABELS[event.data.phase] ?? event.data.phase,
+ duration: 300_000,
+ });
+ } else if (event.type === 'install' && event.data.line?.trim()) {
+ toast.loading(progressTitle, {
+ id: toastId,
+ description: event.data.line.trim().slice(0, 140),
+ duration: 300_000,
+ });
+ } else if (event.type === 'rollout' && event.data.state === 'following') {
+ toast.loading(progressTitle, {
+ id: toastId,
+ description: 'Rolling out to the other nodes…',
+ duration: Infinity,
+ });
+ }
+ },
+ ...instanceParams,
+ });
+ } catch (error) {
+ toast.dismiss(toastId);
+ // Live stream dropped after the deploy started — verify rather than assume failure.
+ if (error instanceof SSEInconclusiveError && stage) {
+ toast.warning(`${project} may not be staged`, {
+ description: 'Live updates ended before the stage finished. Check Deployments for its record.',
});
- },
- });
- }, [mutate, sseDeploy, canStage, instanceParams, runHealthCheck, onImported, recordAction]);
+ return;
+ }
+ if (error instanceof SSEInconclusiveError) {
+ onImported(project, entityId);
+ await reportDeployOutcome(project, undefined, runHealthCheck);
+ return;
+ }
+ const { deploymentId } = deployFailureDetails(error);
+ if (error instanceof DeployRolloutError) {
+ // Live on the node that received it, which opens it in the editor in place of this form.
+ onImported(project, entityId);
+ toast.error(`${project} did not reach every node`, {
+ description: error.message,
+ action: recordAction(deploymentId),
+ });
+ return;
+ }
+ const title = `Failed to ${stage ? 'stage' : 'import'} ${project}`;
+ const { message } = describeError(error);
+ if (formEntity.current === entityId) {
+ setImportFailure({ entityId, failure: { title, message, deploymentId } });
+ } else {
+ toast.error(title, { description: message, action: recordAction(deploymentId) });
+ }
+ return;
+ }
+ toast.dismiss(toastId);
+ if (stage) {
+ // Nothing went live, so there is no new application in the tree to open.
+ reportStaged(project, result, { action: recordAction(result?.deployment_id) });
+ return;
+ }
+ onImported(project, entityId);
+ // A followed rollout restarted the other nodes, so there is a health to check however the deploy was sent.
+ if (sseDeploy || result?.rollout) {
+ await reportDeployOutcome(project, result, runHealthCheck, { action: recordAction(result?.deployment_id) });
+ } else {
+ toast.success(`Imported successfully`, {
+ description: `${project} will be available once you restart your ${instanceParams.entityType}!`,
+ duration: 5_000,
+ });
+ }
+ }, [mutateAsync, sseDeploy, canStage, instanceParams, runHealthCheck, onImported, recordAction]);
+
+ const importApplication = useCallback((args: ImportArgs) => void runImport(args), [runImport]);
+ const shownFailure = importFailure?.entityId === instanceParams.entityId ? importFailure.failure : undefined;
return useMemo(() => ({
isImportingApplication,
- importApplication: callback,
- }), [isImportingApplication, callback]);
+ importApplication,
+ importFailure: shownFailure,
+ }), [isImportingApplication, importApplication, shownFailure]);
}
diff --git a/src/features/instance/applications/modals/RedeployApplicationModal.test.tsx b/src/features/instance/applications/modals/RedeployApplicationModal.test.tsx
new file mode 100644
index 000000000..4d93c6d35
--- /dev/null
+++ b/src/features/instance/applications/modals/RedeployApplicationModal.test.tsx
@@ -0,0 +1,259 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => {
+ const post = vi.fn();
+ return {
+ post,
+ sse: { enabled: true },
+ instanceParams: { entityId: 'ins-1', entityType: 'instance', instanceClient: { post } },
+ editorView: { openedEntry: { name: 'my-app', path: 'my-app', project: 'my-app', package: 'npm:my-app' } },
+ runHealthCheck: vi.fn(),
+ routeParams: { organizationId: 'org-1', instanceId: 'ins-1' },
+ navigate: vi.fn(),
+ };
+});
+
+vi.mock('sonner', () => ({
+ toast: {
+ loading: vi.fn(() => 'loading-toast'),
+ dismiss: vi.fn(),
+ error: vi.fn(),
+ success: vi.fn(),
+ warning: vi.fn(),
+ },
+}));
+vi.mock('@tanstack/react-router', () => ({
+ Link: ({ children }: { children?: ReactNode }) => {children} ,
+ useParams: () => mocks.routeParams,
+ useNavigate: () => mocks.navigate,
+}));
+vi.mock('@/config/useInstanceClient', () => ({ useInstanceClientIdParams: () => mocks.instanceParams }));
+vi.mock('@/features/instance/applications/hooks/useSupportsDeploymentSSE', () => ({
+ useSupportsDeploymentSSE: () => mocks.sse.enabled,
+ useStagedDeploymentsAvailable: () => false,
+}));
+vi.mock('@/features/instance/applications/hooks/useComponentHealthCheck', () => ({
+ useComponentHealthCheck: () => mocks.runHealthCheck,
+}));
+vi.mock('@/features/instance/applications/hooks/useEditorView', () => ({ useEditorView: () => mocks.editorView }));
+vi.mock('@/integrations/api/instance/applications/deployComponentStream', async (importOriginal) => ({
+ ...(await importOriginal()),
+ deployComponentStream: vi.fn(),
+}));
+vi.mock('@/lib/attemptToRestoreFocus', () => ({ attemptToRestoreFocus: vi.fn() }));
+
+import {
+ DeployComponentResult,
+ deployComponentStream,
+} from '@/integrations/api/instance/applications/deployComponentStream';
+import { SSEOperationError } from '@/integrations/api/sse/errors';
+import { setWatchedValue } from '@/lib/events/watcher';
+import { mutationErrorHandler } from '@/react-query/queryClient';
+import { MutationCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { AxiosError, AxiosHeaders, InternalAxiosRequestConfig } from 'axios';
+import { ReactNode } from 'react';
+import { toast } from 'sonner';
+import { RedeployApplicationModal } from './RedeployApplicationModal';
+
+const FAILURE = 'npm install failed with exit code 1';
+
+function deferred() {
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((_, fail) => {
+ reject = fail;
+ });
+ return { promise, reject };
+}
+
+let unmount: () => void;
+
+async function renderOpenModal() {
+ // The app's own routing, so the deploy mutation's opt-out from the global toast is what's tested.
+ const queryClient = new QueryClient({ mutationCache: new MutationCache({ onError: mutationErrorHandler }) });
+ ({ unmount } = render(
+
+
+ ,
+ ));
+ await act(async () => setWatchedValue('ShowRedeployApplicationModal', true));
+}
+
+async function submit() {
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: /Redeploy Application/ }));
+ });
+}
+
+beforeAll(() => {
+ // Radix Dialog relies on DOM APIs jsdom doesn't implement.
+ Element.prototype.hasPointerCapture ??= () => false;
+ Element.prototype.setPointerCapture ??= () => undefined;
+ Element.prototype.releasePointerCapture ??= () => undefined;
+ Element.prototype.scrollIntoView ??= () => undefined;
+});
+
+beforeEach(() => {
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ mocks.sse.enabled = true;
+});
+
+afterEach(async () => {
+ await act(async () => setWatchedValue('ShowRedeployApplicationModal', false));
+ cleanup();
+ vi.restoreAllMocks();
+ vi.clearAllMocks();
+});
+
+describe('RedeployApplicationModal', () => {
+ it('shows a streamed failure in the open progress panel only, not in a toast as well', async () => {
+ vi.mocked(deployComponentStream).mockRejectedValue(new SSEOperationError(FAILURE, { phase: 'load' }));
+ await renderOpenModal();
+
+ await submit();
+
+ expect((await screen.findByRole('alert')).textContent).toContain(FAILURE);
+ expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ [
+ 'the modal was closed',
+ async () => {
+ await act(async () => {
+ fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
+ });
+ expect(screen.queryByRole('dialog')).toBeNull();
+ },
+ ],
+ ['the page was left', async () => unmount()],
+ ])('toasts a streamed failure that lands after %s', async (_, leave) => {
+ const pending = deferred();
+ vi.mocked(deployComponentStream).mockReturnValue(pending.promise);
+ await renderOpenModal();
+ await submit();
+
+ await leave();
+ await act(async () => pending.reject(new SSEOperationError(FAILURE)));
+
+ await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalledOnce());
+ expect(vi.mocked(toast.error)).toHaveBeenCalledWith('Failed to redeploy my-app', { description: FAILURE });
+ });
+
+ it('leaves a reopened modal alone when the deploy it no longer shows succeeds', async () => {
+ let succeed!: (value: DeployComponentResult) => void;
+ vi.mocked(deployComponentStream).mockReturnValue(
+ new Promise((resolve) => {
+ succeed = resolve;
+ }),
+ );
+ mocks.runHealthCheck.mockResolvedValue({ level: 'healthy' });
+ await renderOpenModal();
+ await submit();
+ await act(async () => {
+ fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
+ });
+ await act(async () => setWatchedValue('ShowRedeployApplicationModal', true));
+
+ await act(async () => succeed({ message: 'Successfully deployed: my-app', deployment_id: 'd1' }));
+
+ await waitFor(() =>
+ expect(vi.mocked(toast.success)).toHaveBeenCalledWith('my-app deployed and healthy', expect.anything())
+ );
+ expect(screen.getByRole('dialog')).toBeTruthy();
+ expect(screen.getByRole('button', { name: /Redeploy Application/ })).toBeTruthy();
+ });
+
+ it("replaces a buffered redeploy's progress toast with the server's reason, once", async () => {
+ mocks.sse.enabled = false;
+ const config = { headers: new AxiosHeaders() } as InternalAxiosRequestConfig;
+ mocks.post.mockRejectedValue(
+ new AxiosError('Request failed with status code 500', AxiosError.ERR_BAD_RESPONSE, config, undefined, {
+ data: { error: FAILURE },
+ status: 500,
+ statusText: 'Internal Server Error',
+ headers: {},
+ config,
+ }),
+ );
+ await renderOpenModal();
+
+ await submit();
+
+ await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalledOnce());
+ expect(vi.mocked(toast.error)).toHaveBeenCalledWith('Failed to redeploy my-app', {
+ id: 'loading-toast',
+ description: FAILURE,
+ });
+ });
+
+ describe('a rollout that settles after the modal was closed', () => {
+ function deployThenFollow(job: () => Promise) {
+ vi.mocked(deployComponentStream).mockResolvedValue({ message: 'ok', deployment_id: 'd1', restartJobId: 'job-1' });
+ mocks.post.mockImplementation(job);
+ }
+
+ async function closeModal() {
+ await act(async () => {
+ fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
+ });
+ expect(screen.queryByRole('dialog')).toBeNull();
+ }
+
+ it('still reports the outcome', async () => {
+ let finish!: () => void;
+ deployThenFollow(() =>
+ new Promise((resolve) => {
+ finish = () => resolve({ data: [{ id: 'job-1', status: 'COMPLETE', result: { replicated: [] } }] });
+ })
+ );
+ mocks.runHealthCheck.mockResolvedValue({ level: 'healthy' });
+ await renderOpenModal();
+ await submit();
+ await waitFor(() => expect(finish).toBeDefined());
+
+ await closeModal();
+ await act(async () => finish());
+
+ await waitFor(() =>
+ expect(vi.mocked(toast.success)).toHaveBeenCalledWith('my-app deployed and healthy', expect.anything())
+ );
+ });
+
+ it('says which nodes did not take it in a toast, once', async () => {
+ let fail!: () => void;
+ deployThenFollow(() =>
+ new Promise((resolve) => {
+ fail = () => resolve({ data: [{ id: 'job-1', status: 'ERROR', message: 'node-c did not restart' }] });
+ })
+ );
+ await renderOpenModal();
+ await submit();
+ await waitFor(() => expect(fail).toBeDefined());
+
+ await closeModal();
+ await act(async () => fail());
+
+ await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalledOnce());
+ expect(vi.mocked(toast.error)).toHaveBeenCalledWith('my-app did not reach every node', {
+ description: 'node-c did not restart',
+ action: { label: 'View record', onClick: expect.any(Function) },
+ });
+ });
+
+ it('shows a failed rollout in the open panel only, while the modal is still open', async () => {
+ deployThenFollow(() =>
+ Promise.resolve({ data: [{ id: 'job-1', status: 'ERROR', message: 'node-c did not restart' }] })
+ );
+ await renderOpenModal();
+
+ await submit();
+
+ expect(await screen.findByText('node-c did not restart')).toBeTruthy();
+ expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/features/instance/applications/modals/RedeployApplicationModal.tsx b/src/features/instance/applications/modals/RedeployApplicationModal.tsx
index 2047b14a1..1e907e3e3 100644
--- a/src/features/instance/applications/modals/RedeployApplicationModal.tsx
+++ b/src/features/instance/applications/modals/RedeployApplicationModal.tsx
@@ -20,6 +20,7 @@ import {
useSupportsDeploymentSSE,
} from '@/features/instance/applications/hooks/useSupportsDeploymentSSE';
import { useDeployComponentMutation } from '@/integrations/api/instance/applications/deployComponent';
+import { DeployComponentResult } from '@/integrations/api/instance/applications/deployComponentStream';
import { deployFailureDetails } from '@/integrations/api/instance/applications/deployOutcome';
import { DeployRolloutError } from '@/integrations/api/instance/applications/restartJob';
import { SSEInconclusiveError } from '@/integrations/api/sse/errors';
@@ -28,7 +29,7 @@ import { setWatchedValue, useWatchedValue } from '@/lib/events/watcher';
import { describeError } from '@/react-query/queryClient';
import { useQueryClient } from '@tanstack/react-query';
import { RefreshCwIcon } from 'lucide-react';
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { deployNotes, reportDeployOutcome, reportDeployResult, reportStaged } from './reportDeployHealth';
@@ -47,8 +48,14 @@ export function RedeployApplicationModal() {
const { openedEntry } = useEditorView();
const packageUrl = openedEntry?.package;
- const { mutate: reDeployApplication, isPending } = useDeployComponentMutation();
+ const { mutateAsync: reDeployApplication, isPending } = useDeployComponentMutation();
const [isStreaming, setIsStreaming] = useState(false);
+ // The attempt whose panel is on screen. Set beside the state it mirrors, not from an effect: a
+ // deploy can fail before the render that shows the panel commits.
+ const deployOnScreen = useRef(undefined);
+ useEffect(() => () => {
+ deployOnScreen.current = undefined;
+ }, []);
const [isStaging, setIsStaging] = useState(false);
const methods = useForm({
@@ -71,6 +78,7 @@ export function RedeployApplicationModal() {
attemptToRestoreFocus(trigger);
reset({ applicationUrl: packageUrl, stageOnly: false });
setIsStreaming(false);
+ deployOnScreen.current = undefined;
setIsStaging(false);
stream.reset();
}, [reset, packageUrl, trigger, stream]);
@@ -83,80 +91,85 @@ export function RedeployApplicationModal() {
}, [queryClient, instanceParams.entityId]);
const redeployWithProgress = useCallback(
- (applicationUrl: string, installCommand: string | undefined, stage: boolean) => {
+ async (applicationUrl: string, installCommand: string | undefined, stage: boolean) => {
if (!openedEntry) {
return;
}
const project = openedEntry.project;
+ const deploy = Symbol(project);
+ const isOnScreen = () => deployOnScreen.current === deploy;
stream.reset();
stream.markStarted();
setIsStreaming(true);
setIsStaging(stage);
+ deployOnScreen.current = deploy;
- reDeployApplication(
- {
+ let result: DeployComponentResult;
+ try {
+ result = await reDeployApplication({
request: { kind: stage ? 'stage' : 'deploy', project, package: applicationUrl, installCommand },
useSSE: true,
onEvent: stream.onEvent,
...instanceParams,
- },
- {
- onSuccess: async (result) => {
- if (stage) {
- stream.markSettled('success', undefined, { result });
- reportStaged(project, result, { action: recordAction(result?.deployment_id) });
- if (!deployNotes(result).warn) {
- closeModal();
- }
- return;
- }
- const known = result?.rollout?.state !== 'unfollowable';
- stream.markSettled(known ? 'success' : 'inconclusive', undefined, { result });
- invalidateComponents();
- await reportDeployOutcome(project, result, runHealthCheck, {
- action: recordAction(result?.deployment_id),
- });
- // Left open when there is something to read in it: an outcome Studio couldn't confirm, or a warning.
- if (known && !deployNotes(result).warn) {
- closeModal();
- }
- },
- onError: async (error) => {
- // The deploy started but the live stream dropped — verify via health check
- // instead of reporting a failure we don't actually know happened.
- if (error instanceof SSEInconclusiveError) {
- stream.markSettled('inconclusive');
- if (stage) {
- // A stage changes nothing that is running, so the component's health says nothing about it.
- toast.warning(`${project} may not be staged`, {
- description: 'Live updates ended before the stage finished. Check Deployments for its record.',
- });
- closeModal();
- return;
- }
- invalidateComponents();
- await reportDeployOutcome(project, undefined, runHealthCheck);
- closeModal();
- return;
- }
- const action = recordAction(deployFailureDetails(error).deploymentId);
- if (error instanceof DeployRolloutError) {
- // Live on the node that received it; the rollout section names the nodes that did not take it.
- stream.markSettled('error', undefined, { failure: error });
- invalidateComponents();
- toast.error(`${project} did not reach every node`, { description: error.message, action });
- return;
- }
- stream.markSettled('error', error instanceof Error ? error.message : String(error), {
- failure: error,
- });
- toast.error(`Failed to ${stage ? 'stage' : 'redeploy'} ${project}`, {
- description: error instanceof Error ? error.message : undefined,
- action,
+ });
+ } catch (error) {
+ // The deploy started but the live stream dropped — verify via health check
+ // instead of reporting a failure we don't actually know happened.
+ if (error instanceof SSEInconclusiveError) {
+ if (isOnScreen()) {
+ stream.markSettled('inconclusive');
+ }
+ if (stage) {
+ // A stage changes nothing that is running, so the component's health says nothing about it.
+ toast.warning(`${project} may not be staged`, {
+ description: 'Live updates ended before the stage finished. Check Deployments for its record.',
});
- },
- },
- );
+ } else {
+ invalidateComponents();
+ await reportDeployOutcome(project, undefined, runHealthCheck);
+ }
+ if (isOnScreen()) {
+ closeModal();
+ }
+ return;
+ }
+ const rolloutFailed = error instanceof DeployRolloutError;
+ if (rolloutFailed) {
+ // Live on the node that received it.
+ invalidateComponents();
+ }
+ const { message } = describeError(error);
+ if (isOnScreen()) {
+ // The rollout section names the nodes a rolled-out release missed.
+ stream.markSettled('error', rolloutFailed ? undefined : message, { failure: error });
+ return;
+ }
+ const title = rolloutFailed
+ ? `${project} did not reach every node`
+ : `Failed to ${stage ? 'stage' : 'redeploy'} ${project}`;
+ toast.error(title, { description: message, action: recordAction(deployFailureDetails(error).deploymentId) });
+ return;
+ }
+ if (stage) {
+ if (isOnScreen()) {
+ stream.markSettled('success', undefined, { result });
+ }
+ reportStaged(project, result, { action: recordAction(result?.deployment_id) });
+ if (isOnScreen() && !deployNotes(result).warn) {
+ closeModal();
+ }
+ return;
+ }
+ const known = result?.rollout?.state !== 'unfollowable';
+ if (isOnScreen()) {
+ stream.markSettled(known ? 'success' : 'inconclusive', undefined, { result });
+ }
+ invalidateComponents();
+ await reportDeployOutcome(project, result, runHealthCheck, { action: recordAction(result?.deployment_id) });
+ // Left open when there is something to read in it: an outcome Studio couldn't confirm, or a warning.
+ if (isOnScreen() && known && !deployNotes(result).warn) {
+ closeModal();
+ }
},
[
reDeployApplication,
@@ -171,60 +184,44 @@ export function RedeployApplicationModal() {
);
const redeployBuffered = useCallback(
- (applicationUrl: string, installCommand: string | undefined, stage: boolean) => {
+ async (applicationUrl: string, installCommand: string | undefined, stage: boolean) => {
if (!openedEntry) {
return;
}
+ const project = openedEntry.project;
closeModal();
const toastId = toast.loading(stage ? 'Staging...' : 'Redeploying...');
- reDeployApplication(
- {
- request: {
- kind: stage ? 'stage' : 'deploy',
- project: openedEntry.project,
- package: applicationUrl,
- installCommand,
- },
+ let result: DeployComponentResult;
+ try {
+ result = await reDeployApplication({
+ request: { kind: stage ? 'stage' : 'deploy', project, package: applicationUrl, installCommand },
...instanceParams,
- },
- {
- onSuccess: (result) => {
- if (stage) {
- reportStaged(openedEntry.project, result, { id: toastId, action: recordAction(result?.deployment_id) });
- return;
- }
- reportDeployResult(
- openedEntry.project,
- result,
- { title: `Application ${openedEntry.project} redeployed successfully` },
- { id: toastId, action: recordAction(result?.deployment_id) },
- );
- invalidateComponents();
- },
- onError: (error) => {
- // The modal is already closed, so a toast is the only place left to link the record from.
- const action = recordAction(deployFailureDetails(error).deploymentId);
- if (error instanceof DeployRolloutError) {
- toast.error(`${openedEntry.project} did not reach every node`, {
- id: toastId,
- description: error.message,
- action,
- });
- invalidateComponents();
- return;
- }
- if (action) {
- toast.error(`Failed to ${stage ? 'stage' : 'redeploy'} ${openedEntry.project}`, {
- id: toastId,
- description: describeError(error).message,
- action,
- });
- return;
- }
- toast.dismiss(toastId);
- },
- },
+ });
+ } catch (error) {
+ const action = recordAction(deployFailureDetails(error).deploymentId);
+ if (error instanceof DeployRolloutError) {
+ toast.error(`${project} did not reach every node`, { id: toastId, description: error.message, action });
+ invalidateComponents();
+ return;
+ }
+ toast.error(`Failed to ${stage ? 'stage' : 'redeploy'} ${project}`, {
+ id: toastId,
+ description: describeError(error).message,
+ action,
+ });
+ return;
+ }
+ if (stage) {
+ reportStaged(project, result, { id: toastId, action: recordAction(result?.deployment_id) });
+ return;
+ }
+ reportDeployResult(
+ project,
+ result,
+ { title: `Application ${project} redeployed successfully` },
+ { id: toastId, action: recordAction(result?.deployment_id) },
);
+ invalidateComponents();
},
[reDeployApplication, openedEntry, instanceParams, invalidateComponents, closeModal, recordAction],
);
@@ -239,9 +236,9 @@ export function RedeployApplicationModal() {
}
const stage = canStage && !!stageOnly;
if (sseDeploy) {
- redeployWithProgress(applicationUrl, installCommand, stage);
+ void redeployWithProgress(applicationUrl, installCommand, stage);
} else {
- redeployBuffered(applicationUrl, installCommand, stage);
+ void redeployBuffered(applicationUrl, installCommand, stage);
}
};
diff --git a/src/features/instance/config/deployments/components/ActivateStagedRelease.test.tsx b/src/features/instance/config/deployments/components/ActivateStagedRelease.test.tsx
index caea0ab6f..99576d143 100644
--- a/src/features/instance/config/deployments/components/ActivateStagedRelease.test.tsx
+++ b/src/features/instance/config/deployments/components/ActivateStagedRelease.test.tsx
@@ -119,20 +119,18 @@ describe('ActivateStagedRelease', () => {
expect(state.reportDeployFailure).not.toHaveBeenCalled();
});
- it('reports every failure itself, instead of also through the global toast, and still to Datadog', async () => {
+ // The mutation files the Datadog report (deployComponent.test.ts); a second one here would double it.
+ it('reports every failure itself, and leaves the Datadog report to the mutation', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
const failure = new Error('No deployment found with id a1b2c3d4');
state.mutateAsync.mockRejectedValue(failure);
renderButton();
- expect(state.useDeployComponentMutation).toHaveBeenCalledWith(
- activationMutationKey('clu-a', staged.deployment_id),
- { skipGlobalErrorToast: true },
- );
+ expect(state.useDeployComponentMutation).toHaveBeenCalledWith(activationMutationKey('clu-a', staged.deployment_id));
confirm();
await vi.waitFor(() =>
expect(state.reportDeployFailure).toHaveBeenCalledWith('web', failure, 'activate', { action: undefined })
);
- expect(consoleError).toHaveBeenCalledWith(failure);
+ expect(consoleError).not.toHaveBeenCalledWith(failure);
consoleError.mockRestore();
});
diff --git a/src/features/instance/config/deployments/components/ActivateStagedRelease.tsx b/src/features/instance/config/deployments/components/ActivateStagedRelease.tsx
index 01b9f4cf6..d68b10344 100644
--- a/src/features/instance/config/deployments/components/ActivateStagedRelease.tsx
+++ b/src/features/instance/config/deployments/components/ActivateStagedRelease.tsx
@@ -49,8 +49,7 @@ export function ActivateStagedRelease({ deployment }: { deployment: Deployment }
instanceParams.entityId,
deploymentId,
]);
- // Every outcome is reported below, so the global toast would only contradict an unknown one.
- const { mutateAsync } = useDeployComponentMutation(mutationKey, { skipGlobalErrorToast: true });
+ const { mutateAsync } = useDeployComponentMutation(mutationKey);
const isPending = useIsMutating({ mutationKey }) > 0;
const [confirming, setConfirming] = useState(false);
const [started, setStarted] = useState(false);
@@ -75,8 +74,6 @@ export function ActivateStagedRelease({ deployment }: { deployment: Deployment }
void invalidateEntityQueries(queryClient, instanceParams.entityId);
await reportDeployOutcome(project, result, runHealthCheck, { action: recordAction(result?.deployment_id) });
} catch (error) {
- // What the skipped global handler would have done for Datadog; RUM reports `console.error` (AGENTS.md).
- console.error(error);
void invalidateEntityQueries(queryClient, instanceParams.entityId);
if (error instanceof SSEInconclusiveError) {
stream.markSettled('inconclusive');
diff --git a/src/integrations/api/instance/applications/deployComponent.test.ts b/src/integrations/api/instance/applications/deployComponent.test.ts
index 05312d296..58ab4059c 100644
--- a/src/integrations/api/instance/applications/deployComponent.test.ts
+++ b/src/integrations/api/instance/applications/deployComponent.test.ts
@@ -1,3 +1,6 @@
+/**
+ * @vitest-environment jsdom
+ */
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest';
vi.mock('./deployComponentStream', () => ({
@@ -7,10 +10,17 @@ vi.mock('sonner', () => ({ toast: { error: vi.fn(), dismiss: vi.fn() } }));
import { SSEInconclusiveError, SSEOperationError, SSEUnsupportedError } from '@/integrations/api/sse/errors';
import { mutationErrorHandler } from '@/react-query/queryClient';
-import { MutationCache, MutationObserver, QueryClient } from '@tanstack/react-query';
+import { MutationCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { renderHook } from '@testing-library/react';
import { AxiosError, AxiosHeaders, InternalAxiosRequestConfig } from 'axios';
+import { createElement } from 'react';
import { toast } from 'sonner';
-import { deployAndFollowComponent, deployRequestBody, onDeployComponentSubmit } from './deployComponent';
+import {
+ deployAndFollowComponent,
+ deployRequestBody,
+ onDeployComponentSubmit,
+ useDeployComponentMutation,
+} from './deployComponent';
import { deployComponentStream } from './deployComponentStream';
import { DeployRolloutError } from './restartJob';
@@ -220,23 +230,32 @@ describe('onDeployComponentSubmit', () => {
).rejects.toThrow(GUIDANCE);
});
- it('reaches the global mutation toast as guidance too', async () => {
+ it('reaches RUM as guidance, without the global toast, even once its caller has unmounted', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
onTestFinished(() => consoleError.mockRestore());
- mockStream.mockRejectedValue(new SSEOperationError(raw));
+ let failStream!: (error: unknown) => void;
+ mockStream.mockReturnValue(
+ new Promise((_, reject) => {
+ failStream = reject;
+ }),
+ );
const queryClient = new QueryClient({ mutationCache: new MutationCache({ onError: mutationErrorHandler }) });
- // The function `useDeployComponentMutation` runs, so a failure reaches the toast the way it does in the app.
- const observer = new MutationObserver(queryClient, { mutationFn: deployAndFollowComponent });
+ const { result, unmount } = renderHook(() => useDeployComponentMutation(), {
+ wrapper: ({ children }) => createElement(QueryClientProvider, { client: queryClient }, children),
+ });
- await observer
+ const settled = result.current
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- .mutate({ ...baseArgs, useSSE: true, instanceClient: fakeClient({}) as any })
- .catch(() => undefined);
-
- expect(vi.mocked(toast.error)).toHaveBeenCalledOnce();
- const [title, options] = vi.mocked(toast.error).mock.calls[0];
- expect(title).toBe('Error');
- expect(options?.description).toMatch(GUIDANCE);
+ .mutateAsync({ ...baseArgs, useSSE: true, instanceClient: fakeClient({}) as any })
+ .catch((rejection: unknown) => rejection);
+ unmount();
+ failStream(new SSEOperationError(raw));
+ const error = await settled;
+
+ expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
+ expect(consoleError).toHaveBeenCalledOnce();
+ expect(consoleError).toHaveBeenCalledWith(error);
+ expect((error as SSEOperationError).message).toMatch(GUIDANCE);
});
});
});
@@ -321,6 +340,32 @@ describe('deployAndFollowComponent', () => {
expect(post.mock.calls.filter(([, body]) => body.operation === 'deploy_component')).toHaveLength(1);
});
+ it('reports a rollout that fails after its caller left once, to RUM, and not through the global toast', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
+ onTestFinished(() => consoleError.mockRestore());
+ const post = rolloutClient({ message: 'ok', deployment_id: 'd1', restartJobId: 'job-1' }, [
+ [{ id: 'job-1', status: 'IN_PROGRESS' }],
+ [{ id: 'job-1', status: 'ERROR', message: 'node-c did not restart' }],
+ ]);
+ const queryClient = new QueryClient({ mutationCache: new MutationCache({ onError: mutationErrorHandler }) });
+ const { result, unmount } = renderHook(() => useDeployComponentMutation(), {
+ wrapper: ({ children }) => createElement(QueryClientProvider, { client: queryClient }, children),
+ });
+
+ const settled = result.current
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ .mutateAsync({ ...baseArgs, instanceClient: { post } as any })
+ .catch((rejection: unknown) => rejection);
+ unmount();
+ await vi.advanceTimersByTimeAsync(5_000);
+ const error = await settled;
+
+ expect(error).toBeInstanceOf(DeployRolloutError);
+ expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
+ expect(consoleError).toHaveBeenCalledOnce();
+ expect(consoleError).toHaveBeenCalledWith(error);
+ });
+
it('resolves an unfollowable rollout as unknown rather than as a success or a failure', async () => {
const post = rolloutClient({ message: 'ok', restartJobId: 'job-1' }, [[]]);
const pending = deployAndFollowComponent({
diff --git a/src/integrations/api/instance/applications/deployComponent.ts b/src/integrations/api/instance/applications/deployComponent.ts
index 413d55487..ec35d63fc 100644
--- a/src/integrations/api/instance/applications/deployComponent.ts
+++ b/src/integrations/api/instance/applications/deployComponent.ts
@@ -122,10 +122,12 @@ export async function deployAndFollowComponent(args: DeployComponentArgs): Promi
return { ...result, rollout };
}
-export function useDeployComponentMutation(mutationKey?: readonly unknown[], meta?: Record) {
+export function useDeployComponentMutation(mutationKey?: readonly unknown[]) {
return useMutation({
mutationKey,
- meta,
mutationFn: deployAndFollowComponent,
+ // Every caller shows its own failure: applications/DESIGN.md, "Who reports a failed deploy".
+ meta: { skipGlobalErrorToast: true },
+ onError: (error) => console.error(error),
});
}