Skip to content
Merged
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Added

- render network status in the Settings tab, under `Additional environment information` section.

## 0.2.1 - 2025-05-05

### Changed
Expand Down
56 changes: 54 additions & 2 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ package com.coder.toolbox

import com.coder.toolbox.browser.BrowserUtil
import com.coder.toolbox.cli.CoderCLIManager
import com.coder.toolbox.cli.SshCommandProcessHandle
import com.coder.toolbox.models.WorkspaceAndAgentStatus
import com.coder.toolbox.sdk.CoderRestClient
import com.coder.toolbox.sdk.ex.APIResponseException
import com.coder.toolbox.sdk.v2.models.NetworkMetrics
import com.coder.toolbox.sdk.v2.models.Workspace
import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
import com.coder.toolbox.util.waitForFalseWithTimeout
import com.coder.toolbox.util.withPath
import com.coder.toolbox.views.Action
import com.coder.toolbox.views.EnvironmentView
import com.jetbrains.toolbox.api.localization.LocalizableString
import com.jetbrains.toolbox.api.remoteDev.AfterDisconnectHook
import com.jetbrains.toolbox.api.remoteDev.BeforeConnectionHook
import com.jetbrains.toolbox.api.remoteDev.DeleteEnvironmentConfirmationParams
Expand All @@ -20,15 +23,21 @@ import com.jetbrains.toolbox.api.remoteDev.environments.EnvironmentContentsView
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentDescription
import com.jetbrains.toolbox.api.remoteDev.states.RemoteEnvironmentState
import com.jetbrains.toolbox.api.ui.actions.ActionDescription
import com.squareup.moshi.Moshi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import java.io.File
import java.nio.file.Path
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds

private val POLL_INTERVAL = 5.seconds

