Skip to content

Commit d5b1c3b

Browse files
authored
impl: support for displaying network latency (#108)
Under the "Additional environment information". Unfortunately it was not possible any other way. The description property is modifiable however Toolbox renders the description label only as long as the SSH connection is not established. As soon as an ssh connection is running the description label is used as mechanism to notify users about available IDE updates. It also appears that we can't have any other extra tab, other than "Tools", "Projects" and "Settings". There is a secondary information attribute API, but it is not usable to show recurring metrics info because it can only be configured once, it is not a mutable field. The best effort was to add the information in the Settings page, and it is worth highlighting that the metrics are only refreshed when user either: - switches between tabs - expands/collapses the "Additional environment information" section. There is no programmatic mechanism to notify the information in the Settings page that latency changed. The network metrics are loaded from the pid files created by the ssh command. Toolbox spawns a native process running the SSH client. The ssh client then spawns another process which is associated to the coder proxy command. SSH network metrics are saved into json files with the name equal to the pid of the ssh command (not to be confused with the proxy command's name). - resolves #100 - resolves #101
1 parent 0dfd81c commit d5b1c3b

35 files changed

+320
-31
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Added
6+
7+
- render network status in the Settings tab, under `Additional environment information` section.
8+
59
## 0.2.1 - 2025-05-05
610

711
### Changed

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

+54-2
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@ package com.coder.toolbox
22

33
import com.coder.toolbox.browser.BrowserUtil
44
import com.coder.toolbox.cli.CoderCLIManager
5+
import com.coder.toolbox.cli.SshCommandProcessHandle
56
import com.coder.toolbox.models.WorkspaceAndAgentStatus
67
import com.coder.toolbox.sdk.CoderRestClient
78
import com.coder.toolbox.sdk.ex.APIResponseException
9+
import com.coder.toolbox.sdk.v2.models.NetworkMetrics
810
import com.coder.toolbox.sdk.v2.models.Workspace
911
import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
1012
import com.coder.toolbox.util.waitForFalseWithTimeout
1113
import com.coder.toolbox.util.withPath
1214
import com.coder.toolbox.views.Action
1315
import com.coder.toolbox.views.EnvironmentView
16+
import com.jetbrains.toolbox.api.localization.LocalizableString
1417
import com.jetbrains.toolbox.api.remoteDev.AfterDisconnectHook
1518
import com.jetbrains.toolbox.api.remoteDev.BeforeConnectionHook
1619
import com.jetbrains.toolbox.api.remoteDev.DeleteEnvironmentConfirmationParams
@@ -20,15 +23,21 @@ import com.jetbrains.toolbox.api.remoteDev.environments.EnvironmentContentsView
2023
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentDescription
2124
import com.jetbrains.toolbox.api.remoteDev.states.RemoteEnvironmentState
2225
import com.jetbrains.toolbox.api.ui.actions.ActionDescription
26+
import com.squareup.moshi.Moshi
27+
import kotlinx.coroutines.Job
2328
import kotlinx.coroutines.delay
2429
import kotlinx.coroutines.flow.MutableStateFlow
2530
import kotlinx.coroutines.flow.update
2631
import kotlinx.coroutines.isActive
2732
import kotlinx.coroutines.launch
2833
import kotlinx.coroutines.withTimeout
34+
import java.io.File
35+
import java.nio.file.Path
2936
import kotlin.time.Duration.Companion.minutes
3037
import kotlin.time.Duration.Companion.seconds
3138

