diff --git a/.gitignore b/.gitignore index 01b32146..489df97e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ jvm/ # hidden macOS metadata files .DS_Store +bin/ \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 092774ae..d143cf9b 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -256,6 +256,9 @@ class CoderRemoteEnvironment( context.logger.info("Disconnected from $id") } + /** + * Update the workspace/agent status to the listeners, if it has changed. + */ /** * Update the workspace/agent status to the listeners, if it has changed. */ @@ -265,15 +268,18 @@ class CoderRemoteEnvironment( } this.workspace = workspace this.agent = agent + // workspace&agent status can be different from "environment status" // which is forced to queued state when a workspace is scheduled to start updateStatus(WorkspaceAndAgentStatus.from(workspace, agent)) + context.connectionMonitoringService.checkConnectionStatus(workspace, agent) // we have to regenerate the action list in order to force a redraw // because the actions don't have a state flow on the enabled property refreshAvailableActions() } + private fun updateStatus(status: WorkspaceAndAgentStatus) { environmentStatus = status state.update { diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index ac3cbcc7..53080677 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -2,6 +2,7 @@ package com.coder.toolbox import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore +import com.coder.toolbox.util.ConnectionMonitoringService import com.coder.toolbox.util.toURL import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager @@ -30,6 +31,7 @@ data class CoderToolboxContext( val settingsStore: CoderSettingsStore, val secrets: CoderSecretsStore, val proxySettings: ToolboxProxySettings, + val connectionMonitoringService: ConnectionMonitoringService, ) { /** * Try to find a URL. diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt index 5cfcd11f..010789d7 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt @@ -3,6 +3,7 @@ package com.coder.toolbox import com.coder.toolbox.settings.Environment import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore +import com.coder.toolbox.util.ConnectionMonitoringService import com.jetbrains.toolbox.api.core.PluginSecretStore import com.jetbrains.toolbox.api.core.PluginSettingsStore import com.jetbrains.toolbox.api.core.ServiceLocator @@ -26,21 +27,30 @@ import kotlinx.coroutines.CoroutineScope class CoderToolboxExtension : RemoteDevExtension { // All services must be passed in here and threaded as necessary. override fun createRemoteProviderPluginInstance(serviceLocator: ServiceLocator): RemoteProvider { + val ui = serviceLocator.getService() val logger = serviceLocator.getService(Logger::class.java) + val cs = serviceLocator.getService() + val i18n = serviceLocator.getService() return CoderRemoteProvider( CoderToolboxContext( - serviceLocator.getService(), + ui, serviceLocator.getService(), serviceLocator.getService(), serviceLocator.getService(), serviceLocator.getService(), serviceLocator.getService(), - serviceLocator.getService(), + cs, serviceLocator.getService(), - serviceLocator.getService(), + i18n, CoderSettingsStore(serviceLocator.getService(), Environment(), logger), CoderSecretsStore(serviceLocator.getService()), - serviceLocator.getService() + serviceLocator.getService(), + ConnectionMonitoringService( + cs, + ui, + logger, + i18n + ) ) ) } diff --git a/src/main/kotlin/com/coder/toolbox/util/ConnectionMonitoringService.kt b/src/main/kotlin/com/coder/toolbox/util/ConnectionMonitoringService.kt new file mode 100644 index 00000000..0ff3e723 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/util/ConnectionMonitoringService.kt @@ -0,0 +1,58 @@ +package com.coder.toolbox.util + +import com.coder.toolbox.sdk.v2.models.Workspace +import com.coder.toolbox.sdk.v2.models.WorkspaceAgent +import com.coder.toolbox.sdk.v2.models.WorkspaceAgentLifecycleState +import com.coder.toolbox.sdk.v2.models.WorkspaceAgentStatus +import com.coder.toolbox.sdk.v2.models.WorkspaceStatus +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.localization.LocalizableStringFactory +import com.jetbrains.toolbox.api.ui.ToolboxUi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.util.UUID + +class ConnectionMonitoringService( + private val cs: CoroutineScope, + private val ui: ToolboxUi, + private val logger: Logger, + private val i18n: LocalizableStringFactory +) { + private var alreadyNotified = false + + fun checkConnectionStatus(ws: Workspace, agent: WorkspaceAgent) { + if (alreadyNotified) { + return + } + + val isWorkspaceRunning = ws.latestBuild.status == WorkspaceStatus.RUNNING + val isAgentReady = agent.lifecycleState == WorkspaceAgentLifecycleState.READY + val hasConnectionIssue = agent.status in setOf( + WorkspaceAgentStatus.DISCONNECTED, + WorkspaceAgentStatus.TIMEOUT + ) + + when { + isWorkspaceRunning && isAgentReady && hasConnectionIssue -> { + cs.launch { + logAndShowWarning( + title = "Unstable connection detected", + warning = "Unstable connection between Coder server and workspace detected. Your active sessions may disconnect" + ) + } + alreadyNotified = true + } + } + } + + + private suspend fun logAndShowWarning(title: String, warning: String) { + logger.warn(warning) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.ptrl(title), + i18n.ptrl(warning), + i18n.ptrl("OK") + ) + } +} \ No newline at end of file diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index 16b6ed5a..abff66c3 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -191,4 +191,10 @@ msgid "Workspace name" msgstr "" msgid "Use app name as main page title instead of URL" +msgstr "" + +msgid "Unstable connection detected" +msgstr "" + +msgid "Unstable connection between Coder server and workspace detected. Your active sessions may disconnect" msgstr "" \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index 74caf65c..15ebfcdd 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -23,6 +23,7 @@ import com.coder.toolbox.store.NETWORK_INFO_DIR import com.coder.toolbox.store.SSH_CONFIG_OPTIONS import com.coder.toolbox.store.SSH_CONFIG_PATH import com.coder.toolbox.store.SSH_LOG_DIR +import com.coder.toolbox.util.ConnectionMonitoringService import com.coder.toolbox.util.InvalidVersionException import com.coder.toolbox.util.OS import com.coder.toolbox.util.SemVer @@ -100,7 +101,9 @@ internal class CoderCLIManagerTest { override fun removeProxyChangeListener(listener: Runnable) { } - }) + }, + mockk() + ) @BeforeTest fun setup() { diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index 49314c55..dc135230 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -18,6 +18,7 @@ import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore import com.coder.toolbox.store.TLS_ALTERNATE_HOSTNAME import com.coder.toolbox.store.TLS_CA_PATH +import com.coder.toolbox.util.ConnectionMonitoringService import com.coder.toolbox.util.pluginTestSettingsStore import com.coder.toolbox.util.sslContextFromPEMs import com.jetbrains.toolbox.api.core.diagnostics.Logger @@ -122,7 +123,9 @@ class CoderRestClientTest { override fun removeProxyChangeListener(listener: Runnable) { } - }) + }, + mockk() + ) data class TestWorkspace(var workspace: Workspace, var resources: List? = emptyList()) diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index 326fce04..48697eb4 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -50,7 +50,8 @@ internal class CoderProtocolHandlerTest { mockk(relaxed = true), CoderSettingsStore(pluginTestSettingsStore(), Environment(), mockk(relaxed = true)), mockk(), - mockk() + mockk(), + mockk() ) private val protocolHandler = CoderProtocolHandler( diff --git a/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt b/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt new file mode 100644 index 00000000..72ccb255 --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt @@ -0,0 +1,198 @@ +package com.coder.toolbox.util + +import com.coder.toolbox.sdk.v2.models.Workspace +import com.coder.toolbox.sdk.v2.models.WorkspaceAgent +import com.coder.toolbox.sdk.v2.models.WorkspaceAgentLifecycleState +import com.coder.toolbox.sdk.v2.models.WorkspaceAgentStatus +import com.coder.toolbox.sdk.v2.models.WorkspaceBuild +import com.coder.toolbox.sdk.v2.models.WorkspaceStatus +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.localization.LocalizableString +import com.jetbrains.toolbox.api.localization.LocalizableStringFactory +import com.jetbrains.toolbox.api.ui.ToolboxUi +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import java.util.UUID +import kotlin.test.Test + +class ConnectionMonitoringServiceTest { + + private val ui = mockk(relaxed = true) + private val logger = mockk(relaxed = true) + private val i18n = mockk() + private val cs = TestScope(UnconfinedTestDispatcher()) + + init { + every { i18n.ptrl(any()) } answers { I18String(firstArg()) } + } + + @Test + fun `given a running workspace with a timed out agent and a ready lifecycle then expect a connection unstable notification`() = + cs.runTest { + val service = ConnectionMonitoringService(cs, ui, logger, i18n) + val workspace = createWorkspace(WorkspaceStatus.RUNNING) + val agent = createAgent(WorkspaceAgentStatus.TIMEOUT, WorkspaceAgentLifecycleState.READY) + + service.checkConnectionStatus(workspace, agent) + + coVerify(exactly = 1) { logger.warn(any()) } + coVerify(exactly = 1) { ui.showSnackbar(any(), any(), any(), any()) } + } + + @Test + fun `given a running workspace with a disconnected agent and a ready lifecycle then expect a connection unstable notification`() = + cs.runTest { + val service = ConnectionMonitoringService(cs, ui, logger, i18n) + val workspace = createWorkspace(WorkspaceStatus.RUNNING) + val agent = createAgent(WorkspaceAgentStatus.DISCONNECTED, WorkspaceAgentLifecycleState.READY) + + service.checkConnectionStatus(workspace, agent) + + coVerify(exactly = 1) { logger.warn(any()) } + coVerify(exactly = 1) { ui.showSnackbar(any(), any(), any(), any()) } + } + + @Test + fun `given a stopped workspace then expect no notification`() = cs.runTest { + val service = ConnectionMonitoringService(cs, ui, logger, i18n) + val workspace = createWorkspace(WorkspaceStatus.STOPPED) + val agent = createAgent(WorkspaceAgentStatus.DISCONNECTED, WorkspaceAgentLifecycleState.READY) + + service.checkConnectionStatus(workspace, agent) + + coVerify(exactly = 0) { logger.warn(any()) } + coVerify(exactly = 0) { ui.showSnackbar(any(), any(), any(), any()) } + } + + @Test + fun `given a running workspace with a disconnected agent and a starting lifecycle then expect no notification`() = + cs.runTest { + val service = ConnectionMonitoringService(cs, ui, logger, i18n) + val workspace = createWorkspace(WorkspaceStatus.RUNNING) + val agent = createAgent(WorkspaceAgentStatus.DISCONNECTED, WorkspaceAgentLifecycleState.STARTING) + + service.checkConnectionStatus(workspace, agent) + + coVerify(exactly = 0) { logger.warn(any()) } + coVerify(exactly = 0) { ui.showSnackbar(any(), any(), any(), any()) } + } + + @Test + fun `given a running workspace with a disconnected agent and a ready lifecycle then expect expect that user is notified only once`() = + cs.runTest { + val service = ConnectionMonitoringService(cs, ui, logger, i18n) + val workspace = createWorkspace(WorkspaceStatus.RUNNING) + val agent = createAgent(WorkspaceAgentStatus.DISCONNECTED, WorkspaceAgentLifecycleState.READY) + + // First call triggers notification + service.checkConnectionStatus(workspace, agent) + + // Reset mocks to verify subsequent calls + io.mockk.clearMocks(ui, logger, answers = false) + + // Second call should not trigger notification + service.checkConnectionStatus(workspace, agent) + + coVerify(exactly = 0) { logger.warn(any()) } + coVerify(exactly = 0) { ui.showSnackbar(any(), any(), any(), any()) } + } + + @Test + fun `given a running workspace with a timed out agent and a ready lifecycle then expect expect that user is notified only once`() = + cs.runTest { + val service = ConnectionMonitoringService(cs, ui, logger, i18n) + val workspace = createWorkspace(WorkspaceStatus.RUNNING) + val agent = createAgent(WorkspaceAgentStatus.TIMEOUT, WorkspaceAgentLifecycleState.READY) + + // First call triggers notification + service.checkConnectionStatus(workspace, agent) + + // Second call should not trigger notification + service.checkConnectionStatus(workspace, agent) + + coVerify(exactly = 1) { logger.warn(any()) } + coVerify(exactly = 1) { ui.showSnackbar(any(), any(), any(), any()) } + } + + @Test + fun `given two running workspaces with disconnected agents and ready lifecycles then expect expect that user is notified only once`() = + cs.runTest { + val service = ConnectionMonitoringService(cs, ui, logger, i18n) + val ws1 = createWorkspace(WorkspaceStatus.RUNNING) + val ws2 = createWorkspace(WorkspaceStatus.RUNNING) + val agent1 = createAgent(WorkspaceAgentStatus.DISCONNECTED, WorkspaceAgentLifecycleState.READY) + val agent2 = createAgent(WorkspaceAgentStatus.DISCONNECTED, WorkspaceAgentLifecycleState.READY) + + // First call triggers notification + service.checkConnectionStatus(ws1, agent1) + + // Second call should not trigger notification + service.checkConnectionStatus(ws2, agent2) + + coVerify(exactly = 1) { logger.warn(any()) } + coVerify(exactly = 1) { ui.showSnackbar(any(), any(), any(), any()) } + } + + @Test + fun `given two running workspaces with timed out agents and ready lifecycles then expect expect that user is notified only once`() = + cs.runTest { + val service = ConnectionMonitoringService(cs, ui, logger, i18n) + val ws1 = createWorkspace(WorkspaceStatus.RUNNING) + val ws2 = createWorkspace(WorkspaceStatus.RUNNING) + val agent1 = createAgent(WorkspaceAgentStatus.TIMEOUT, WorkspaceAgentLifecycleState.READY) + val agent2 = createAgent(WorkspaceAgentStatus.TIMEOUT, WorkspaceAgentLifecycleState.READY) + + // First call triggers notification + service.checkConnectionStatus(ws1, agent1) + + // Second call should not trigger notification + service.checkConnectionStatus(ws2, agent2) + + coVerify(exactly = 1) { logger.warn(any()) } + coVerify(exactly = 1) { ui.showSnackbar(any(), any(), any(), any()) } + } + + + private fun createWorkspace(status: WorkspaceStatus): Workspace { + return Workspace( + id = UUID.randomUUID(), + templateID = UUID.randomUUID(), + templateName = "template", + templateDisplayName = "Template", + templateIcon = "icon", + latestBuild = WorkspaceBuild( + id = UUID.randomUUID(), + buildNumber = 1, + templateVersionID = UUID.randomUUID(), + resources = emptyList(), + status = status + ), + outdated = false, + name = "workspace-${UUID.randomUUID()}", + ownerName = "owner" + ) + } + + private fun createAgent( + status: WorkspaceAgentStatus, + lifecycleState: WorkspaceAgentLifecycleState + ): WorkspaceAgent { + return WorkspaceAgent( + id = UUID.randomUUID(), + status = status, + name = "agent-${UUID.randomUUID()}", + architecture = null, + operatingSystem = null, + directory = null, + expandedDirectory = null, + lifecycleState = lifecycleState, + loginBeforeReady = false + ) + } + + private data class I18String(val str: String) : LocalizableString +}