diff --git a/package-lock.json b/package-lock.json index 883a0786146..f165e509ea6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46761,8 +46761,10 @@ "@mongodb-js/atlas-service": "^0.56.0", "@mongodb-js/compass-app-registry": "^9.4.20", "@mongodb-js/compass-components": "^1.49.0", + "@mongodb-js/connection-info": "^0.17.1", "ai": "^5.0.5", "compass-preferences-model": "^2.51.0", + "mongodb-connection-string-url": "^3.0.1", "react": "^17.0.2", "throttleit": "^2.1.0", "use-sync-external-store": "^1.5.0" @@ -47504,6 +47506,7 @@ "license": "SSPL", "dependencies": { "@mongodb-js/compass-app-registry": "^9.4.20", + "@mongodb-js/compass-assistant": "^1.2.0", "@mongodb-js/compass-components": "^1.49.0", "@mongodb-js/compass-logging": "^1.7.12", "@mongodb-js/compass-telemetry": "^1.14.0", @@ -60034,6 +60037,7 @@ "@mongodb-js/atlas-service": "^0.56.0", "@mongodb-js/compass-app-registry": "^9.4.20", "@mongodb-js/compass-components": "^1.49.0", + "@mongodb-js/connection-info": "^0.17.1", "@mongodb-js/eslint-config-compass": "^1.4.7", "@mongodb-js/mocha-config-compass": "^1.7.0", "@mongodb-js/prettier-config-compass": "^1.2.8", @@ -60052,6 +60056,7 @@ "compass-preferences-model": "^2.51.0", "depcheck": "^1.4.1", "mocha": "^10.2.0", + "mongodb-connection-string-url": "^3.0.1", "nyc": "^15.1.0", "openai": "^4.104.0", "react": "^17.0.2", @@ -60649,6 +60654,7 @@ "version": "file:packages/compass-connections", "requires": { "@mongodb-js/compass-app-registry": "^9.4.20", + "@mongodb-js/compass-assistant": "^1.2.0", "@mongodb-js/compass-components": "^1.49.0", "@mongodb-js/compass-logging": "^1.7.12", "@mongodb-js/compass-telemetry": "^1.14.0", diff --git a/packages/compass-assistant/package.json b/packages/compass-assistant/package.json index 24ebdef5d55..44c1c9fe9eb 100644 --- a/packages/compass-assistant/package.json +++ b/packages/compass-assistant/package.json @@ -53,6 +53,8 @@ "@mongodb-js/atlas-service": "^0.56.0", "@mongodb-js/compass-app-registry": "^9.4.20", "@mongodb-js/compass-components": "^1.49.0", + "@mongodb-js/connection-info": "^0.17.1", + "mongodb-connection-string-url": "^3.0.1", "ai": "^5.0.5", "compass-preferences-model": "^2.51.0", "react": "^17.0.2", diff --git a/packages/compass-assistant/src/compass-assistant-drawer.tsx b/packages/compass-assistant/src/compass-assistant-drawer.tsx index 09b3ad98503..dee3f70be35 100644 --- a/packages/compass-assistant/src/compass-assistant-drawer.tsx +++ b/packages/compass-assistant/src/compass-assistant-drawer.tsx @@ -33,6 +33,7 @@ export const CompassAssistantDrawer: React.FunctionComponent<{ 'The current chat will be cleared, and chat history will not be retrievable.', buttonText: 'Clear chat', variant: 'danger', + 'data-testid': 'assistant-confirm-clear-chat-modal', }); if (confirmed) { clearChat(); diff --git a/packages/compass-assistant/src/compass-assistant-provider.spec.tsx b/packages/compass-assistant/src/compass-assistant-provider.spec.tsx index 56b896f09b0..31c9083827c 100644 --- a/packages/compass-assistant/src/compass-assistant-provider.spec.tsx +++ b/packages/compass-assistant/src/compass-assistant-provider.spec.tsx @@ -202,19 +202,20 @@ describe('AssistantProvider', function () { userEvent.click(clearButton); await waitFor(() => { - expect(screen.getByTestId('confirmation-modal')).to.exist; + expect(screen.getByTestId('assistant-confirm-clear-chat-modal')).to + .exist; }); // There should be messages in the chat expect(screen.getByTestId('assistant-message-1')).to.exist; expect(screen.getByTestId('assistant-message-2')).to.exist; - const modal = screen.getByTestId('confirmation-modal'); + const modal = screen.getByTestId('assistant-confirm-clear-chat-modal'); const confirmButton = within(modal).getByText('Clear chat'); userEvent.click(confirmButton); await waitForElementToBeRemoved(() => - screen.getByTestId('confirmation-modal') + screen.getByTestId('assistant-confirm-clear-chat-modal') ); expect(mockChat.messages).to.be.empty; @@ -231,19 +232,20 @@ describe('AssistantProvider', function () { userEvent.click(clearButton); await waitFor(() => { - expect(screen.getByTestId('confirmation-modal')).to.exist; + expect(screen.getByTestId('assistant-confirm-clear-chat-modal')).to + .exist; }); // There should be messages in the chat expect(screen.getByTestId('assistant-message-1')).to.exist; expect(screen.getByTestId('assistant-message-2')).to.exist; - const modal = screen.getByTestId('confirmation-modal'); + const modal = screen.getByTestId('assistant-confirm-clear-chat-modal'); const cancelButton = within(modal).getByText('Cancel'); userEvent.click(cancelButton); await waitForElementToBeRemoved(() => - screen.getByTestId('confirmation-modal') + screen.getByTestId('assistant-confirm-clear-chat-modal') ); expect(mockChat.messages).to.deep.equal(mockMessages); diff --git a/packages/compass-assistant/src/compass-assistant-provider.tsx b/packages/compass-assistant/src/compass-assistant-provider.tsx index d0d740bbe54..c4c3cfd5320 100644 --- a/packages/compass-assistant/src/compass-assistant-provider.tsx +++ b/packages/compass-assistant/src/compass-assistant-provider.tsx @@ -2,12 +2,17 @@ import React, { type PropsWithChildren, useRef } from 'react'; import { type UIMessage } from './@ai-sdk/react/use-chat'; import { Chat } from './@ai-sdk/react/chat-react'; import { createContext, useContext } from 'react'; -import { registerCompassPlugin } from '@mongodb-js/compass-app-registry'; +import { + createServiceLocator, + registerCompassPlugin, +} from '@mongodb-js/compass-app-registry'; import { atlasServiceLocator } from '@mongodb-js/atlas-service/provider'; import { DocsProviderTransport } from './docs-provider-transport'; import { useDrawerActions } from '@mongodb-js/compass-components'; -import { buildExplainPlanPrompt } from './prompts'; +import { buildConnectionErrorPrompt, buildExplainPlanPrompt } from './prompts'; import { usePreference } from 'compass-preferences-model/provider'; +import type { ConnectionInfo } from '@mongodb-js/connection-info'; +import { redactConnectionString } from 'mongodb-connection-string-url'; export const ASSISTANT_DRAWER_ID = 'compass-assistant-drawer'; @@ -32,11 +37,19 @@ type AssistantActionsContextType = { namespace: string; explainPlan: string; }) => void; + interpretConnectionError: ({ + connectionInfo, + error, + }: { + connectionInfo: ConnectionInfo; + error: Error; + }) => void; clearChat: () => void; }; export const AssistantActionsContext = createContext({ interpretExplainPlan: () => {}, + interpretConnectionError: () => {}, clearChat: () => {}, }); @@ -51,6 +64,24 @@ export function useAssistantActions(): AssistantActionsContextType & { }; } +export const compassAssistantServiceLocator = createServiceLocator(function () { + const { isAssistantEnabled, ...actions } = useAssistantActions(); + + const assistantEnabledRef = useRef(isAssistantEnabled); + assistantEnabledRef.current = isAssistantEnabled; + + return { + ...actions, + getIsAssistantEnabled() { + return assistantEnabledRef.current; + }, + }; +}, 'compassAssistantLocator'); + +export type CompassAssistantService = ReturnType< + typeof compassAssistantServiceLocator +>; + export const AssistantProvider: React.FunctionComponent< PropsWithChildren<{ chat: Chat; @@ -72,6 +103,25 @@ export const AssistantProvider: React.FunctionComponent< {} ); }, + interpretConnectionError: ({ connectionInfo, error }) => { + openDrawer(ASSISTANT_DRAWER_ID); + + const connectionString = redactConnectionString( + connectionInfo.connectionOptions.connectionString + ); + const connectionError = error.toString(); + + const { prompt } = buildConnectionErrorPrompt({ + connectionString, + connectionError, + }); + void chat.sendMessage( + { + text: prompt, + }, + {} + ); + }, clearChat: () => { chat.messages = []; }, diff --git a/packages/compass-assistant/src/index.tsx b/packages/compass-assistant/src/index.tsx index d3fd2d72b12..2a7bcb6e5e5 100644 --- a/packages/compass-assistant/src/index.tsx +++ b/packages/compass-assistant/src/index.tsx @@ -1,3 +1,7 @@ export { CompassAssistantProvider } from './compass-assistant-provider'; export { CompassAssistantDrawer } from './compass-assistant-drawer'; -export { useAssistantActions } from './compass-assistant-provider'; +export { + useAssistantActions, + compassAssistantServiceLocator, +} from './compass-assistant-provider'; +export type { CompassAssistantService } from './compass-assistant-provider'; diff --git a/packages/compass-assistant/src/prompts.ts b/packages/compass-assistant/src/prompts.ts index b0981f24c0a..a421228796b 100644 --- a/packages/compass-assistant/src/prompts.ts +++ b/packages/compass-assistant/src/prompts.ts @@ -11,3 +11,21 @@ ${explainPlan}`, displayText: 'Provide an explanation of this explain plan.', }; }; + +export const buildConnectionErrorPrompt = ({ + connectionString, + connectionError, +}: { + connectionString: string; + connectionError: string; +}) => { + return { + prompt: `Given the error message below, please provide clear instructions to guide the user to debug their connection attempt from MongoDB Compass. If no auth mechanism is specified in the connection string, the default (username/password) is being used: + +Connection string (password redacted): +${connectionString} + +Error message: +${connectionError}`, + }; +}; diff --git a/packages/compass-connections/package.json b/packages/compass-connections/package.json index 444bb736406..97228a316e8 100644 --- a/packages/compass-connections/package.json +++ b/packages/compass-connections/package.json @@ -52,6 +52,7 @@ }, "dependencies": { "@mongodb-js/compass-app-registry": "^9.4.20", + "@mongodb-js/compass-assistant": "^1.2.0", "@mongodb-js/compass-components": "^1.49.0", "@mongodb-js/compass-logging": "^1.7.12", "@mongodb-js/compass-telemetry": "^1.14.0", diff --git a/packages/compass-connections/src/components/connection-status-notifications.tsx b/packages/compass-connections/src/components/connection-status-notifications.tsx index ddb838e88d6..8679c4ec581 100644 --- a/packages/compass-connections/src/components/connection-status-notifications.tsx +++ b/packages/compass-connections/src/components/connection-status-notifications.tsx @@ -83,6 +83,33 @@ function ConnectionErrorToastBody({ ); } +type ConnectionDebugToastBodyProps = { + onDebug: () => void; +}; + +function ConnectionDebugToastBody({ + onDebug, +}: ConnectionDebugToastBodyProps): React.ReactElement { + return ( + + + Diagnose the issue and explore solutions with the assistant + + + DEBUG FOR ME + + + ); +} + const deviceAuthModalContentStyles = css({ textAlign: 'center', '& > *:not(:last-child)': { @@ -134,6 +161,8 @@ const openConnectionFailedToast = ( ) => { const failedToastId = connectionInfo?.id ?? 'failed'; + // TODO(COMPASS-9746): close the existing connection toast and make a new one + // for the failure so that the debug toast will appear below the failure one openToast(`connection-status--${failedToastId}`, { title: error.message, description: ( @@ -150,6 +179,25 @@ const openConnectionFailedToast = ( }); }; +const openDebugConnectionErrorToast = ( + connectionInfo: ConnectionInfo, + error: Error, + onDebugClick: () => void +) => { + openToast(`debug-connection-error--${connectionInfo.id}`, { + title: 'Need help debugging your connection error?', + description: ( + { + closeToast(`debug-connection-error--${connectionInfo.id}`); + onDebugClick(); + }} + /> + ), + variant: 'note', + }); +}; + const openMaximumConnectionsReachedToast = ( maxConcurrentConnections: number ) => { @@ -214,6 +262,7 @@ export function getNotificationTriggers() { openConnectionStartedToast, openConnectionSucceededToast, openConnectionFailedToast, + openDebugConnectionErrorToast, openMaximumConnectionsReachedToast, closeConnectionStatusToast: (connectionId: string) => { return closeToast(`connection-status--${connectionId}`); diff --git a/packages/compass-connections/src/index.tsx b/packages/compass-connections/src/index.tsx index 299165f51ee..3e821b9bc8a 100644 --- a/packages/compass-connections/src/index.tsx +++ b/packages/compass-connections/src/index.tsx @@ -24,6 +24,7 @@ import { } from './stores/store-context'; export type { ConnectionFeature } from './utils/connection-supports'; export { connectionSupports, connectable } from './utils/connection-supports'; +import { compassAssistantServiceLocator } from '@mongodb-js/compass-assistant'; const ConnectionsComponent: React.FunctionComponent<{ /** @@ -82,7 +83,14 @@ const CompassConnectionsPlugin = registerCompassPlugin( component: ConnectionsComponent, activate( initialProps, - { logger, preferences, connectionStorage, track, globalAppRegistry }, + { + logger, + preferences, + connectionStorage, + track, + globalAppRegistry, + compassAssistant, + }, { addCleanup, cleanup } ) { const store = configureStore(initialProps.preloadStorageConnectionInfos, { @@ -95,6 +103,7 @@ const CompassConnectionsPlugin = registerCompassPlugin( connectFn: initialProps.connectFn, globalAppRegistry, onFailToLoadConnections: initialProps.onFailToLoadConnections, + compassAssistant, }); setTimeout(() => { @@ -128,6 +137,7 @@ const CompassConnectionsPlugin = registerCompassPlugin( preferences: preferencesLocator, connectionStorage: connectionStorageLocator, track: telemetryLocator, + compassAssistant: compassAssistantServiceLocator, } ); diff --git a/packages/compass-connections/src/stores/connections-store-redux.spec.tsx b/packages/compass-connections/src/stores/connections-store-redux.spec.tsx index 5d476bf6130..062ff51fb84 100644 --- a/packages/compass-connections/src/stores/connections-store-redux.spec.tsx +++ b/packages/compass-connections/src/stores/connections-store-redux.spec.tsx @@ -150,6 +150,30 @@ describe('CompassConnections store', function () { } }); + it('should show debug toast in addition to the connection error toast if connection fails and the assistant is enabled', async function () { + const { connectionsStore } = renderCompassConnections({ + preferences: { + enableAIAssistant: true, + }, + connectFn: sinon + .stub() + .rejects(new Error('Failed to connect to cluster')), + }); + + const connectionInfo = createDefaultConnectionInfo(); + + void connectionsStore.actions.connect(connectionInfo); + + await waitFor(() => { + expect(screen.getByText('Failed to connect to cluster')).to.exist; + }); + + await waitFor(() => { + expect(screen.getByText('Need help debugging your connection error?')) + .to.exist; + }); + }); + it('should show non-genuine modal at the end of connection if non genuine mongodb detected', async function () { const { connectionsStore } = renderCompassConnections({}); diff --git a/packages/compass-connections/src/stores/connections-store-redux.ts b/packages/compass-connections/src/stores/connections-store-redux.ts index 0bf61e99845..d1130df80a5 100644 --- a/packages/compass-connections/src/stores/connections-store-redux.ts +++ b/packages/compass-connections/src/stores/connections-store-redux.ts @@ -41,6 +41,7 @@ import { } from '../utils/end-of-life-server'; import type { ImportConnectionOptions } from '@mongodb-js/connection-storage/provider'; import { getErrorCodeCauseChain } from '../utils/telemetry'; +import type { CompassAssistantService } from '@mongodb-js/compass-assistant'; export type ConnectionsEventMap = { connected: ( @@ -212,6 +213,7 @@ type ThunkExtraArg = { connectFn?: typeof devtoolsConnect; globalAppRegistry: Pick; onFailToLoadConnections: (error: Error) => void; + compassAssistant: CompassAssistantService; }; export type ConnectionsThunkAction< @@ -1263,11 +1265,25 @@ const connectionAttemptError = ( connectionInfo: ConnectionInfo | null, err: any ): ConnectionsThunkAction => { - return (dispatch, _getState, { track, getExtraConnectionData }) => { - const { openConnectionFailedToast } = getNotificationTriggers(); + return ( + dispatch, + _getState, + { track, getExtraConnectionData, compassAssistant } + ) => { + const { openConnectionFailedToast, openDebugConnectionErrorToast } = + getNotificationTriggers(); - const showReviewButton = !!connectionInfo && !connectionInfo.atlasMetadata; + const isAssistanceEnabled = compassAssistant.getIsAssistantEnabled(); + if (isAssistanceEnabled && connectionInfo) { + openDebugConnectionErrorToast(connectionInfo, err, () => { + compassAssistant.interpretConnectionError({ + connectionInfo, + error: err, + }); + }); + } + const showReviewButton = !!connectionInfo && !connectionInfo.atlasMetadata; openConnectionFailedToast(connectionInfo, err, showReviewButton, () => { if (connectionInfo) { dispatch(editConnection(connectionInfo.id)); diff --git a/packages/compass-e2e-tests/helpers/selectors.ts b/packages/compass-e2e-tests/helpers/selectors.ts index 536565c3f18..2b646478906 100644 --- a/packages/compass-e2e-tests/helpers/selectors.ts +++ b/packages/compass-e2e-tests/helpers/selectors.ts @@ -242,6 +242,8 @@ export const ConnectionToastErrorReviewButton = '[data-testid="connection-error-review"]'; export const ConenctionToastCancelConnectionButton = '[data-testid="cancel-connection-button"]'; +export const ConnectionToastErrorDebugButton = + '[data-testid="connection-error-debug"]'; // Connections sidebar export const ConnectionsTitle = '[data-testid="connections-header"]'; @@ -1511,3 +1513,10 @@ export const SideDrawer = `[data-testid="${getDrawerIds().root}"]`; export const SideDrawerCloseButton = `[data-testid="${ getDrawerIds().closeButton }"]`; + +// Assistant +export const AssistantChatMessages = '[data-testid="assistant-chat-messages"]'; +export const AssistantClearChatButton = '[data-testid="assistant-clear-chat"]'; +export const ConfirmClearChatModal = + '[data-testid="assistant-confirm-clear-chat-modal"]'; +export const ConfirmClearChatModalConfirmButton = `${ConfirmClearChatModal} [data-testid="lg-confirmation_modal-footer-confirm_button"]`; diff --git a/packages/compass-e2e-tests/tests/connection.test.ts b/packages/compass-e2e-tests/tests/connection.test.ts index 542ab573466..97687c3b625 100644 --- a/packages/compass-e2e-tests/tests/connection.test.ts +++ b/packages/compass-e2e-tests/tests/connection.test.ts @@ -273,6 +273,9 @@ describe('Connection string', function () { before(async function () { compass = await init(this.test?.fullTitle()); browser = compass.browser; + // TODO(COMPASS-9746) the debug toast obscures the connection toast which + // breaks the connect custom commands and who knows what else + //await browser.setFeature('enableAIAssistant', true) }); beforeEach(async function () { @@ -326,6 +329,29 @@ describe('Connection string', function () { .$(Selectors.ConnectionFormErrorMessage) .getText(); expect(errorText).to.equal('Authentication failed.'); + + // close the modal + await browser.clickVisible(Selectors.ConnectionModalCloseButton); + await browser + .$(Selectors.ConnectionModal) + .waitForDisplayed({ reverse: true }); + + // TODO(COMPASS-9746) the toasts should be swapped around before this can work + /* + await browser.clickVisible(Selectors.ConnectionToastErrorDebugButton); + const messagesElement = browser.$(Selectors.AssistantChatMessages) + await messagesElement.waitForDisplayed(); + // TODO(COMPASS-9744) check the response from the chatbot too + + await browser.waitUntil(async () => { + return (await messagesElement.getText()).includes('Given the error message below,'); + }); + + // clear the chat so that a broken message doesn't break every future message + await browser.clickVisible(Selectors.AssistantClearChatButton); + await browser.clickVisible(Selectors.ConfirmClearChatModalConfirmButton); + await browser.clickVisible(Selectors.SideDrawerCloseButton); + */ }); it('can connect to an Atlas replicaset without srv', async function () { diff --git a/packages/compass/src/app/components/home.tsx b/packages/compass/src/app/components/home.tsx index 3ca5b5cd396..da6b6562f34 100644 --- a/packages/compass/src/app/components/home.tsx +++ b/packages/compass/src/app/components/home.tsx @@ -108,27 +108,22 @@ function Home({ return ( - - -
- - - -
- - - - - - -
-
+ +
+ + + +
+ + + + + + +
); @@ -152,21 +147,23 @@ function HomeWithConnections({ return ( - { - openToast('failed-to-load-connections', { - title: 'Failed to load connections', - description: error.message, - variant: 'warning', - }); - }} - > - - + + { + openToast('failed-to-load-connections', { + title: 'Failed to load connections', + description: error.message, + variant: 'warning', + }); + }} + > + + + );