39+
private val POLL_INTERVAL = 5.seconds
40+
3241
/**
3342
* Represents an agent and workspace combination.
3443
*
@@ -44,17 +53,20 @@ class CoderRemoteEnvironment(
4453
private var wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent)
4554

4655
override var name: String = "${workspace.name}.${agent.name}"
47-
4856
private var isConnected: MutableStateFlow<Boolean> = MutableStateFlow(false)
4957
override val connectionRequest: MutableStateFlow<Boolean> = MutableStateFlow(false)
5058

5159
override val state: MutableStateFlow<RemoteEnvironmentState> =
5260
MutableStateFlow(wsRawStatus.toRemoteEnvironmentState(context))
5361
override val description: MutableStateFlow<EnvironmentDescription> =
5462
MutableStateFlow(EnvironmentDescription.General(context.i18n.pnotr(workspace.templateDisplayName)))
55-
63+
override val additionalEnvironmentInformation: MutableMap<LocalizableString, String> = mutableMapOf()
5664
override val actionsList: MutableStateFlow<List<ActionDescription>> = MutableStateFlow(getAvailableActions())
5765

66+
private val networkMetricsMarshaller = Moshi.Builder().build().adapter(NetworkMetrics::class.java)
67+
private val proxyCommandHandle = SshCommandProcessHandle(context)
68+
private var pollJob: Job? = null
69+
5870
fun asPairOfWorkspaceAndAgent(): Pair<Workspace, WorkspaceAgent> = Pair(workspace, agent)
5971

6072
private fun getAvailableActions(): List<ActionDescription> {
@@ -141,9 +153,49 @@ class CoderRemoteEnvironment(
141153
override fun beforeConnection() {
142154
context.logger.info("Connecting to $id...")
143155
isConnected.update { true }
156+
pollJob = pollNetworkMetrics()
157+
}
158+
159+
private fun pollNetworkMetrics(): Job = context.cs.launch {
160+
context.logger.info("Starting the network metrics poll job for $id")
161+
while (isActive) {
162+
context.logger.debug("Searching SSH command's PID for workspace $id...")
163+
val pid = proxyCommandHandle.findByWorkspaceAndAgent(workspace, agent)
164+
if (pid == null) {
165+
context.logger.debug("No SSH command PID was found for workspace $id")
166+
delay(POLL_INTERVAL)
167+
continue
168+
}
169+
170+
val metricsFile = Path.of(context.settingsStore.networkInfoDir, "$pid.json").toFile()
171+
if (metricsFile.doesNotExists()) {
172+
context.logger.debug("No metrics file found at ${metricsFile.absolutePath} for $id")
173+
delay(POLL_INTERVAL)
174+
continue
175+
}
176+
context.logger.debug("Loading metrics from ${metricsFile.absolutePath} for $id")
177+
try {
178+
val metrics = networkMetricsMarshaller.fromJson(metricsFile.readText())
179+
if (metrics == null) {
180+
return@launch
181+
}
182+
context.logger.debug("$id metrics: $metrics")
183+
additionalEnvironmentInformation.put(context.i18n.ptrl("Network Status"), metrics.toPretty())
184+
} catch (e: Exception) {
185+
context.logger.error(
186+
e,
187+
"Error encountered while trying to load network metrics from ${metricsFile.absolutePath} for $id"
188+
)
189+
}
190+
delay(POLL_INTERVAL)
191+
}
144192
}
145193

194+
private fun File.doesNotExists(): Boolean = !this.exists()
195+
146196
override fun afterDisconnect() {
197+
context.logger.info("Stopping the network metrics poll job for $id")
198+
pollJob?.cancel()
147199
this.connectionRequest.update { false }
148200
isConnected.update { false }
149201
context.logger.info("Disconnected from $id")

src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt

+1-2
Original file line numberDiff line numberDiff line change
@@ -271,14 +271,13 @@ class CoderCLIManager(
271271
"ssh",
272272
"--stdio",
273273
if (settings.disableAutostart && feats.disableAutostart) "--disable-autostart" else null,
274+
"--network-info-dir ${escape(settings.networkInfoDir)}"
274275
)
275276
val proxyArgs = baseArgs + listOfNotNull(
276277
if (!settings.sshLogDirectory.isNullOrBlank()) "--log-dir" else null,
277278
if (!settings.sshLogDirectory.isNullOrBlank()) escape(settings.sshLogDirectory!!) else null,
278279
if (feats.reportWorkspaceUsage) "--usage-app=jetbrains" else null,
279280
)
280-
val backgroundProxyArgs =
281-
baseArgs + listOfNotNull(if (feats.reportWorkspaceUsage) "--usage-app=disable" else null)
282281
val extraConfig =
283282
if (!settings.sshConfigOptions.isNullOrBlank()) {
284283
"\n" + settings.sshConfigOptions!!.prependIndent(" ")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.coder.toolbox.cli
2+
3+
import com.coder.toolbox.CoderToolboxContext
4+
import com.coder.toolbox.sdk.v2.models.Workspace
5+
import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
6+
import kotlin.jvm.optionals.getOrNull
7+
8+
/**
9+
* Identifies the PID for the SSH Coder command spawned by Toolbox.
10+
*/
11+
class SshCommandProcessHandle(private val ctx: CoderToolboxContext) {
12+
13+
/**
14+
* Finds the PID of a Coder (not the proxy command) ssh cmd associated with the specified workspace and agent.
15+
* Null is returned when no ssh command process was found.
16+
*
17+
* Implementation Notes:
18+
* An iterative DFS approach where we start with Toolbox's direct children, grep the command
19+
* and if nothing is found we continue with the processes children. Toolbox spawns an ssh command
20+
* as a separate command which in turns spawns another child for the proxy command.
21+
*/
22+
fun findByWorkspaceAndAgent(ws: Workspace, agent: WorkspaceAgent): Long? {
23+
val stack = ArrayDeque<ProcessHandle>(ProcessHandle.current().children().toList())
24+
while (stack.isNotEmpty()) {
25+
val processHandle = stack.removeLast()
26+
val cmdLine = processHandle.info().commandLine().getOrNull()
27+
ctx.logger.debug("SSH command PID: ${processHandle.pid()} Command: $cmdLine")
28+
if (cmdLine != null && cmdLine.isSshCommandFor(ws, agent)) {
29+
ctx.logger.debug("SSH command with PID: ${processHandle.pid()} and Command: $cmdLine matches ${ws.name}.${agent.name}")
30+
return processHandle.pid()
31+
} else {
32+
stack.addAll(processHandle.children().toList())
33+
}
34+
}
35+
return null
36+
}
37+
38+
private fun String.isSshCommandFor(ws: Workspace, agent: WorkspaceAgent): Boolean {
39+
// usage-app is present only in the ProxyCommand
40+
return !this.contains("--usage-app=jetbrains") && this.contains("${ws.name}.${agent.name}")
41+
}
42+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.coder.toolbox.sdk.v2.models
2+
3+
import com.squareup.moshi.Json
4+
import com.squareup.moshi.JsonClass
5+
import java.text.DecimalFormat
6+
7+
private val formatter = DecimalFormat("#.00")
8+
9+
/**
10+
* Coder ssh network metrics. All properties are optional
11+
* because Coder Connect only populates `using_coder_connect`
12+
* while p2p doesn't populate this property.
13+
*/
14+
@JsonClass(generateAdapter = true)
15+
data class NetworkMetrics(
16+
@Json(name = "p2p")
17+
val p2p: Boolean?,
18+
19+
@Json(name = "latency")
20+
val latency: Double?,
21+
22+
@Json(name = "preferred_derp")
23+
val preferredDerp: String?,
24+
25+
@Json(name = "derp_latency")
26+
val derpLatency: Map<String, Double>?,
27+
28+
@Json(name = "upload_bytes_sec")
29+
val uploadBytesSec: Long?,
30+
31+
@Json(name = "download_bytes_sec")
32+
val downloadBytesSec: Long?,
33+
34+
@Json(name = "using_coder_connect")
35+
val usingCoderConnect: Boolean?
36+
) {
37+
fun toPretty(): String {
38+
if (usingCoderConnect == true) {
39+
return "You're connected using Coder Connect"
40+
}
41+
return if (p2p == true) {
42+
"Direct (${formatter.format(latency)}ms). You're connected peer-to-peer"
43+
} else {
44+
val derpLatency = derpLatency!![preferredDerp]
45+
val workspaceLatency = latency!!.minus(derpLatency!!)
46+
"You ↔ $preferredDerp (${formatter.format(derpLatency)}ms) ↔ Workspace (${formatter.format(workspaceLatency)}ms). You are connected through a relay"
47+
}
48+
}
49+
}

src/main/kotlin/com/coder/toolbox/settings/ReadOnlyCoderSettings.kt

+6
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,12 @@ interface ReadOnlyCoderSettings {
110110
*/
111111
val sshConfigOptions: String?
112112

113+
114+
/**
115+
* The path where network information for SSH hosts are stored
116+
*/
117+
val networkInfoDir: String
118+
113119
/**
114120
* The default URL to show in the connection window.
115121
*/

src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt

+9
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ class CoderSettingsStore(
6565
override val sshLogDirectory: String? get() = store[SSH_LOG_DIR]
6666
override val sshConfigOptions: String?
6767
get() = store[SSH_CONFIG_OPTIONS].takeUnless { it.isNullOrEmpty() } ?: env.get(CODER_SSH_CONFIG_OPTIONS)
68+
override val networkInfoDir: String
69+
get() = store[NETWORK_INFO_DIR].takeUnless { it.isNullOrEmpty() } ?: getDefaultGlobalDataDir()
70+
.resolve("ssh-network-metrics")
71+
.normalize()
72+
.toString()
6873

6974
/**
7075
* The default URL to show in the connection window.
@@ -232,6 +237,10 @@ class CoderSettingsStore(
232237
store[SSH_LOG_DIR] = path
233238
}
234239

240+
fun updateNetworkInfoDir(path: String) {
241+
store[NETWORK_INFO_DIR] = path
242+
}
243+
235244
fun updateSshConfigOptions(options: String) {
236245
store[SSH_CONFIG_OPTIONS] = options
237246
}

src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt

+2
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,5 @@ internal const val SSH_LOG_DIR = "sshLogDir"
3838

3939
internal const val SSH_CONFIG_OPTIONS = "sshConfigOptions"
4040

41+
internal const val NETWORK_INFO_DIR = "networkInfoDir"
42+

src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt

+4
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel<
5656
TextField(context.i18n.ptrl("Extra SSH options"), settings.sshConfigOptions ?: "", TextType.General)
5757
private val sshLogDirField =
5858
TextField(context.i18n.ptrl("SSH proxy log directory"), settings.sshLogDirectory ?: "", TextType.General)
59+
private val networkInfoDirField =
60+
TextField(context.i18n.ptrl("SSH network metrics directory"), settings.networkInfoDir, TextType.General)
5961

6062

6163
override val fields: StateFlow<List<UiField>> = MutableStateFlow(
@@ -73,6 +75,7 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel<
7375
disableAutostartField,
7476
enableSshWildCardConfig,
7577
sshLogDirField,
78+
networkInfoDirField,
7679
sshExtraArgs,
7780
)
7881
)
@@ -104,6 +107,7 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel<
104107
}
105108
}
106109
context.settingsStore.updateSshLogDir(sshLogDirField.textState.value)
110+
context.settingsStore.updateNetworkInfoDir(networkInfoDirField.textState.value)
107111
context.settingsStore.updateSshConfigOptions(sshExtraArgs.textState.value)
108112
}
109113
)

src/main/resources/localization/defaultMessages.po

+6
Original file line numberDiff line numberDiff line change
@@ -128,4 +128,10 @@ msgid "Extra SSH options"
128128
msgstr ""
129129

130130
msgid "SSH proxy log directory"
131+
msgstr ""
132+
133+
msgid "SSH network metrics directory"
134+
msgstr ""
135+
136+
msgid "Network Status"
131137
msgstr ""

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

+10-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import com.coder.toolbox.store.DISABLE_AUTOSTART
1818
import com.coder.toolbox.store.ENABLE_BINARY_DIR_FALLBACK
1919
import com.coder.toolbox.store.ENABLE_DOWNLOADS
2020
import com.coder.toolbox.store.HEADER_COMMAND
21+
import com.coder.toolbox.store.NETWORK_INFO_DIR
2122
import com.coder.toolbox.store.SSH_CONFIG_OPTIONS
2223
import com.coder.toolbox.store.SSH_CONFIG_PATH
2324
import com.coder.toolbox.store.SSH_LOG_DIR
@@ -510,7 +511,10 @@ internal class CoderCLIManagerTest {
510511
HEADER_COMMAND to it.headerCommand,
511512
SSH_CONFIG_PATH to tmpdir.resolve(it.input + "_to_" + it.output + ".conf").toString(),
512513
SSH_CONFIG_OPTIONS to it.extraConfig,
513-
SSH_LOG_DIR to (it.sshLogDirectory?.toString() ?: "")
514+
SSH_LOG_DIR to (it.sshLogDirectory?.toString() ?: ""),
515+
NETWORK_INFO_DIR to tmpdir.parent.resolve("coder-toolbox")
516+
.resolve("ssh-network-metrics")
517+
.normalize().toString()
514518
),
515519
env = it.env,
516520
context.logger,
@@ -531,6 +535,7 @@ internal class CoderCLIManagerTest {
531535

532536
// Output is the configuration we expect to have after configuring.
533537
val coderConfigPath = ccm.localBinaryPath.parent.resolve("config")
538+
val networkMetricsPath = tmpdir.parent.resolve("coder-toolbox").resolve("ssh-network-metrics")
534539
val expectedConf =
535540
Path.of("src/test/resources/fixtures/outputs/").resolve(it.output + ".conf").toFile().readText()
536541
.replace(newlineRe, System.lineSeparator())
@@ -539,6 +544,10 @@ internal class CoderCLIManagerTest {
539544
"/tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64",
540545
escape(ccm.localBinaryPath.toString())
541546
)
547+
.replace(
548+
"/tmp/coder-toolbox/ssh-network-metrics",
549+
escape(networkMetricsPath.toString())
550+
)
542551
.let { conf ->
543552
if (it.sshLogDirectory != null) {
544553
conf.replace("/tmp/coder-toolbox/test.coder.invalid/logs", it.sshLogDirectory.toString())

0 commit comments

Comments
 (0)