Skip to content

Support for removing workspaces #10

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Feb 28, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
50 changes: 48 additions & 2 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.coder.toolbox
import com.coder.toolbox.browser.BrowserUtil
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.Workspace
import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
import com.coder.toolbox.util.withPath
Expand All @@ -13,9 +14,15 @@ import com.jetbrains.toolbox.api.remoteDev.AbstractRemoteProviderEnvironment
import com.jetbrains.toolbox.api.remoteDev.EnvironmentVisibilityState
import com.jetbrains.toolbox.api.remoteDev.environments.EnvironmentContentsView
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateConsumer
import com.jetbrains.toolbox.api.remoteDev.ui.EnvironmentUiPageManager
import com.jetbrains.toolbox.api.ui.ToolboxUi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds

/**
* Represents an agent and workspace combination.
Expand Down Expand Up @@ -71,7 +78,7 @@ class CoderRemoteEnvironment(
},
)
actionsList.add(
Action("Stop", enabled = { status.ready() || status.pending() }) {
Action("Stop", enabled = { status.canStop() }) {
val build = client.stopWorkspace(workspace)
workspace = workspace.copy(latestBuild = build)
update(workspace, agent)
Expand Down Expand Up @@ -128,7 +135,46 @@ class CoderRemoteEnvironment(
}

override fun onDelete() {
throw NotImplementedError()
cs.launch {
// TODO info and cancel pop-ups only appear on the main page where all environments are listed.
// However, #showSnackbar works on other pages. Until JetBrains fixes this issue we are going to use the snackbar
val shouldDelete = if (status.canStop()) {
ui.showOkCancelPopup(
"Delete running workspace?",
"Workspace will be closed and all the information in this workspace will be lost, including all files, unsaved changes and historical.",
"Delete",
"Cancel"
)
} else {
ui.showOkCancelPopup(
"Delete workspace?",
"All the information in this workspace will be lost, including all files, unsaved changes and historical.",
"Delete",
"Cancel"
)
}
if (shouldDelete) {
try {
client.removeWorkspace(workspace)
cs.launch {
withTimeout(5.minutes) {
var workspaceStillExists = true
while (cs.isActive && workspaceStillExists) {
if (status == WorkspaceAndAgentStatus.DELETING || status == WorkspaceAndAgentStatus.DELETED) {
workspaceStillExists = false
serviceLocator.getService(EnvironmentUiPageManager::class.java)
.showPluginEnvironmentsPage()
} else {
delay(1.seconds)
}
}
}
}
} catch (e: APIResponseException) {
ui.showErrorInfoPopup(e)
}
}
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.jetbrains.toolbox.api.core.ServiceLocator
import com.jetbrains.toolbox.api.core.ui.color.StateColor
import com.jetbrains.toolbox.api.remoteDev.states.CustomRemoteEnvironmentState
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateColorPalette
import com.jetbrains.toolbox.api.remoteDev.states.EnvironmentStateIcons
import com.jetbrains.toolbox.api.remoteDev.states.StandardRemoteEnvironmentState

/**
Expand Down Expand Up @@ -59,26 +60,34 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) {
* "disconnected" regardless of the label we give that status.
*/
fun toRemoteEnvironmentState(serviceLocator: ServiceLocator): CustomRemoteEnvironmentState {
val stateColor = getStateColor(serviceLocator)
return CustomRemoteEnvironmentState(
label,
stateColor,
getStateColor(serviceLocator),
ready(), // reachable
// TODO@JB: How does this work? Would like a spinner for pending states.
null, // iconId
getStateIcon()
)
}

private fun getStateColor(serviceLocator: ServiceLocator): StateColor {
val colorPalette = serviceLocator.getService(EnvironmentStateColorPalette::class.java)


return if (ready()) colorPalette.getColor(StandardRemoteEnvironmentState.Active)
else if (canStart()) colorPalette.getColor(StandardRemoteEnvironmentState.Failed)
else if (pending()) colorPalette.getColor(StandardRemoteEnvironmentState.Activating)
else if (this == DELETING) colorPalette.getColor(StandardRemoteEnvironmentState.Deleting)
else if (this == DELETED) colorPalette.getColor(StandardRemoteEnvironmentState.Deleted)
else colorPalette.getColor(StandardRemoteEnvironmentState.Unreachable)
}

private fun getStateIcon(): EnvironmentStateIcons {
return if (ready()) EnvironmentStateIcons.Active
else if (canStart()) EnvironmentStateIcons.Hibernated
else if (pending()) EnvironmentStateIcons.Connecting
else if (this == DELETING || this == DELETED) EnvironmentStateIcons.Offline
else EnvironmentStateIcons.NoIcon
}

/**
* Return true if the agent is in a connectable state.
*/
Expand Down Expand Up @@ -107,6 +116,11 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) {
fun canStart(): Boolean = listOf(STOPPED, FAILED, CANCELED)
.contains(this)

/**
* Return true if the workspace can be stopped.
*/
fun canStop(): Boolean = ready() || pending()

// We want to check that the workspace is `running`, the agent is
// `connected`, and the agent lifecycle state is `ready` to ensure the best
// possible scenario for attempting a connection.
Expand Down
14 changes: 12 additions & 2 deletions src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import retrofit2.converter.moshi.MoshiConverterFactory
import java.net.HttpURLConnection
import java.net.ProxySelector
import java.net.URL
import java.util.*
import java.util.UUID
import javax.net.ssl.X509TrustManager

/**
Expand Down Expand Up @@ -229,7 +229,6 @@ open class CoderRestClient(
}

/**
* @throws [APIResponseException].
*/
fun stopWorkspace(workspace: Workspace): WorkspaceBuild {
val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.STOP)
Expand All @@ -240,6 +239,17 @@ open class CoderRestClient(
return buildResponse.body()!!
}

/**
* @throws [APIResponseException] if issues are encountered during deletion
*/
fun removeWorkspace(workspace: Workspace) {
val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.DELETE, false)
val buildResponse = retroRestClient.createWorkspaceBuild(workspace.id, buildRequest).execute()
if (buildResponse.code() != HttpURLConnection.HTTP_CREATED) {
throw APIResponseException("delete workspace ${workspace.name}", url, buildResponse)
}
}

/**
* Start the workspace with the latest template version. Best practice is
* to STOP a workspace before doing an update if it is started.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import java.util.UUID
data class CreateWorkspaceBuildRequest(
// Use to update the workspace to a new template version.
@Json(name = "template_version_id") val templateVersionID: UUID?,
// Use to start and stop the workspace.
// Use to start, stop and delete the workspace.
@Json(name = "transition") val transition: WorkspaceTransition,
@Json(name = "orphan") var orphan: Boolean? = null
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
Expand All @@ -19,12 +20,13 @@ data class CreateWorkspaceBuildRequest(

if (templateVersionID != other.templateVersionID) return false
if (transition != other.transition) return false

if (orphan != other.orphan) return false
return true
}

override fun hashCode(): Int {
var result = templateVersionID?.hashCode() ?: 0
var result = orphan?.hashCode() ?: 0
result = 31 * result + (templateVersionID?.hashCode() ?: 0)
result = 31 * result + transition.hashCode()
return result
}
Expand Down