Skip to content

Commit 53b1362

Browse files
authored
impl: notify user when agent can't be contacted (#249)
At a recent customer incident the Coder server could not ping the agent for a short brief of time because of intermittent network issues. The user had no idea they have network issues and was expecting the ssh sessions to work flawlessly. This PR lays the groundwork for monitoring the connection by checking the status for workspace, agent and agent lifecycle and make an educated guess when the network runs into issues. - resolves #246
1 parent 57ce058 commit 53b1362

10 files changed

Lines changed: 295 additions & 7 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ jvm/
88

99
# hidden macOS metadata files
1010
.DS_Store
11+
bin/

src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,9 @@ class CoderRemoteEnvironment(
256256
context.logger.info("Disconnected from $id")
257257
}
258258

259+
/**
260+
* Update the workspace/agent status to the listeners, if it has changed.
261+
*/
259262
/**
260263
* Update the workspace/agent status to the listeners, if it has changed.
261264
*/
@@ -265,15 +268,18 @@ class CoderRemoteEnvironment(
265268
}
266269
this.workspace = workspace
267270
this.agent = agent
271+
268272
// workspace&agent status can be different from "environment status"
269273
// which is forced to queued state when a workspace is scheduled to start
270274
updateStatus(WorkspaceAndAgentStatus.from(workspace, agent))
275+
context.connectionMonitoringService.checkConnectionStatus(workspace, agent)
271276

272277
// we have to regenerate the action list in order to force a redraw
273278
// because the actions don't have a state flow on the enabled property
274279
refreshAvailableActions()
275280
}
276281

