Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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

- improved diagnose support

## 0.6.3 - 2025-08-25

### Added
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
version=0.6.3
version=0.6.4
group=com.coder.toolbox
name=coder-toolbox
25 changes: 13 additions & 12 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ 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.CoroutineName
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
Expand Down Expand Up @@ -81,7 +82,7 @@ class CoderRemoteEnvironment(
val actions = mutableListOf<Action>()
if (wsRawStatus.canStop()) {
actions.add(Action(context.i18n.ptrl("Open web terminal")) {
context.cs.launch {
context.cs.launch(CoroutineName("Open Web Terminal Action")) {
context.desktop.browse(client.url.withPath("/${workspace.ownerName}/$name/terminal").toString()) {
context.ui.showErrorInfoPopup(it)
}
Expand All @@ -90,7 +91,7 @@ class CoderRemoteEnvironment(
}
actions.add(
Action(context.i18n.ptrl("Open in dashboard")) {
context.cs.launch {
context.cs.launch(CoroutineName("Open in Dashboard Action")) {
context.desktop.browse(
client.url.withPath("/@${workspace.ownerName}/${workspace.name}").toString()
) {
Expand All @@ -100,7 +101,7 @@ class CoderRemoteEnvironment(
})

actions.add(Action(context.i18n.ptrl("View template")) {
context.cs.launch {
context.cs.launch(CoroutineName("View Template Action")) {
context.desktop.browse(client.url.withPath("/templates/${workspace.templateName}").toString()) {
context.ui.showErrorInfoPopup(it)
}
Expand All @@ -110,14 +111,14 @@ class CoderRemoteEnvironment(
if (wsRawStatus.canStart()) {
if (workspace.outdated) {
actions.add(Action(context.i18n.ptrl("Update and start")) {
context.cs.launch {
context.cs.launch(CoroutineName("Update and Start Action")) {
val build = client.updateWorkspace(workspace)
update(workspace.copy(latestBuild = build), agent)
}
})
} else {
actions.add(Action(context.i18n.ptrl("Start")) {
context.cs.launch {
context.cs.launch(CoroutineName("Start Action")) {
val build = client.startWorkspace(workspace)
update(workspace.copy(latestBuild = build), agent)

Expand All @@ -128,14 +129,14 @@ class CoderRemoteEnvironment(
if (wsRawStatus.canStop()) {
if (workspace.outdated) {
actions.add(Action(context.i18n.ptrl("Update and restart")) {
context.cs.launch {
context.cs.launch(CoroutineName("Update and Restart Action")) {
val build = client.updateWorkspace(workspace)
update(workspace.copy(latestBuild = build), agent)
}
})
}
actions.add(Action(context.i18n.ptrl("Stop")) {
context.cs.launch {
context.cs.launch(CoroutineName("Stop Action")) {
tryStopSshConnection()

val build = client.stopWorkspace(workspace)
Expand Down Expand Up @@ -169,7 +170,7 @@ class CoderRemoteEnvironment(
pollJob = pollNetworkMetrics()
}

private fun pollNetworkMetrics(): Job = context.cs.launch {
private fun pollNetworkMetrics(): Job = context.cs.launch(CoroutineName("Network Metrics Poller")) {
context.logger.info("Starting the network metrics poll job for $id")
while (isActive) {
context.logger.debug("Searching SSH command's PID for workspace $id...")
Expand Down Expand Up @@ -227,7 +228,7 @@ class CoderRemoteEnvironment(
actionsList.update {
getAvailableActions()
}
context.cs.launch {
context.cs.launch(CoroutineName("Workspace Status Updater")) {
state.update {
wsRawStatus.toRemoteEnvironmentState(context)
}
Expand Down Expand Up @@ -262,7 +263,7 @@ class CoderRemoteEnvironment(
*/
fun startSshConnection(): Boolean {
if (wsRawStatus.ready() && !isConnected.value) {
context.cs.launch {
context.cs.launch(CoroutineName("SSH Connection Trigger")) {
connectionRequest.update {
true
}
Expand All @@ -284,7 +285,7 @@ class CoderRemoteEnvironment(
}

override val deleteActionFlow: StateFlow<(() -> Unit)?> = MutableStateFlow {
context.cs.launch {
context.cs.launch(CoroutineName("Delete Workspace Action")) {
try {
client.removeWorkspace(workspace)
// mark the env as deleting otherwise we will have to
Expand All @@ -293,7 +294,7 @@ class CoderRemoteEnvironment(
WorkspaceAndAgentStatus.DELETING.toRemoteEnvironmentState(context)
}

context.cs.launch {
context.cs.launch(CoroutineName("Workspace Deletion Poller")) {
withTimeout(5.minutes) {
var workspaceStillExists = true
while (context.cs.isActive && workspaceStillExists) {
Expand Down
190 changes: 98 additions & 92 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import com.jetbrains.toolbox.api.remoteDev.RemoteProvider
import com.jetbrains.toolbox.api.ui.actions.ActionDelimiter
import com.jetbrains.toolbox.api.ui.actions.ActionDescription
import com.jetbrains.toolbox.api.ui.components.UiPage
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
Expand Down Expand Up @@ -87,113 +88,114 @@ class CoderRemoteProvider(
* workspace is added, reconfigure SSH using the provided cli (including the
* first time).
*/
private fun poll(client: CoderRestClient, cli: CoderCLIManager): Job = context.cs.launch {
var lastPollTime = TimeSource.Monotonic.markNow()
while (isActive) {
try {
context.logger.debug("Fetching workspace agents from ${client.url}")
val resolvedEnvironments = client.workspaces().flatMap { ws ->
// Agents are not included in workspaces that are off
// so fetch them separately.
when (ws.latestBuild.status) {
WorkspaceStatus.RUNNING -> ws.latestBuild.resources
else -> emptyList()
}.ifEmpty {
client.resources(ws)
}.flatMap { resource ->
resource.agents?.distinctBy {
// There can be duplicates with coder_agent_instance.
// TODO: Can we just choose one or do they hold
// different information?
it.name
}?.map { agent ->
// If we have an environment already, update that.
val env = CoderRemoteEnvironment(context, client, cli, ws, agent)
lastEnvironments.firstOrNull { it == env }?.let {
it.update(ws, agent)
it
} ?: env
} ?: emptyList()
}
}.toSet()
private fun poll(client: CoderRestClient, cli: CoderCLIManager): Job =
context.cs.launch(CoroutineName("Workspace Poller")) {
var lastPollTime = TimeSource.Monotonic.markNow()
while (isActive) {
try {
context.logger.debug("Fetching workspace agents from ${client.url}")
val resolvedEnvironments = client.workspaces().flatMap { ws ->
// Agents are not included in workspaces that are off
// so fetch them separately.
when (ws.latestBuild.status) {
WorkspaceStatus.RUNNING -> ws.latestBuild.resources
else -> emptyList()
}.ifEmpty {
client.resources(ws)
}.flatMap { resource ->
resource.agents?.distinctBy {
// There can be duplicates with coder_agent_instance.
// TODO: Can we just choose one or do they hold
// different information?
it.name
}?.map { agent ->
// If we have an environment already, update that.
val env = CoderRemoteEnvironment(context, client, cli, ws, agent)
lastEnvironments.firstOrNull { it == env }?.let {
it.update(ws, agent)
it
} ?: env
} ?: emptyList()
}
}.toSet()

// In case we logged out while running the query.
if (!isActive) {
return@launch
}
// In case we logged out while running the query.
if (!isActive) {
return@launch
}

// Reconfigure if environments changed.
if (lastEnvironments.size != resolvedEnvironments.size || lastEnvironments != resolvedEnvironments) {
context.logger.info("Workspaces have changed, reconfiguring CLI: $resolvedEnvironments")
cli.configSsh(resolvedEnvironments.map { it.asPairOfWorkspaceAndAgent() }.toSet())
}
// Reconfigure if environments changed.
if (lastEnvironments.size != resolvedEnvironments.size || lastEnvironments != resolvedEnvironments) {
context.logger.info("Workspaces have changed, reconfiguring CLI: $resolvedEnvironments")
cli.configSsh(resolvedEnvironments.map { it.asPairOfWorkspaceAndAgent() }.toSet())
}

environments.update {
LoadableState.Value(resolvedEnvironments.toList())
}
if (!isInitialized.value) {
context.logger.info("Environments for ${client.url} are now initialized")
isInitialized.update {
true
environments.update {
LoadableState.Value(resolvedEnvironments.toList())
}
if (!isInitialized.value) {
context.logger.info("Environments for ${client.url} are now initialized")
isInitialized.update {
true
}
}
lastEnvironments.apply {
clear()
addAll(resolvedEnvironments.sortedBy { it.id })
}
}
lastEnvironments.apply {
clear()
addAll(resolvedEnvironments.sortedBy { it.id })
}

if (WorkspaceConnectionManager.shouldEstablishWorkspaceConnections) {
WorkspaceConnectionManager.allConnected().forEach { wsId ->
val env = lastEnvironments.firstOrNull() { it.id == wsId }
if (env != null && !env.isConnected()) {
context.logger.info("Establishing lost SSH connection for workspace with id $wsId")
if (!env.startSshConnection()) {
context.logger.info("Can't establish lost SSH connection for workspace with id $wsId")
if (WorkspaceConnectionManager.shouldEstablishWorkspaceConnections) {
WorkspaceConnectionManager.allConnected().forEach { wsId ->
val env = lastEnvironments.firstOrNull() { it.id == wsId }
if (env != null && !env.isConnected()) {
context.logger.info("Establishing lost SSH connection for workspace with id $wsId")
if (!env.startSshConnection()) {
context.logger.info("Can't establish lost SSH connection for workspace with id $wsId")
}
}
}
WorkspaceConnectionManager.reset()
}
WorkspaceConnectionManager.reset()
}

WorkspaceConnectionManager.collectStatuses(lastEnvironments)
} catch (_: CancellationException) {
context.logger.debug("${client.url} polling loop canceled")
break
} catch (ex: Exception) {
val elapsed = lastPollTime.elapsedNow()
if (elapsed > POLL_INTERVAL * 2) {
context.logger.info("wake-up from an OS sleep was detected")
} else {
context.logger.error(ex, "workspace polling error encountered")
if (ex is APIResponseException && ex.isTokenExpired) {
WorkspaceConnectionManager.shouldEstablishWorkspaceConnections = true
close()
context.envPageManager.showPluginEnvironmentsPage()
errorBuffer.add(ex)
break
WorkspaceConnectionManager.collectStatuses(lastEnvironments)
} catch (_: CancellationException) {
context.logger.debug("${client.url} polling loop canceled")
break
} catch (ex: Exception) {
val elapsed = lastPollTime.elapsedNow()
if (elapsed > POLL_INTERVAL * 2) {
context.logger.info("wake-up from an OS sleep was detected")
} else {
context.logger.error(ex, "workspace polling error encountered")
if (ex is APIResponseException && ex.isTokenExpired) {
WorkspaceConnectionManager.shouldEstablishWorkspaceConnections = true
close()
context.envPageManager.showPluginEnvironmentsPage()
errorBuffer.add(ex)
break
}
}
}
}

select {
onTimeout(POLL_INTERVAL) {
context.logger.debug("workspace poller waked up by the $POLL_INTERVAL timeout")
}
triggerSshConfig.onReceive { shouldTrigger ->
if (shouldTrigger) {
context.logger.debug("workspace poller waked up because it should reconfigure the ssh configurations")
cli.configSsh(lastEnvironments.map { it.asPairOfWorkspaceAndAgent() }.toSet())
select {
onTimeout(POLL_INTERVAL) {
context.logger.debug("workspace poller waked up by the $POLL_INTERVAL timeout")
}
}
triggerProviderVisible.onReceive { isCoderProviderVisible ->
if (isCoderProviderVisible) {
context.logger.debug("workspace poller waked up by Coder Toolbox which is currently visible, fetching latest workspace statuses")
triggerSshConfig.onReceive { shouldTrigger ->
if (shouldTrigger) {
context.logger.debug("workspace poller waked up because it should reconfigure the ssh configurations")
cli.configSsh(lastEnvironments.map { it.asPairOfWorkspaceAndAgent() }.toSet())
}
}
triggerProviderVisible.onReceive { isCoderProviderVisible ->
if (isCoderProviderVisible) {
context.logger.debug("workspace poller waked up by Coder Toolbox which is currently visible, fetching latest workspace statuses")
}
}
}
lastPollTime = TimeSource.Monotonic.markNow()
}
lastPollTime = TimeSource.Monotonic.markNow()
}
}

/**
* Stop polling, clear the client and environments, then go back to the
Expand Down Expand Up @@ -221,7 +223,7 @@ class CoderRemoteProvider(
override val additionalPluginActions: StateFlow<List<ActionDescription>> = MutableStateFlow(
listOf(
Action(context.i18n.ptrl("Create workspace")) {
context.cs.launch {
context.cs.launch(CoroutineName("Create Workspace Action")) {
context.desktop.browse(client?.url?.withPath("/templates").toString()) {
context.ui.showErrorInfoPopup(it)
}
Expand Down Expand Up @@ -299,7 +301,7 @@ class CoderRemoteProvider(
visibility
}
if (visibility.providerVisible) {
context.cs.launch {
context.cs.launch(CoroutineName("Notify Plugin Visibility")) {
triggerProviderVisible.send(true)
}
}
Expand Down Expand Up @@ -396,11 +398,15 @@ class CoderRemoteProvider(
context.secrets.lastDeploymentURL = client.url.toString()
context.secrets.lastToken = client.token ?: ""
context.secrets.storeTokenFor(client.url, context.secrets.lastToken)
context.logger.info("Deployment URL and token were stored and will be available for automatic connection")
this.client = client
pollJob?.cancel()
context.logger.info("Previous poll job was canceled")
environments.showLoadingMessage()
coderHeaderPage.setTitle(context.i18n.pnotr(client.url.toString()))
context.logger.info("Displaying ${client.url} in the UI")
pollJob = poll(client, cli)
context.logger.info("Workspace poller job created with reference $pollJob")
context.envPageManager.showPluginEnvironmentsPage()
}

Expand Down
1 change: 1 addition & 0 deletions src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ class CoderCLIManager(
) {
context.logger.info("Configuring SSH config at ${context.settingsStore.sshConfigPath}")
writeSSHConfig(modifySSHConfig(readSSHConfig(), wsWithAgents, feats))
context.logger.info("Finished configuring SSH config")
}

/**
Expand Down
Loading
Loading