/**
* Represents an agent and workspace combination.
*
Expand All @@ -44,17 +53,20 @@ class CoderRemoteEnvironment(
private var wsRawStatus = WorkspaceAndAgentStatus.from(workspace, agent)

override var name: String = "${workspace.name}.${agent.name}"

private var isConnected: MutableStateFlow<Boolean> = MutableStateFlow(false)
override val connectionRequest: MutableStateFlow<Boolean> = MutableStateFlow(false)

override val state: MutableStateFlow<RemoteEnvironmentState> =
MutableStateFlow(wsRawStatus.toRemoteEnvironmentState(context))
override val description: MutableStateFlow<EnvironmentDescription> =
MutableStateFlow(EnvironmentDescription.General(context.i18n.pnotr(workspace.templateDisplayName)))

override val additionalEnvironmentInformation: MutableMap<LocalizableString, String> = mutableMapOf()
override val actionsList: MutableStateFlow<List<ActionDescription>> = MutableStateFlow(getAvailableActions())

private val networkMetricsMarshaller = Moshi.Builder().build().adapter(NetworkMetrics::class.java)
private val proxyCommandHandle = SshCommandProcessHandle(context)
private var pollJob: Job? = null

fun asPairOfWorkspaceAndAgent(): Pair<Workspace, WorkspaceAgent> = Pair(workspace, agent)

private fun getAvailableActions(): List<ActionDescription> {
Expand Down Expand Up @@ -141,9 +153,49 @@ class CoderRemoteEnvironment(
override fun beforeConnection() {
context.logger.info("Connecting to $id...")
isConnected.update { true }
pollJob = pollNetworkMetrics()
}

private fun pollNetworkMetrics(): Job = context.cs.launch {
context.logger.info("Starting the network metrics poll job for $id")
while (isActive) {
context.logger.debug("Searching SSH command's PID for workspace $id...")
val pid = proxyCommandHandle.findByWorkspaceAndAgent(workspace, agent)
if (pid == null) {
context.logger.debug("No SSH command PID was found for workspace $id")
delay(POLL_INTERVAL)
continue
}

val metricsFile = Path.of(context.settingsStore.networkInfoDir, "$pid.json").toFile()
if (metricsFile.doesNotExists()) {
context.logger.debug("No metrics file found at ${metricsFile.absolutePath} for $id")
delay(POLL_INTERVAL)
continue
}
context.logger.debug("Loading metrics from ${metricsFile.absolutePath} for $id")
try {
val metrics = networkMetricsMarshaller.fromJson(metricsFile.readText())
if (metrics == null) {
return@launch
}
context.logger.debug("$id metrics: $metrics")
additionalEnvironmentInformation.put(context.i18n.ptrl("Network Status"), metrics.toPretty())
} catch (e: Exception) {
context.logger.error(
e,
"Error encountered while trying to load network metrics from ${metricsFile.absolutePath} for $id"
)
}
delay(POLL_INTERVAL)
}
}

private fun File.doesNotExists(): Boolean = !this.exists()

override fun afterDisconnect() {
context.logger.info("Stopping the network metrics poll job for $id")
pollJob?.cancel()
this.connectionRequest.update { false }
isConnected.update { false }
context.logger.info("Disconnected from $id")
Expand Down
3 changes: 1 addition & 2 deletions src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -271,14 +271,13 @@ class CoderCLIManager(
"ssh",
"--stdio",
if (settings.disableAutostart && feats.disableAutostart) "--disable-autostart" else null,
"--network-info-dir ${escape(settings.networkInfoDir)}"
)
val proxyArgs = baseArgs + listOfNotNull(
if (!settings.sshLogDirectory.isNullOrBlank()) "--log-dir" else null,
if (!settings.sshLogDirectory.isNullOrBlank()) escape(settings.sshLogDirectory!!) else null,
if (feats.reportWorkspaceUsage) "--usage-app=jetbrains" else null,
)
val backgroundProxyArgs =
baseArgs + listOfNotNull(if (feats.reportWorkspaceUsage) "--usage-app=disable" else null)
val extraConfig =
if (!settings.sshConfigOptions.isNullOrBlank()) {
"\n" + settings.sshConfigOptions!!.prependIndent(" ")
Expand Down
42 changes: 42 additions & 0 deletions src/main/kotlin/com/coder/toolbox/cli/SshCommandProcessHandle.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.coder.toolbox.cli

import com.coder.toolbox.CoderToolboxContext
import com.coder.toolbox.sdk.v2.models.Workspace
import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
import kotlin.jvm.optionals.getOrNull

/**
* Identifies the PID for the SSH Coder command spawned by Toolbox.
*/
class SshCommandProcessHandle(private val ctx: CoderToolboxContext) {

/**
* Finds the PID of a Coder (not the proxy command) ssh cmd associated with the specified workspace and agent.
* Null is returned when no ssh command process was found.
*
* Implementation Notes:
* An iterative DFS approach where we start with Toolbox's direct children, grep the command
* and if nothing is found we continue with the processes children. Toolbox spawns an ssh command
* as a separate command which in turns spawns another child for the proxy command.
*/
fun findByWorkspaceAndAgent(ws: Workspace, agent: WorkspaceAgent): Long? {
val stack = ArrayDeque<ProcessHandle>(ProcessHandle.current().children().toList())
while (stack.isNotEmpty()) {
val processHandle = stack.removeLast()
val cmdLine = processHandle.info().commandLine().getOrNull()
ctx.logger.debug("SSH command PID: ${processHandle.pid()} Command: $cmdLine")
if (cmdLine != null && cmdLine.isSshCommandFor(ws, agent)) {
ctx.logger.debug("SSH command with PID: ${processHandle.pid()} and Command: $cmdLine matches ${ws.name}.${agent.name}")
return processHandle.pid()
} else {
stack.addAll(processHandle.children().toList())
}
}
return null
}

private fun String.isSshCommandFor(ws: Workspace, agent: WorkspaceAgent): Boolean {
// usage-app is present only in the ProxyCommand
return !this.contains("--usage-app=jetbrains") && this.contains("${ws.name}.${agent.name}")
}
}
49 changes: 49 additions & 0 deletions src/main/kotlin/com/coder/toolbox/sdk/v2/models/NetworkMetrics.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.coder.toolbox.sdk.v2.models

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.text.DecimalFormat

private val formatter = DecimalFormat("#.00")