282+
277283
private fun updateStatus(status: WorkspaceAndAgentStatus) {
278284
environmentStatus = status
279285
state.update {

src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.coder.toolbox
22

33
import com.coder.toolbox.store.CoderSecretsStore
44
import com.coder.toolbox.store.CoderSettingsStore
5+
import com.coder.toolbox.util.ConnectionMonitoringService
56
import com.coder.toolbox.util.toURL
67
import com.jetbrains.toolbox.api.core.diagnostics.Logger
78
import com.jetbrains.toolbox.api.core.os.LocalDesktopManager
@@ -30,6 +31,7 @@ data class CoderToolboxContext(
3031
val settingsStore: CoderSettingsStore,
3132
val secrets: CoderSecretsStore,
3233
val proxySettings: ToolboxProxySettings,
34+
val connectionMonitoringService: ConnectionMonitoringService,
3335
) {
3436
/**
3537
* Try to find a URL.

src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.coder.toolbox
33
import com.coder.toolbox.settings.Environment
44
import com.coder.toolbox.store.CoderSecretsStore
55
import com.coder.toolbox.store.CoderSettingsStore
6+
import com.coder.toolbox.util.ConnectionMonitoringService
67
import com.jetbrains.toolbox.api.core.PluginSecretStore
78
import com.jetbrains.toolbox.api.core.PluginSettingsStore
89
import com.jetbrains.toolbox.api.core.ServiceLocator
@@ -26,21 +27,30 @@ import kotlinx.coroutines.CoroutineScope
2627
class CoderToolboxExtension : RemoteDevExtension {
2728
// All services must be passed in here and threaded as necessary.
2829
override fun createRemoteProviderPluginInstance(serviceLocator: ServiceLocator): RemoteProvider {
30+
val ui = serviceLocator.getService<ToolboxUi>()
2931
val logger = serviceLocator.getService(Logger::class.java)
32+
val cs = serviceLocator.getService<CoroutineScope>()
33+
val i18n = serviceLocator.getService<LocalizableStringFactory>()
3034
return CoderRemoteProvider(
3135
CoderToolboxContext(
32-
serviceLocator.getService<ToolboxUi>(),
36+
ui,
3337
serviceLocator.getService<EnvironmentUiPageManager>(),
3438
serviceLocator.getService<EnvironmentStateColorPalette>(),
3539
serviceLocator.getService<RemoteToolsHelper>(),
3640
serviceLocator.getService<ClientHelper>(),
3741
serviceLocator.getService<LocalDesktopManager>(),
38-
serviceLocator.getService<CoroutineScope>(),
42+
cs,
3943
serviceLocator.getService<Logger>(),
40-
serviceLocator.getService<LocalizableStringFactory>(),
44+
i18n,
4145
CoderSettingsStore(serviceLocator.getService<PluginSettingsStore>(), Environment(), logger),
4246
CoderSecretsStore(serviceLocator.getService<PluginSecretStore>()),
43-
serviceLocator.getService<ToolboxProxySettings>()
47+
serviceLocator.getService<ToolboxProxySettings>(),
48+
ConnectionMonitoringService(
49+
cs,
50+
ui,
51+
logger,
52+
i18n
53+
)
4454
)
4555
)
4656
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package com.coder.toolbox.util
2+
3+
import com.coder.toolbox.sdk.v2.models.Workspace
4+
import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
5+
import com.coder.toolbox.sdk.v2.models.WorkspaceAgentLifecycleState
6+
import com.coder.toolbox.sdk.v2.models.WorkspaceAgentStatus
7+
import com.coder.toolbox.sdk.v2.models.WorkspaceStatus
8+
import com.jetbrains.toolbox.api.core.diagnostics.Logger
9+
import com.jetbrains.toolbox.api.localization.LocalizableStringFactory
10+
import com.jetbrains.toolbox.api.ui.ToolboxUi
11+
import kotlinx.coroutines.CoroutineScope
12+
import kotlinx.coroutines.launch
13+
import java.util.UUID
14+
15+
class ConnectionMonitoringService(
16+
private val cs: CoroutineScope,
17+
private val ui: ToolboxUi,
18+
private val logger: Logger,
19+
private val i18n: LocalizableStringFactory
20+
) {
21+
private var alreadyNotified = false
22+
23+
fun checkConnectionStatus(ws: Workspace, agent: WorkspaceAgent) {
24+
if (alreadyNotified) {
25+
return
26+
}
27+
28+
val isWorkspaceRunning = ws.latestBuild.status == WorkspaceStatus.RUNNING
29+
val isAgentReady = agent.lifecycleState == WorkspaceAgentLifecycleState.READY
30+
val hasConnectionIssue = agent.status in setOf(
31+
WorkspaceAgentStatus.DISCONNECTED,
32+
WorkspaceAgentStatus.TIMEOUT
33+
)
34+
35+
when {
36+
isWorkspaceRunning && isAgentReady && hasConnectionIssue -> {
37+
cs.launch {
38+
logAndShowWarning(
39+
title = "Unstable connection detected",
40+
warning = "Unstable connection between Coder server and workspace detected. Your active sessions may disconnect"
41+
)
42+
}
43+
alreadyNotified = true
44+
}
45+
}
46+
}
47+
48+
49+
private suspend fun logAndShowWarning(title: String, warning: String) {
50+
logger.warn(warning)
51+
ui.showSnackbar(
52+
UUID.randomUUID().toString(),
53+
i18n.ptrl(title),
54+
i18n.ptrl(warning),
55+
i18n.ptrl("OK")
56+
)
57+
}
58+
}

src/main/resources/localization/defaultMessages.po

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,4 +191,10 @@ msgid "Workspace name"
191191
msgstr ""
192192

193193
msgid "Use app name as main page title instead of URL"
194+
msgstr ""
195+
196+
msgid "Unstable connection detected"
197+
msgstr ""
198+
199+
msgid "Unstable connection between Coder server and workspace detected. Your active sessions may disconnect"
194200
msgstr ""

src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import com.coder.toolbox.store.NETWORK_INFO_DIR
2323
import com.coder.toolbox.store.SSH_CONFIG_OPTIONS
2424
import com.coder.toolbox.store.SSH_CONFIG_PATH
2525
import com.coder.toolbox.store.SSH_LOG_DIR
26+
import com.coder.toolbox.util.ConnectionMonitoringService
2627
import com.coder.toolbox.util.InvalidVersionException
2728
import com.coder.toolbox.util.OS
2829
import com.coder.toolbox.util.SemVer
@@ -100,7 +101,9 @@ internal class CoderCLIManagerTest {
100101

101102
override fun removeProxyChangeListener(listener: Runnable) {
102103
}
103-
})
104+
},
105+
mockk<ConnectionMonitoringService>()
106+
)
104107

105108
@BeforeTest
106109
fun setup() {

src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import com.coder.toolbox.store.CoderSecretsStore
1818
import com.coder.toolbox.store.CoderSettingsStore
1919
import com.coder.toolbox.store.TLS_ALTERNATE_HOSTNAME
2020
import com.coder.toolbox.store.TLS_CA_PATH
21+
import com.coder.toolbox.util.ConnectionMonitoringService
2122
import com.coder.toolbox.util.pluginTestSettingsStore
2223
import com.coder.toolbox.util.sslContextFromPEMs
2324
import com.jetbrains.toolbox.api.core.diagnostics.Logger
@@ -122,7 +123,9 @@ class CoderRestClientTest {
122123

123124
override fun removeProxyChangeListener(listener: Runnable) {
124125
}
125-
})
126+
},
127+
mockk<ConnectionMonitoringService>()
128+
)
126129

127130

128131
data class TestWorkspace(var workspace: Workspace, var resources: List<WorkspaceResource>? = emptyList())

src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ internal class CoderProtocolHandlerTest {
5050
mockk<LocalizableStringFactory>(relaxed = true),
5151
CoderSettingsStore(pluginTestSettingsStore(), Environment(), mockk<Logger>(relaxed = true)),
5252
mockk<CoderSecretsStore>(),
53-
mockk<ToolboxProxySettings>()
53+
mockk<ToolboxProxySettings>(),
54+
mockk<ConnectionMonitoringService>()
5455
)
5556

5657
private val protocolHandler = CoderProtocolHandler(

0 commit comments

Comments
 (0)