diff --git a/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentModel.kt b/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentModel.kt index 5ecd186c..6cfc103b 100644 --- a/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentModel.kt +++ b/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentModel.kt @@ -2,6 +2,7 @@ package com.coder.gateway.models import com.coder.gateway.sdk.Arch import com.coder.gateway.sdk.OS +import com.coder.gateway.sdk.v2.models.WorkspaceStatus import com.coder.gateway.sdk.v2.models.WorkspaceTransition import java.util.UUID import javax.swing.Icon @@ -15,11 +16,12 @@ data class WorkspaceAgentModel( val templateIconPath: String, var templateIcon: Icon?, val status: WorkspaceVersionStatus, - val agentStatus: WorkspaceAgentStatus, + val workspaceStatus: WorkspaceStatus, + val agentStatus: WorkspaceAndAgentStatus, val lastBuildTransition: WorkspaceTransition, val agentOS: OS?, val agentArch: Arch?, - val homeDirectory: String? + val homeDirectory: String?, ) { override fun equals(other: Any?): Boolean { if (this === other) return true diff --git a/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentStatus.kt b/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentStatus.kt deleted file mode 100644 index 44a71614..00000000 --- a/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentStatus.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.coder.gateway.models - -import com.coder.gateway.sdk.v2.models.ProvisionerJobStatus -import com.coder.gateway.sdk.v2.models.Workspace -import com.coder.gateway.sdk.v2.models.WorkspaceTransition -import com.intellij.ui.JBColor - -enum class WorkspaceAgentStatus(val label: String) { - QUEUED("◍ Queued"), STARTING("⦿ Starting"), STOPPING("◍ Stopping"), DELETING("⦸ Deleting"), - RUNNING("⦿ Running"), STOPPED("◍ Stopped"), DELETED("⦸ Deleted"), - CANCELING("◍ Canceling action"), CANCELED("◍ Canceled action"), FAILED("ⓧ Failed"); - - fun statusColor() = when (this) { - RUNNING -> JBColor.GREEN - FAILED -> JBColor.RED - else -> if (JBColor.isBright()) JBColor.LIGHT_GRAY else JBColor.DARK_GRAY - } - - companion object { - fun from(workspace: Workspace) = when (workspace.latestBuild.job.status) { - ProvisionerJobStatus.PENDING -> QUEUED - ProvisionerJobStatus.RUNNING -> when (workspace.latestBuild.transition) { - WorkspaceTransition.START -> STARTING - WorkspaceTransition.STOP -> STOPPING - WorkspaceTransition.DELETE -> DELETING - } - - ProvisionerJobStatus.SUCCEEDED -> when (workspace.latestBuild.transition) { - WorkspaceTransition.START -> RUNNING - WorkspaceTransition.STOP -> STOPPED - WorkspaceTransition.DELETE -> DELETED - } - - ProvisionerJobStatus.CANCELING -> CANCELING - ProvisionerJobStatus.CANCELED -> CANCELED - ProvisionerJobStatus.FAILED -> FAILED - } - - fun from(str: String) = WorkspaceAgentStatus.values().first { it.label.contains(str, true) } - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt b/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt new file mode 100644 index 00000000..2ffaa43b --- /dev/null +++ b/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt @@ -0,0 +1,106 @@ +package com.coder.gateway.models + +import com.coder.gateway.sdk.v2.models.Workspace +import com.coder.gateway.sdk.v2.models.WorkspaceAgent +import com.coder.gateway.sdk.v2.models.WorkspaceAgentLifecycleState +import com.coder.gateway.sdk.v2.models.WorkspaceAgentStatus +import com.coder.gateway.sdk.v2.models.WorkspaceStatus +import com.intellij.ui.JBColor + +/** + * WorkspaceAndAgentStatus represents the combined status of a single agent and + * its workspace (or just the workspace if there are no agents). + */ +enum class WorkspaceAndAgentStatus(val label: String, val description: String) { + // Workspace states. + QUEUED("◍ Queued", "The workspace is queueing to start."), + STARTING("⦿ Starting", "The workspace is starting."), + FAILED("ⓧ Failed", "The workspace has failed to start."), + DELETING("⦸ Deleting", "The workspace is being deleted."), + DELETED("⦸ Deleted", "The workspace has been deleted."), + STOPPING("◍ Stopping", "The workspace is stopping."), + STOPPED("◍ Stopped", "The workspace has stopped."), + CANCELING("◍ Canceling action", "The workspace is being canceled."), + CANCELED("◍ Canceled action", "The workspace has been canceled."), + RUNNING("⦿ Running", "The workspace is running, waiting for agents."), + + // Agent states. + CONNECTING("⦿ Connecting", "The agent is connecting."), + DISCONNECTED("⦸ Disconnected", "The agent has disconnected."), + TIMEOUT("ⓧ Timeout", "The agent is taking longer than expected to connect."), + AGENT_STARTING("⦿ Starting", "The startup script is running."), + AGENT_STARTING_READY("⦿ Starting", "The startup script is still running but the agent is ready to accept connections."), + CREATED("⦿ Created", "The agent has been created."), + START_ERROR("◍ Started with error", "The agent is ready but the startup script errored."), + START_TIMEOUT("◍ Starting", "The startup script is taking longer than expected."), + START_TIMEOUT_READY("◍ Starting", "The startup script is taking longer than expected but the agent is ready to accept connections."), + SHUTTING_DOWN("◍ Shutting down", "The agent is shutting down."), + SHUTDOWN_ERROR("⦸ Shutdown with error", "The agent shut down but the shutdown script errored."), + SHUTDOWN_TIMEOUT("⦸ Shutting down", "The shutdown script is taking longer than expected."), + OFF("⦸ Off", "The agent has shut down."), + READY("⦿ Ready", "The agent is ready to accept connections."); + + fun statusColor(): JBColor = when (this) { + READY, AGENT_STARTING_READY, START_TIMEOUT_READY -> JBColor.GREEN + START_ERROR, START_TIMEOUT, SHUTDOWN_TIMEOUT -> JBColor.YELLOW + FAILED, DISCONNECTED, TIMEOUT, SHUTDOWN_ERROR -> JBColor.RED + else -> if (JBColor.isBright()) JBColor.LIGHT_GRAY else JBColor.DARK_GRAY + } + + /** + * Return true if the agent is in a connectable state. + */ + fun ready(): Boolean { + return listOf(READY, START_ERROR, AGENT_STARTING_READY, START_TIMEOUT_READY) + .contains(this) + } + + // 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. + // + // We can also choose to allow `start_error` for the agent lifecycle state; + // this means the startup script did not successfully complete but the agent + // will still accept SSH connections. + // + // Lastly we can also allow connections when the agent lifecycle state is + // `starting` or `start_timeout` if `login_before_ready` is true on the + // workspace response since this bypasses the need to wait for the script. + // + // Note that latest_build.status is derived from latest_build.job.status and + // latest_build.job.transition so there is no need to check those. + companion object { + fun from(workspace: Workspace, agent: WorkspaceAgent? = null) = when (workspace.latestBuild.status) { + WorkspaceStatus.PENDING -> QUEUED + WorkspaceStatus.STARTING -> STARTING + WorkspaceStatus.RUNNING -> when (agent?.status) { + WorkspaceAgentStatus.CONNECTED -> when (agent.lifecycleState) { + WorkspaceAgentLifecycleState.CREATED -> CREATED + WorkspaceAgentLifecycleState.STARTING -> if (agent.loginBeforeReady == true) AGENT_STARTING_READY else AGENT_STARTING + WorkspaceAgentLifecycleState.START_TIMEOUT -> if (agent.loginBeforeReady == true) START_TIMEOUT_READY else START_TIMEOUT + WorkspaceAgentLifecycleState.START_ERROR -> START_ERROR + WorkspaceAgentLifecycleState.READY -> READY + WorkspaceAgentLifecycleState.SHUTTING_DOWN -> SHUTTING_DOWN + WorkspaceAgentLifecycleState.SHUTDOWN_TIMEOUT -> SHUTDOWN_TIMEOUT + WorkspaceAgentLifecycleState.SHUTDOWN_ERROR -> SHUTDOWN_ERROR + WorkspaceAgentLifecycleState.OFF -> OFF + } + + WorkspaceAgentStatus.DISCONNECTED -> DISCONNECTED + WorkspaceAgentStatus.TIMEOUT -> TIMEOUT + WorkspaceAgentStatus.CONNECTING -> CONNECTING + else -> RUNNING + } + + WorkspaceStatus.STOPPING -> STOPPING + WorkspaceStatus.STOPPED -> STOPPED + WorkspaceStatus.FAILED -> FAILED + WorkspaceStatus.CANCELING -> CANCELING + WorkspaceStatus.CANCELED -> CANCELED + WorkspaceStatus.DELETING -> DELETING + WorkspaceStatus.DELETED -> DELETED + } + + fun from(str: String) = WorkspaceAndAgentStatus.values().first { it.label.contains(str, true) } + } +} diff --git a/src/main/kotlin/com/coder/gateway/sdk/v2/models/WorkspaceAgent.kt b/src/main/kotlin/com/coder/gateway/sdk/v2/models/WorkspaceAgent.kt index 8f05e4bd..69eb1f52 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/v2/models/WorkspaceAgent.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/v2/models/WorkspaceAgent.kt @@ -26,23 +26,30 @@ data class WorkspaceAgent( @SerializedName("latency") val derpLatency: Map?, @SerializedName("connection_timeout_seconds") val connectionTimeoutSeconds: Int, @SerializedName("troubleshooting_url") val troubleshootingURL: String, + @SerializedName("lifecycle_state") val lifecycleState: WorkspaceAgentLifecycleState, + @SerializedName("login_before_ready") val loginBeforeReady: Boolean?, ) enum class WorkspaceAgentStatus { - @SerializedName("connecting") - CONNECTING, - - @SerializedName("connected") - CONNECTED, - - @SerializedName("disconnected") - DISCONNECTED, + @SerializedName("connecting") CONNECTING, + @SerializedName("connected") CONNECTED, + @SerializedName("disconnected") DISCONNECTED, + @SerializedName("timeout") TIMEOUT +} - @SerializedName("timeout") - TIMEOUT +enum class WorkspaceAgentLifecycleState { + @SerializedName("created") CREATED, + @SerializedName("starting") STARTING, + @SerializedName("start_timeout") START_TIMEOUT, + @SerializedName("start_error") START_ERROR, + @SerializedName("ready") READY, + @SerializedName("shutting_down") SHUTTING_DOWN, + @SerializedName("shutdown_timeout") SHUTDOWN_TIMEOUT, + @SerializedName("shutdown_error") SHUTDOWN_ERROR, + @SerializedName("off") OFF, } data class DERPRegion( @SerializedName("preferred") val preferred: Boolean, - @SerializedName("latency_ms") val latencyMillis: Double + @SerializedName("latency_ms") val latencyMillis: Double, ) diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt index 4d6d1cf4..a0fa03df 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -5,10 +5,7 @@ import com.coder.gateway.icons.CoderIcons import com.coder.gateway.models.CoderWorkspacesWizardModel import com.coder.gateway.models.TokenSource import com.coder.gateway.models.WorkspaceAgentModel -import com.coder.gateway.models.WorkspaceAgentStatus -import com.coder.gateway.models.WorkspaceAgentStatus.FAILED -import com.coder.gateway.models.WorkspaceAgentStatus.RUNNING -import com.coder.gateway.models.WorkspaceAgentStatus.STOPPED +import com.coder.gateway.models.WorkspaceAndAgentStatus import com.coder.gateway.models.WorkspaceVersionStatus import com.coder.gateway.sdk.Arch import com.coder.gateway.sdk.CoderCLIManager @@ -24,6 +21,7 @@ import com.coder.gateway.sdk.ex.TemplateResponseException import com.coder.gateway.sdk.ex.WorkspaceResponseException import com.coder.gateway.sdk.toURL import com.coder.gateway.sdk.v2.models.Workspace +import com.coder.gateway.sdk.v2.models.WorkspaceStatus import com.coder.gateway.sdk.withPath import com.coder.gateway.services.CoderSettingsState import com.intellij.ide.ActivityTracker @@ -134,8 +132,8 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod setEmptyState("Disconnected") setSelectionMode(ListSelectionModel.SINGLE_SELECTION) selectionModel.addListSelectionListener { - setNextButtonEnabled(selectedObject != null && selectedObject?.agentStatus == RUNNING && selectedObject?.agentOS == OS.LINUX) - if (selectedObject?.agentStatus == RUNNING && selectedObject?.agentOS != OS.LINUX) { + setNextButtonEnabled(selectedObject?.agentStatus?.ready() == true && selectedObject?.agentOS == OS.LINUX) + if (selectedObject?.agentStatus?.ready() == true && selectedObject?.agentOS != OS.LINUX) { notificationBanner.apply { component.isVisible = true showInfo(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.unsupported.os.info")) @@ -270,7 +268,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod }.apply { background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() - border = JBUI.Borders.empty(0, 16, 0, 16) + border = JBUI.Borders.empty(0, 16) } override val previousActionText = IdeBundle.message("button.back") @@ -384,8 +382,8 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod private fun updateWorkspaceActions() { goToDashboardAction.isEnabled = coderClient.isReady createWorkspaceAction.isEnabled = coderClient.isReady - when (tableOfWorkspaces.selectedObject?.agentStatus) { - RUNNING -> { + when (tableOfWorkspaces.selectedObject?.workspaceStatus) { + WorkspaceStatus.RUNNING -> { startWorkspaceAction.isEnabled = false stopWorkspaceAction.isEnabled = true when (tableOfWorkspaces.selectedObject?.status) { @@ -395,7 +393,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } - STOPPED, FAILED -> { + WorkspaceStatus.STOPPED, WorkspaceStatus.FAILED -> { startWorkspaceAction.isEnabled = true stopWorkspaceAction.isEnabled = false when (tableOfWorkspaces.selectedObject?.status) { @@ -687,86 +685,59 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } private fun Workspace.toAgentModels(): Set { - return when (this.latestBuild.resources.size) { - 0 -> { - val wm = WorkspaceAgentModel( - this.id, - this.name, - this.name, - this.templateID, - this.templateName, - this.templateIcon, - null, - WorkspaceVersionStatus.from(this), - WorkspaceAgentStatus.from(this), - this.latestBuild.transition, - null, - null, - null - ) - cs.launch(Dispatchers.IO) { - wm.templateIcon = iconDownloader.load(wm.templateIconPath, wm.name) - withContext(Dispatchers.Main) { - tableOfWorkspaces.updateUI() - } + val wam = this.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! }.map { agent -> + val workspaceWithAgentName = "${this.name}.${agent.name}" + val wm = WorkspaceAgentModel( + this.id, + this.name, + workspaceWithAgentName, + this.templateID, + this.templateName, + this.templateIcon, + null, + WorkspaceVersionStatus.from(this), + this.latestBuild.status, + WorkspaceAndAgentStatus.from(this, agent), + this.latestBuild.transition, + OS.from(agent.operatingSystem), + Arch.from(agent.architecture), + agent.expandedDirectory ?: agent.directory, + ) + cs.launch(Dispatchers.IO) { + wm.templateIcon = iconDownloader.load(wm.templateIconPath, wm.name) + withContext(Dispatchers.Main) { + tableOfWorkspaces.updateUI() } - setOf(wm) } - - else -> { - val wam = this.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! }.map { agent -> - val workspaceWithAgentName = "${this.name}.${agent.name}" - val wm = WorkspaceAgentModel( - this.id, - this.name, - workspaceWithAgentName, - this.templateID, - this.templateName, - this.templateIcon, - null, - WorkspaceVersionStatus.from(this), - WorkspaceAgentStatus.from(this), - this.latestBuild.transition, - OS.from(agent.operatingSystem), - Arch.from(agent.architecture), - agent.expandedDirectory ?: agent.directory, - ) - cs.launch(Dispatchers.IO) { - wm.templateIcon = iconDownloader.load(wm.templateIconPath, wm.name) - withContext(Dispatchers.Main) { - tableOfWorkspaces.updateUI() - } - } - wm - }.toSet() - - if (wam.isNullOrEmpty()) { - val wm = WorkspaceAgentModel( - this.id, - this.name, - this.name, - this.templateID, - this.templateName, - this.templateIcon, - null, - WorkspaceVersionStatus.from(this), - WorkspaceAgentStatus.from(this), - this.latestBuild.transition, - null, - null, - null - ) - cs.launch(Dispatchers.IO) { - wm.templateIcon = iconDownloader.load(wm.templateIconPath, wm.name) - withContext(Dispatchers.Main) { - tableOfWorkspaces.updateUI() - } - } - return setOf(wm) + wm + }.toSet() + + if (wam.isNullOrEmpty()) { + val wm = WorkspaceAgentModel( + this.id, + this.name, + this.name, + this.templateID, + this.templateName, + this.templateIcon, + null, + WorkspaceVersionStatus.from(this), + this.latestBuild.status, + WorkspaceAndAgentStatus.from(this), + this.latestBuild.transition, + null, + null, + null + ) + cs.launch(Dispatchers.IO) { + wm.templateIcon = iconDownloader.load(wm.templateIconPath, wm.name) + withContext(Dispatchers.Main) { + tableOfWorkspaces.updateUI() } - return wam } + return setOf(wm) } + return wam } override fun onPrevious() { @@ -824,7 +795,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod override fun getTableCellRendererComponent(table: JTable?, value: Any?, selected: Boolean, focus: Boolean, row: Int, column: Int): Component { super.getTableCellRendererComponent(table, value, selected, focus, row, column).apply { - border = JBUI.Borders.empty(8, 8) + border = JBUI.Borders.empty(8) } return this } @@ -839,10 +810,6 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod override fun getComparator(): Comparator { return Comparator { a, b -> - if (a === b) 0 - if (a == null) -1 - if (b == null) 1 - a.name.compareTo(b.name, ignoreCase = true) } } @@ -870,10 +837,6 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod override fun getComparator(): java.util.Comparator { return Comparator { a, b -> - if (a === b) 0 - if (a == null) -1 - if (b == null) 1 - a.templateName.compareTo(b.templateName, ignoreCase = true) } } @@ -933,21 +896,25 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod override fun getComparator(): java.util.Comparator { return Comparator { a, b -> - if (a === b) 0 - if (a == null) -1 - if (b == null) 1 - a.agentStatus.label.compareTo(b.agentStatus.label, ignoreCase = true) } } override fun getRenderer(item: WorkspaceAgentModel?): TableCellRenderer { return object : DefaultTableCellRenderer() { - override fun getTableCellRendererComponent(table: JTable, value: Any, isSelected: Boolean, hasFocus: Boolean, row: Int, column: Int): Component { + override fun getTableCellRendererComponent( + table: JTable, + value: Any, + isSelected: Boolean, + hasFocus: Boolean, + row: Int, + column: Int, + ): Component { super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) if (value is String) { text = value - foreground = WorkspaceAgentStatus.from(value).statusColor() + foreground = WorkspaceAndAgentStatus.from(value).statusColor() + toolTipText = WorkspaceAndAgentStatus.from(value).description } font = this@CoderWorkspacesStepView.tableOfWorkspaces.tableHeader.font border = JBUI.Borders.empty(0, 8) diff --git a/src/test/groovy/CoderCLIManagerTest.groovy b/src/test/groovy/CoderCLIManagerTest.groovy index d06690ab..8f09d4c7 100644 --- a/src/test/groovy/CoderCLIManagerTest.groovy +++ b/src/test/groovy/CoderCLIManagerTest.groovy @@ -1,8 +1,9 @@ package com.coder.gateway.sdk import com.coder.gateway.models.WorkspaceAgentModel -import com.coder.gateway.models.WorkspaceAgentStatus +import com.coder.gateway.models.WorkspaceAndAgentStatus import com.coder.gateway.models.WorkspaceVersionStatus +import com.coder.gateway.sdk.v2.models.WorkspaceStatus import com.coder.gateway.sdk.v2.models.WorkspaceTransition import com.sun.net.httpserver.HttpExchange import com.sun.net.httpserver.HttpHandler @@ -373,7 +374,8 @@ class CoderCLIManagerTest extends Specification { "template-icon-path", null, WorkspaceVersionStatus.UPDATED, - WorkspaceAgentStatus.RUNNING, + WorkspaceStatus.RUNNING, + WorkspaceAndAgentStatus.READY, WorkspaceTransition.START, null, null,