/**
* Coder ssh network metrics. All properties are optional
* because Coder Connect only populates `using_coder_connect`
* while p2p doesn't populate this property.
*/
@JsonClass(generateAdapter = true)
data class NetworkMetrics(
@Json(name = "p2p")
val p2p: Boolean?,

@Json(name = "latency")
val latency: Double?,

@Json(name = "preferred_derp")
val preferredDerp: String?,

@Json(name = "derp_latency")
val derpLatency: Map<String, Double>?,

@Json(name = "upload_bytes_sec")
val uploadBytesSec: Long?,

@Json(name = "download_bytes_sec")
val downloadBytesSec: Long?,

@Json(name = "using_coder_connect")
val usingCoderConnect: Boolean?
) {
fun toPretty(): String {
if (usingCoderConnect == true) {
return "You're connected using Coder Connect"
}
return if (p2p == true) {
"Direct (${formatter.format(latency)}ms). You're connected peer-to-peer"
} else {
val derpLatency = derpLatency!![preferredDerp]
val workspaceLatency = latency!!.minus(derpLatency!!)
"You ↔ $preferredDerp (${formatter.format(derpLatency)}ms) ↔ Workspace (${formatter.format(workspaceLatency)}ms). You are connected through a relay"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ interface ReadOnlyCoderSettings {
*/
val sshConfigOptions: String?


/**
* The path where network information for SSH hosts are stored
*/
val networkInfoDir: String

/**
* The default URL to show in the connection window.
*/
Expand Down
9 changes: 9 additions & 0 deletions src/main/kotlin/com/coder/toolbox/store/CoderSettingsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ class CoderSettingsStore(
override val sshLogDirectory: String? get() = store[SSH_LOG_DIR]
override val sshConfigOptions: String?
get() = store[SSH_CONFIG_OPTIONS].takeUnless { it.isNullOrEmpty() } ?: env.get(CODER_SSH_CONFIG_OPTIONS)
override val networkInfoDir: String
get() = store[NETWORK_INFO_DIR].takeUnless { it.isNullOrEmpty() } ?: getDefaultGlobalDataDir()
.resolve("ssh-network-metrics")
.normalize()
.toString()

/**
* The default URL to show in the connection window.
Expand Down Expand Up @@ -232,6 +237,10 @@ class CoderSettingsStore(
store[SSH_LOG_DIR] = path
}

fun updateNetworkInfoDir(path: String) {
store[NETWORK_INFO_DIR] = path
}

fun updateSshConfigOptions(options: String) {
store[SSH_CONFIG_OPTIONS] = options
}
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/com/coder/toolbox/store/StoreKeys.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,5 @@ internal const val SSH_LOG_DIR = "sshLogDir"

internal const val SSH_CONFIG_OPTIONS = "sshConfigOptions"

internal const val NETWORK_INFO_DIR = "networkInfoDir"

4 changes: 4 additions & 0 deletions src/main/kotlin/com/coder/toolbox/views/CoderSettingsPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel<
TextField(context.i18n.ptrl("Extra SSH options"), settings.sshConfigOptions ?: "", TextType.General)
private val sshLogDirField =
TextField(context.i18n.ptrl("SSH proxy log directory"), settings.sshLogDirectory ?: "", TextType.General)
private val networkInfoDirField =
TextField(context.i18n.ptrl("SSH network metrics directory"), settings.networkInfoDir, TextType.General)


override val fields: StateFlow<List<UiField>> = MutableStateFlow(
Expand All @@ -73,6 +75,7 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel<
disableAutostartField,
enableSshWildCardConfig,
sshLogDirField,
networkInfoDirField,
sshExtraArgs,
)
)
Expand Down Expand Up @@ -104,6 +107,7 @@ class CoderSettingsPage(context: CoderToolboxContext, triggerSshConfig: Channel<
}
}
context.settingsStore.updateSshLogDir(sshLogDirField.textState.value)
context.settingsStore.updateNetworkInfoDir(networkInfoDirField.textState.value)
context.settingsStore.updateSshConfigOptions(sshExtraArgs.textState.value)
}
)
Expand Down
6 changes: 6 additions & 0 deletions src/main/resources/localization/defaultMessages.po
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,10 @@ msgid "Extra SSH options"
msgstr ""

msgid "SSH proxy log directory"
msgstr ""

msgid "SSH network metrics directory"
msgstr ""

msgid "Network Status"
msgstr ""
11 changes: 10 additions & 1 deletion src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import com.coder.toolbox.store.DISABLE_AUTOSTART
import com.coder.toolbox.store.ENABLE_BINARY_DIR_FALLBACK
import com.coder.toolbox.store.ENABLE_DOWNLOADS
import com.coder.toolbox.store.HEADER_COMMAND
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
Expand Down Expand Up @@ -510,7 +511,10 @@ internal class CoderCLIManagerTest {
HEADER_COMMAND to it.headerCommand,
SSH_CONFIG_PATH to tmpdir.resolve(it.input + "_to_" + it.output + ".conf").toString(),
SSH_CONFIG_OPTIONS to it.extraConfig,
SSH_LOG_DIR to (it.sshLogDirectory?.toString() ?: "")
SSH_LOG_DIR to (it.sshLogDirectory?.toString() ?: ""),
NETWORK_INFO_DIR to tmpdir.parent.resolve("coder-toolbox")
.resolve("ssh-network-metrics")
.normalize().toString()
),
env = it.env,
context.logger,
Expand All @@ -531,6 +535,7 @@ internal class CoderCLIManagerTest {

// Output is the configuration we expect to have after configuring.
val coderConfigPath = ccm.localBinaryPath.parent.resolve("config")
val networkMetricsPath = tmpdir.parent.resolve("coder-toolbox").resolve("ssh-network-metrics")
val expectedConf =
Path.of("src/test/resources/fixtures/outputs/").resolve(it.output + ".conf").toFile().readText()
.replace(newlineRe, System.lineSeparator())
Expand All @@ -539,6 +544,10 @@ internal class CoderCLIManagerTest {
"/tmp/coder-toolbox/test.coder.invalid/coder-linux-amd64",
escape(ccm.localBinaryPath.toString())
)
.replace(
"/tmp/coder-toolbox/ssh-network-metrics",
escape(networkMetricsPath.toString())
)
.let { conf ->
if (it.sshLogDirectory != null) {
conf.replace("/tmp/coder-toolbox/test.coder.invalid/logs", it.sshLogDirectory.toString())
Expand Down
Loading
Loading