From 2f829180566fb21c5f8a05baa33e84a0de0174af Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 21 Apr 2023 16:34:54 -0800 Subject: [PATCH 1/3] Refactor connect to not use global model Should make it easier to test. This meant not calling askTokenAndConnect because it interacts with the global model. Initially I was trying to figure out a way to wait progress dialog background job so you could then retry but I am not sure how to do that so I went with passing in a callback. Also launching a new coroutine since otherwise it blocked the current job and threw a warning, I think it disliked the invokeAndWait of the token dialog from inside the job. --- .../views/steps/CoderWorkspacesStepView.kt | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt index fcee11c4..50346030 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -357,12 +357,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod localWizardModel.token = token } if (!url.isNullOrBlank() && !token.isNullOrBlank()) { - // It could be jarring to suddenly ask for a token when you are - // just trying to launch the Coder plugin so in this case where - // we are trying to automatically connect to the last deployment - // (or the deployment in the CLI config) do not ask for the - // token again until they explicitly press connect. - connect(false) + connect(url.toURL(), token) } } updateWorkspaceActions() @@ -415,6 +410,9 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod /** * Ask for a new token (regardless of whether we already have a token), * place it in the local model, then connect. + * + * If the token is invalid abort and start over from askTokenAndConnect() + * unless retry is false. */ private fun askTokenAndConnect(openBrowser: Boolean = true) { component.apply() // Force bindings to be filled. @@ -428,7 +426,9 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod return // User aborted. } localWizardModel.token = pastedToken - connect() + connect(localWizardModel.coderURL.toURL(), localWizardModel.token) { + askTokenAndConnect(false) + } } /** @@ -439,20 +439,16 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod * Existing workspaces will be immediately cleared before attempting to * connect to the new deployment. * - * If the token is invalid abort and start over from askTokenAndConnect() - * unless retry is false. + * If the token is invalid invoke onAuthFailure. */ - private fun connect(retry: Boolean = true) { + private fun connect(deploymentURL: URL, token: String, onAuthFailure: (() -> Unit)? = null): Job { // Clear out old deployment details. poller?.cancel() listTableModelOfWorkspaces.items = emptyList() - val deploymentURL = localWizardModel.coderURL.toURL() - val token = localWizardModel.token - // Authenticate and load in a background process with progress. // TODO: Make this cancelable. - LifetimeDefinition().launchUnderBackgroundProgress( + return LifetimeDefinition().launchUnderBackgroundProgress( CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.cli.downloader.dialog.title"), canBeCancelled = false, isIndeterminate = true From 670954e2a17442c6ec9d7d31d58c8eefe3bd04e6 Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 21 Apr 2023 16:35:22 -0800 Subject: [PATCH 2/3] Surface connection status and errors in table Instead of just "Nothing to show" it will show the last error or the status if we are currently trying to connect. --- .../views/steps/CoderWorkspacesStepView.kt | 60 ++++++++++++++----- .../messages/CoderGatewayBundle.properties | 4 ++ 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt index 50346030..01ed7f41 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -38,6 +38,7 @@ import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.rd.util.launchUnderBackgroundProgress import com.intellij.openapi.ui.panel.ComponentPanelBuilder +import com.intellij.openapi.ui.setEmptyState import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager import com.intellij.ui.AnActionButton import com.intellij.ui.AppIcon @@ -76,6 +77,7 @@ import java.awt.event.MouseListener import java.awt.event.MouseMotionListener import java.awt.font.TextAttribute import java.awt.font.TextAttribute.UNDERLINE_ON +import java.net.ConnectException import java.net.SocketTimeoutException import java.net.URL import java.nio.file.Path @@ -126,6 +128,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod minWidth = JBUI.scale(52) } rowHeight = 48 + setEmptyState("Disconnected") setSelectionMode(ListSelectionModel.SINGLE_SELECTION) selectionModel.addListSelectionListener { setNextButtonEnabled(selectedObject != null && selectedObject?.agentStatus == RUNNING && selectedObject?.agentOS == OS.LINUX) @@ -444,6 +447,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod private fun connect(deploymentURL: URL, token: String, onAuthFailure: (() -> Unit)? = null): Job { // Clear out old deployment details. poller?.cancel() + tableOfWorkspaces.setEmptyState("Connecting to $deploymentURL...") listTableModelOfWorkspaces.items = emptyList() // Authenticate and load in a background process with progress. @@ -453,6 +457,12 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod canBeCancelled = false, isIndeterminate = true ) { + val cliManager = CoderCLIManager( + deploymentURL, + if (settings.binaryDestination.isNotBlank()) Path.of(settings.binaryDestination) + else CoderCLIManager.getDataDir(), + settings.binarySource, + ) try { this.indicator.text = "Authenticating client..." authenticate(deploymentURL, token) @@ -461,12 +471,6 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod appPropertiesService.setValue(SESSION_TOKEN, token) this.indicator.text = "Downloading Coder CLI..." - val cliManager = CoderCLIManager( - deploymentURL, - if (settings.binaryDestination.isNotBlank()) Path.of(settings.binaryDestination) - else CoderCLIManager.getDataDir(), - settings.binarySource, - ) cliManager.downloadCLI() this.indicator.text = "Authenticating Coder CLI..." @@ -477,17 +481,41 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod updateWorkspaceActions() triggerWorkspacePolling(false) - } catch (e: AuthenticationResponseException) { - logger.error("Token was rejected by $deploymentURL; has your token expired?", e) - if (retry) { - askTokenAndConnect(false) // Try again but no more opening browser windows. - } - } catch (e: SocketTimeoutException) { - logger.error("Unable to connect to $deploymentURL; is it up?", e) - } catch (e: ResponseException) { - logger.error("Failed to download Coder CLI", e) + + tableOfWorkspaces.setEmptyState("Connected to $deploymentURL") } catch (e: Exception) { - logger.error("Failed to configure connection to $deploymentURL", e) + val errorSummary = e.message ?: "No reason was provided" + var msg = CoderGatewayBundle.message( + "gateway.connector.view.workspaces.connect.failed", + deploymentURL, + errorSummary, + ) + when (e) { + is AuthenticationResponseException -> { + msg = CoderGatewayBundle.message( + "gateway.connector.view.workspaces.connect.unauthorized", + deploymentURL, + ) + cs.launch { onAuthFailure?.invoke() } + } + + is SocketTimeoutException -> { + msg = CoderGatewayBundle.message( + "gateway.connector.view.workspaces.connect.timeout", + deploymentURL, + ) + } + + is ResponseException, is ConnectException -> { + msg = CoderGatewayBundle.message( + "gateway.connector.view.workspaces.connect.download-failed", + cliManager.remoteBinaryURL, + errorSummary, + ) + } + } + tableOfWorkspaces.setEmptyState(msg) + logger.error(msg, e) } } } diff --git a/src/main/resources/messages/CoderGatewayBundle.properties b/src/main/resources/messages/CoderGatewayBundle.properties index dc25dca9..96c417a0 100644 --- a/src/main/resources/messages/CoderGatewayBundle.properties +++ b/src/main/resources/messages/CoderGatewayBundle.properties @@ -20,6 +20,10 @@ gateway.connector.view.coder.workspaces.create.text=Create workspace gateway.connector.view.coder.workspaces.unsupported.os.info=Gateway supports only Linux machines. Support for macOS and Windows is planned. gateway.connector.view.coder.workspaces.invalid.coder.version=Could not parse Coder version {0}. Coder Gateway plugin might not be compatible with this version. Connect to a Coder workspace manually gateway.connector.view.coder.workspaces.unsupported.coder.version=Coder version {0} might not be compatible with this plugin version. Connect to a Coder workspace manually +gateway.connector.view.workspaces.connect.unauthorized=Token was rejected by {0}; has your token expired? +gateway.connector.view.workspaces.connect.timeout=Unable to connect to {0}; is it up? +gateway.connector.view.workspaces.connect.download-failed=Failed to download Coder CLI from {0}: {1} +gateway.connector.view.workspaces.connect.failed=Failed to configure connection to {0}: {1} gateway.connector.view.coder.remoteproject.loading.text=Retrieving products... gateway.connector.view.coder.remoteproject.ide.error.text=Could not retrieve any IDE because an error was encountered. Please check the logs for more details! gateway.connector.view.coder.remoteproject.ssh.error.text=Can't connect to the workspace. Please make sure Coder Agent is running! From e3bf09269a794972f4a301435b4fc542ed2bae61 Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 21 Apr 2023 16:40:07 -0800 Subject: [PATCH 3/3] Surface token source and error Now it will say whether the token was from the config or was the last known token and if it fails there will be an error message. You could always check the error in the bottom right but this way it is more obvious why the token dialog has reappeared. Also if the URL has changed there is no point trying to use the token we had stored for the previous URL. --- .../models/CoderWorkspacesWizardModel.kt | 8 +- .../views/steps/CoderWorkspacesStepView.kt | 112 ++++++++++++------ .../messages/CoderGatewayBundle.properties | 4 + 3 files changed, 88 insertions(+), 36 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt b/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt index 290092ff..c553cb14 100644 --- a/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt +++ b/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt @@ -1,8 +1,14 @@ package com.coder.gateway.models +enum class TokenSource { + CONFIG, // Pulled from the Coder CLI config. + USER, // Input by the user. + LAST_USED, // Last used token, either from storage or current run. +} + data class CoderWorkspacesWizardModel( var coderURL: String = "https://coder.example.com", - var token: String = "", + var token: Pair? = null, var selectedWorkspace: WorkspaceAgentModel? = null, var useExistingToken: Boolean = false, ) diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt index 01ed7f41..4d6d1cf4 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -3,6 +3,7 @@ package com.coder.gateway.views.steps import com.coder.gateway.CoderGatewayBundle import com.coder.gateway.icons.CoderIcons import com.coder.gateway.models.CoderWorkspacesWizardModel +import com.coder.gateway.models.TokenSource import com.coder.gateway.models.WorkspaceAgentModel import com.coder.gateway.models.WorkspaceAgentStatus import com.coder.gateway.models.WorkspaceAgentStatus.FAILED @@ -56,10 +57,12 @@ import com.intellij.ui.dsl.builder.bindSelected import com.intellij.ui.dsl.builder.bindText import com.intellij.ui.dsl.builder.panel import com.intellij.ui.table.TableView +import com.intellij.util.applyIf import com.intellij.util.ui.ColumnInfo import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import com.intellij.util.ui.ListTableModel +import com.intellij.util.ui.UIUtil import com.intellij.util.ui.table.IconTableCellRenderer import com.jetbrains.rd.util.lifetime.LifetimeDefinition import kotlinx.coroutines.CoroutineScope @@ -348,7 +351,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod override fun onInit(wizardModel: CoderWorkspacesWizardModel) { listTableModelOfWorkspaces.items = emptyList() - if (localWizardModel.coderURL.isNotBlank() && localWizardModel.token.isNotBlank()) { + if (localWizardModel.coderURL.isNotBlank() && localWizardModel.token != null) { triggerWorkspacePolling(true) } else { val (url, token) = readStorageOrConfig() @@ -357,10 +360,10 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod tfUrl?.text = url } if (!token.isNullOrBlank()) { - localWizardModel.token = token + localWizardModel.token = Pair(token, TokenSource.CONFIG) } if (!url.isNullOrBlank() && !token.isNullOrBlank()) { - connect(url.toURL(), token) + connect(url.toURL(), Pair(token, TokenSource.CONFIG)) } } updateWorkspaceActions() @@ -417,20 +420,21 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod * If the token is invalid abort and start over from askTokenAndConnect() * unless retry is false. */ - private fun askTokenAndConnect(openBrowser: Boolean = true) { + private fun askTokenAndConnect(isRetry: Boolean = false) { + val oldURL = localWizardModel.coderURL.toURL() component.apply() // Force bindings to be filled. + val newURL = localWizardModel.coderURL.toURL() val pastedToken = askToken( - localWizardModel.coderURL.toURL(), - localWizardModel.token, - openBrowser, + newURL, + // If this is a new URL there is no point in trying to use the same + // token. + if (oldURL == newURL) localWizardModel.token else null, + isRetry, localWizardModel.useExistingToken, - ) - if (pastedToken.isNullOrBlank()) { - return // User aborted. - } + ) ?: return // User aborted. localWizardModel.token = pastedToken - connect(localWizardModel.coderURL.toURL(), localWizardModel.token) { - askTokenAndConnect(false) + connect(newURL, pastedToken) { + askTokenAndConnect(true) } } @@ -444,7 +448,11 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod * * If the token is invalid invoke onAuthFailure. */ - private fun connect(deploymentURL: URL, token: String, onAuthFailure: (() -> Unit)? = null): Job { + private fun connect( + deploymentURL: URL, + token: Pair, + onAuthFailure: (() -> Unit)? = null, + ): Job { // Clear out old deployment details. poller?.cancel() tableOfWorkspaces.setEmptyState("Connecting to $deploymentURL...") @@ -465,16 +473,16 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod ) try { this.indicator.text = "Authenticating client..." - authenticate(deploymentURL, token) + authenticate(deploymentURL, token.first) // Remember these in order to default to them for future attempts. appPropertiesService.setValue(CODER_URL_KEY, deploymentURL.toString()) - appPropertiesService.setValue(SESSION_TOKEN, token) + appPropertiesService.setValue(SESSION_TOKEN, token.first) this.indicator.text = "Downloading Coder CLI..." cliManager.downloadCLI() this.indicator.text = "Authenticating Coder CLI..." - cliManager.login(token) + cliManager.login(token.first) this.indicator.text = "Retrieving workspaces..." loadWorkspaces() @@ -521,22 +529,29 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } /** - * Open a dialog for providing the token. Show the existing token so the - * user can validate it if a previous connection failed. Open a browser to - * the auth page if openBrowser is true and useExisting is false. If - * useExisting is true then populate the dialog with the token on disk if - * there is one and it matches the url (this will overwrite the provided - * token). Return the token submitted by the user. + * Open a dialog for providing the token. Show any existing token so the + * user can validate it if a previous connection failed. If we are not + * retrying and the user has not checked the existing token box then open a + * browser to the auth page. If the user has checked the existing token box + * then populate the dialog with the token on disk (this will overwrite any + * other existing token) unless this is a retry to avoid clobbering the + * token that just failed. Return the token submitted by the user. */ - private fun askToken(url: URL, token: String, openBrowser: Boolean, useExisting: Boolean): String? { - var existingToken = token + private fun askToken( + url: URL, + token: Pair?, + isRetry: Boolean, + useExisting: Boolean, + ): Pair? { + var (existingToken, tokenSource) = token ?: Pair("", TokenSource.USER) val getTokenUrl = url.withPath("/login?redirect=%2Fcli-auth") - if (openBrowser && !useExisting) { + if (!isRetry && !useExisting) { BrowserUtil.browse(getTokenUrl) - } else if (useExisting) { + } else if (!isRetry && useExisting) { val (u, t) = CoderCLIManager.readConfig() - if (url == u?.toURL() && !t.isNullOrBlank()) { - logger.info("Injecting valid token from CLI config") + if (url == u?.toURL() && !t.isNullOrBlank() && t != existingToken) { + logger.info("Injecting token from CLI config") + tokenSource = TokenSource.CONFIG existingToken = t } } @@ -549,11 +564,32 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod CoderGatewayBundle.message("gateway.connector.view.login.token.label"), getTokenUrl.toString() ) - sessionTokenTextField = textField().applyToComponent { - text = existingToken - minimumSize = Dimension(520, -1) - }.component - } + sessionTokenTextField = textField() + .applyToComponent { + text = existingToken + minimumSize = Dimension(520, -1) + }.component + }.layout(RowLayout.PARENT_GRID) + row { + cell() // To align with the text box. + cell( + ComponentPanelBuilder.createCommentComponent( + CoderGatewayBundle.message( + if (isRetry) "gateway.connector.view.workspaces.token.rejected" + else if (tokenSource == TokenSource.CONFIG) "gateway.connector.view.workspaces.token.injected" + else if (existingToken.isNotBlank()) "gateway.connector.view.workspaces.token.comment" + else "gateway.connector.view.workspaces.token.none" + ), + false, + -1, + true + ).applyIf(isRetry) { + apply { + foreground = UIUtil.getErrorForeground() + } + } + ) + }.layout(RowLayout.PARENT_GRID) } AppIcon.getInstance().requestAttention(null, true) if (!dialog( @@ -566,7 +602,13 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } tokenFromUser = sessionTokenTextField.text }, ModalityState.any()) - return tokenFromUser + if (tokenFromUser.isNullOrBlank()) { + return null + } + if (tokenFromUser != existingToken) { + tokenSource = TokenSource.USER + } + return Pair(tokenFromUser!!, tokenSource) } private fun triggerWorkspacePolling(fetchNow: Boolean) { diff --git a/src/main/resources/messages/CoderGatewayBundle.properties b/src/main/resources/messages/CoderGatewayBundle.properties index 96c417a0..fa45415f 100644 --- a/src/main/resources/messages/CoderGatewayBundle.properties +++ b/src/main/resources/messages/CoderGatewayBundle.properties @@ -24,6 +24,10 @@ gateway.connector.view.workspaces.connect.unauthorized=Token was rejected by {0} gateway.connector.view.workspaces.connect.timeout=Unable to connect to {0}; is it up? gateway.connector.view.workspaces.connect.download-failed=Failed to download Coder CLI from {0}: {1} gateway.connector.view.workspaces.connect.failed=Failed to configure connection to {0}: {1} +gateway.connector.view.workspaces.token.comment=The last used token is shown above. +gateway.connector.view.workspaces.token.rejected=This token was rejected. +gateway.connector.view.workspaces.token.injected=This token was pulled from your CLI config. +gateway.connector.view.workspaces.token.none=No existing token found. gateway.connector.view.coder.remoteproject.loading.text=Retrieving products... gateway.connector.view.coder.remoteproject.ide.error.text=Could not retrieve any IDE because an error was encountered. Please check the logs for more details! gateway.connector.view.coder.remoteproject.ssh.error.text=Can't connect to the workspace. Please make sure Coder Agent is running!