Skip to content

Commit fa7d3d1

Browse files
authored
Add status and start/stop buttons to recents view (#243)
* Split client out of client service This way we can create multiple clients on the recent workspaces page without having to do the whole init thing for each one. * Store config directory in recent connection We need this information so we can query the status of recent connections as they could belong to multiple deployments. This could end up desyncing if the user manually edits their config file and changes the global config path in ProxyCommand. The alternative would be to parse the SSH config to make sure we have the right config directory but that would mean parsing ProxyCommand to extract the value of --global-config. As a fallback for connections that already exist and are not yet stored with the config directory we could split the host name itself on `--` since it has the domain in it and join with the default directory but this could be inaccurate if the default has been changed or if in the future we change the host name format. * Store name in recent connection So we can match against the API response. We could split the hostname on `--` but there are cases where that will fail (when the name or domain itself contains -- in specific configurations). We have to add the config path anyway so this is the best opportunity to add more information. * Standardize some casing * Ignore null hostnames I guess this could happen if you manually edit the recents? In any case if there is no host name I am not sure there is value in trying to show the connection and it is making it difficult to check if the workspace is up. * Break out toAgentModels We will need this in the recent connections view as well. * Split agent status icon and label So we can show just the icon in the recent connections view. * Add status and start/stop buttons to recent connections This relies on some new data being stored with the recent connections so old connections will be in an unknown state. * Simplify recent workspace connection constructor
1 parent b3033b7 commit fa7d3d1

17 files changed

+427
-208
lines changed

src/main/kotlin/com/coder/gateway/WorkspaceParams.kt

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ private const val IDE_PRODUCT_CODE = "ide_product_code"
2323
private const val IDE_BUILD_NUMBER = "ide_build_number"
2424
private const val IDE_PATH_ON_HOST = "ide_path_on_host"
2525
private const val WEB_TERMINAL_LINK = "web_terminal_link"
26+
private const val CONFIG_DIRECTORY = "config_directory"
27+
private const val NAME = "name"
2628

2729
private val localTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MMM-dd HH:mm")
2830

@@ -33,7 +35,9 @@ fun RecentWorkspaceConnection.toWorkspaceParams(): Map<String, String> {
3335
PROJECT_PATH to this.projectPath!!,
3436
IDE_PRODUCT_CODE to IntelliJPlatformProduct.fromProductCode(this.ideProductCode!!)!!.productCode,
3537
IDE_BUILD_NUMBER to "${this.ideBuildNumber}",
36-
WEB_TERMINAL_LINK to "${this.webTerminalLink}"
38+
WEB_TERMINAL_LINK to "${this.webTerminalLink}",
39+
CONFIG_DIRECTORY to "${this.configDirectory}",
40+
NAME to "${this.name}"
3741
)
3842

3943
if (!this.downloadSource.isNullOrBlank()) {
@@ -80,6 +84,19 @@ fun Map<String, String>.withWebTerminalLink(webTerminalLink: String): Map<String
8084
return map
8185
}
8286

87+
fun Map<String, String>.withConfigDirectory(dir: String): Map<String, String> {
88+
val map = this.toMutableMap()
89+
map[CONFIG_DIRECTORY] = dir
90+
return map
91+
}
92+
93+
fun Map<String, String>.withName(name: String): Map<String, String> {
94+
val map = this.toMutableMap()
95+
map[NAME] = name
96+
return map
97+
}
98+
99+
83100
fun Map<String, String>.areCoderType(): Boolean {
84101
return this[TYPE] == VALUE_FOR_TYPE && !this[CODER_WORKSPACE_HOSTNAME].isNullOrBlank() && !this[PROJECT_PATH].isNullOrBlank()
85102
}
@@ -140,7 +157,9 @@ fun Map<String, String>.toRecentWorkspaceConnection(): RecentWorkspaceConnection
140157
this[IDE_BUILD_NUMBER]!!,
141158
this[IDE_DOWNLOAD_LINK]!!,
142159
null,
143-
this[WEB_TERMINAL_LINK]!!
160+
this[WEB_TERMINAL_LINK]!!,
161+
this[CONFIG_DIRECTORY]!!,
162+
this[NAME]!!,
144163
) else RecentWorkspaceConnection(
145164
this.workspaceHostname(),
146165
this.projectPath(),
@@ -149,6 +168,8 @@ fun Map<String, String>.toRecentWorkspaceConnection(): RecentWorkspaceConnection
149168
this[IDE_BUILD_NUMBER]!!,
150169
null,
151170
this[IDE_PATH_ON_HOST],
152-
this[WEB_TERMINAL_LINK]!!
171+
this[WEB_TERMINAL_LINK]!!,
172+
this[CONFIG_DIRECTORY]!!,
173+
this[NAME]!!,
153174
)
154-
}
175+
}

src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ object CoderIcons {
88

99
val OPEN_TERMINAL = IconLoader.getIcon("open_terminal.svg", javaClass)
1010

11+
val PENDING = IconLoader.getIcon("pending.svg", javaClass)
12+
val RUNNING = IconLoader.getIcon("running.svg", javaClass)
13+
val OFF = IconLoader.getIcon("off.svg", javaClass)
14+
1115
val HOME = IconLoader.getIcon("homeFolder.svg", javaClass)
1216
val CREATE = IconLoader.getIcon("create.svg", javaClass)
1317
val RUN = IconLoader.getIcon("run.svg", javaClass)
@@ -55,4 +59,4 @@ object CoderIcons {
5559
val Y = IconLoader.getIcon("y.svg", javaClass)
5660
val Z = IconLoader.getIcon("z.svg", javaClass)
5761

58-
}
62+
}

src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ data class CoderWorkspacesWizardModel(
1111
var token: Pair<String, TokenSource>? = null,
1212
var selectedWorkspace: WorkspaceAgentModel? = null,
1313
var useExistingToken: Boolean = false,
14+
var configDirectory: String = "",
1415
)

src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt

Lines changed: 17 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,43 +3,28 @@ package com.coder.gateway.models
33
import com.intellij.openapi.components.BaseState
44
import com.intellij.util.xmlb.annotations.Attribute
55

6-
class RecentWorkspaceConnection() : BaseState(), Comparable<RecentWorkspaceConnection> {
7-
constructor(hostname: String, prjPath: String, openedAt: String, productCode: String, buildNumber: String, source: String?, idePath: String?, terminalLink: String) : this() {
8-
coderWorkspaceHostname = hostname
9-
projectPath = prjPath
10-
lastOpened = openedAt
11-
ideProductCode = productCode
12-
ideBuildNumber = buildNumber
13-
downloadSource = source
14-
idePathOnHost = idePath
15-
webTerminalLink = terminalLink
16-
}
17-
6+
class RecentWorkspaceConnection(
187
@get:Attribute
19-
var coderWorkspaceHostname by string()
20-
8+
var coderWorkspaceHostname: String? = null,
219
@get:Attribute
22-
var projectPath by string()
23-
10+
var projectPath: String? = null,
2411
@get:Attribute
25-
var lastOpened by string()
26-
12+
var lastOpened: String? = null,
2713
@get:Attribute
28-
var ideProductCode by string()
29-
14+
var ideProductCode: String? = null,
3015
@get:Attribute
31-
var ideBuildNumber by string()
32-
16+
var ideBuildNumber: String? = null,
3317
@get:Attribute
34-
var downloadSource by string()
35-
36-
18+
var downloadSource: String? = null,
3719
@get:Attribute
38-
var idePathOnHost by string()
39-
20+
var idePathOnHost: String? = null,
4021
@get:Attribute
41-
var webTerminalLink by string()
42-
22+
var webTerminalLink: String? = null,
23+
@get:Attribute
24+
var configDirectory: String? = null,
25+
@get:Attribute
26+
var name: String? = null,
27+
) : BaseState(), Comparable<RecentWorkspaceConnection> {
4328
override fun equals(other: Any?): Boolean {
4429
if (this === other) return true
4530
if (javaClass != other?.javaClass) return false
@@ -88,11 +73,11 @@ class RecentWorkspaceConnection() : BaseState(), Comparable<RecentWorkspaceConne
8873
if (m != null && m != 0) return m
8974

9075
val n = other.idePathOnHost?.let { idePathOnHost?.compareTo(it) }
91-
if (n != null && m != 0) return n
76+
if (n != null && n != 0) return n
9277

9378
val o = other.webTerminalLink?.let { webTerminalLink?.compareTo(it) }
94-
if (o != null && n != 0) return o
79+
if (o != null && o != 0) return o
9580

9681
return 0
9782
}
98-
}
83+
}

src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnectionState.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ class RecentWorkspaceConnectionState : BaseState() {
88
var recentConnections by treeSet<RecentWorkspaceConnection>()
99

1010
fun add(connection: RecentWorkspaceConnection): Boolean {
11-
// if the item is already there but with a different last update timestamp, remove it
11+
// If the item is already there but with a different last updated
12+
// timestamp or config directory, remove it.
1213
recentConnections.remove(connection)
13-
// and add it again with the new timestamp
1414
val result = recentConnections.add(connection)
1515
if (result) incrementModificationCount()
1616
return result
@@ -21,4 +21,4 @@ class RecentWorkspaceConnectionState : BaseState() {
2121
if (result) incrementModificationCount()
2222
return result
2323
}
24-
}
24+
}

src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,46 @@
11
package com.coder.gateway.models
22

3+
import com.coder.gateway.icons.CoderIcons
34
import com.coder.gateway.sdk.v2.models.Workspace
45
import com.coder.gateway.sdk.v2.models.WorkspaceAgent
56
import com.coder.gateway.sdk.v2.models.WorkspaceAgentLifecycleState
67
import com.coder.gateway.sdk.v2.models.WorkspaceAgentStatus
78
import com.coder.gateway.sdk.v2.models.WorkspaceStatus
89
import com.intellij.ui.JBColor
10+
import javax.swing.Icon
911

1012
/**
1113
* WorkspaceAndAgentStatus represents the combined status of a single agent and
1214
* its workspace (or just the workspace if there are no agents).
1315
*/
14-
enum class WorkspaceAndAgentStatus(val label: String, val description: String) {
16+
enum class WorkspaceAndAgentStatus(val icon: Icon, val label: String, val description: String) {
1517
// Workspace states.
16-
QUEUED("Queued", "The workspace is queueing to start."),
17-
STARTING("⦿ Starting", "The workspace is starting."),
18-
FAILED("Failed", "The workspace has failed to start."),
19-
DELETING("Deleting", "The workspace is being deleted."),
20-
DELETED("Deleted", "The workspace has been deleted."),
21-
STOPPING("Stopping", "The workspace is stopping."),
22-
STOPPED("Stopped", "The workspace has stopped."),
23-
CANCELING("Canceling action", "The workspace is being canceled."),
24-
CANCELED("Canceled action", "The workspace has been canceled."),
25-
RUNNING("⦿ Running", "The workspace is running, waiting for agents."),
18+
QUEUED(CoderIcons.PENDING, "Queued", "The workspace is queueing to start."),
19+
STARTING(CoderIcons.PENDING, "Starting", "The workspace is starting."),
20+
FAILED(CoderIcons.OFF, "Failed", "The workspace has failed to start."),
21+
DELETING(CoderIcons.PENDING, "Deleting", "The workspace is being deleted."),
22+
DELETED(CoderIcons.OFF, "Deleted", "The workspace has been deleted."),
23+
STOPPING(CoderIcons.PENDING, "Stopping", "The workspace is stopping."),
24+
STOPPED(CoderIcons.OFF, "Stopped", "The workspace has stopped."),
25+
CANCELING(CoderIcons.PENDING, "Canceling action", "The workspace is being canceled."),
26+
CANCELED(CoderIcons.OFF, "Canceled action", "The workspace has been canceled."),
27+
RUNNING(CoderIcons.RUN, "Running", "The workspace is running, waiting for agents."),
2628

2729
// Agent states.
28-
CONNECTING("⦿ Connecting", "The agent is connecting."),
29-
DISCONNECTED("Disconnected", "The agent has disconnected."),
30-
TIMEOUT("Timeout", "The agent is taking longer than expected to connect."),
31-
AGENT_STARTING("⦿ Starting", "The startup script is running."),
32-
AGENT_STARTING_READY("⦿ Starting", "The startup script is still running but the agent is ready to accept connections."),
33-
CREATED("⦿ Created", "The agent has been created."),
34-
START_ERROR("Started with error", "The agent is ready but the startup script errored."),
35-
START_TIMEOUT("Starting", "The startup script is taking longer than expected."),
36-
START_TIMEOUT_READY("Starting", "The startup script is taking longer than expected but the agent is ready to accept connections."),
37-
SHUTTING_DOWN("Shutting down", "The agent is shutting down."),
38-
SHUTDOWN_ERROR("Shutdown with error", "The agent shut down but the shutdown script errored."),
39-
SHUTDOWN_TIMEOUT("Shutting down", "The shutdown script is taking longer than expected."),
40-
OFF("Off", "The agent has shut down."),
41-
READY("⦿ Ready", "The agent is ready to accept connections.");
30+
CONNECTING(CoderIcons.PENDING, "Connecting", "The agent is connecting."),
31+
DISCONNECTED(CoderIcons.OFF, "Disconnected", "The agent has disconnected."),
32+
TIMEOUT(CoderIcons.PENDING, "Timeout", "The agent is taking longer than expected to connect."),
33+
AGENT_STARTING(CoderIcons.PENDING, "Starting", "The startup script is running."),
34+
AGENT_STARTING_READY(CoderIcons.RUNNING, "Starting", "The startup script is still running but the agent is ready to accept connections."),
35+
CREATED(CoderIcons.PENDING, "Created", "The agent has been created."),
36+
START_ERROR(CoderIcons.RUNNING, "Started with error", "The agent is ready but the startup script errored."),
37+
START_TIMEOUT(CoderIcons.PENDING, "Starting", "The startup script is taking longer than expected."),
38+
START_TIMEOUT_READY(CoderIcons.RUNNING, "Starting", "The startup script is taking longer than expected but the agent is ready to accept connections."),
39+
SHUTTING_DOWN(CoderIcons.PENDING, "Shutting down", "The agent is shutting down."),
40+
SHUTDOWN_ERROR(CoderIcons.OFF, "Shutdown with error", "The agent shut down but the shutdown script errored."),
41+
SHUTDOWN_TIMEOUT(CoderIcons.OFF, "Shutting down", "The shutdown script is taking longer than expected."),
42+
OFF(CoderIcons.OFF, "Off", "The agent has shut down."),
43+
READY(CoderIcons.RUNNING, "Ready", "The agent is ready to accept connections.");
4244

4345
fun statusColor(): JBColor = when (this) {
4446
READY, AGENT_STARTING_READY, START_TIMEOUT_READY -> JBColor.GREEN
@@ -100,7 +102,5 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) {
100102
WorkspaceStatus.DELETING -> DELETING
101103
WorkspaceStatus.DELETED -> DELETED
102104
}
103-
104-
fun from(str: String) = WorkspaceAndAgentStatus.values().first { it.label.contains(str, true) }
105105
}
106106
}

src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ class CoderCLIManager @JvmOverloads constructor(
3131
) {
3232
var remoteBinaryURL: URL
3333
var localBinaryPath: Path
34-
private var coderConfigPath: Path
34+
var coderConfigPath: Path
3535

3636
init {
3737
val binaryName = getCoderCLIForOS(getOS(), getArch())

src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,30 @@ import java.util.UUID
3131
class CoderRestClientService {
3232
var isReady: Boolean = false
3333
private set
34-
private lateinit var httpClient: OkHttpClient
35-
private lateinit var retroRestClient: CoderV2RestFacade
36-
private lateinit var sessionToken: String
37-
lateinit var coderURL: URL
3834
lateinit var me: User
3935
lateinit var buildVersion: String
36+
lateinit var client: CoderRestClient
4037

4138
/**
42-
* This must be called before anything else. It will authenticate with coder and retrieve a session token
39+
* This must be called before anything else. It will authenticate and load
40+
* information about the current user and the build version.
41+
*
4342
* @throws [AuthenticationResponseException] if authentication failed.
4443
*/
4544
fun initClientSession(url: URL, token: String): User {
45+
client = CoderRestClient(url, token)
46+
me = client.me()
47+
buildVersion = client.buildInfo().version
48+
isReady = true
49+
return me
50+
}
51+
}
52+
53+
class CoderRestClient(var url: URL, private var token: String) {
54+
private var httpClient: OkHttpClient
55+
private var retroRestClient: CoderV2RestFacade
56+
57+
init {
4658
val gson: Gson = GsonBuilder().registerTypeAdapter(Instant::class.java, InstantConverter()).setPrettyPrinting().create()
4759
val pluginVersion = PluginManagerCore.getPlugin(PluginId.getId("com.coder.gateway"))!! // this is the id from the plugin.xml
4860

@@ -54,18 +66,19 @@ class CoderRestClientService {
5466
.build()
5567

5668
retroRestClient = Retrofit.Builder().baseUrl(url.toString()).client(httpClient).addConverterFactory(GsonConverterFactory.create(gson)).build().create(CoderV2RestFacade::class.java)
69+
}
5770

71+
/**
72+
* Retrieve the current user.
73+
* @throws [AuthenticationResponseException] if authentication failed.
74+
*/
75+
fun me(): User {
5876
val userResponse = retroRestClient.me().execute()
5977
if (!userResponse.isSuccessful) {
60-
throw AuthenticationResponseException("Could not retrieve information about logged user:${userResponse.code()}, reason: ${userResponse.message()}")
78+
throw AuthenticationResponseException("Could not retrieve information about logged user:${userResponse.code()}, reason: ${userResponse.message().ifBlank { "no reason provided" }}")
6179
}
6280

63-
coderURL = url
64-
sessionToken = token
65-
me = userResponse.body()!!
66-
buildVersion = buildInfo().version
67-
isReady = true
68-
return me
81+
return userResponse.body()!!
6982
}
7083

7184
/**
@@ -75,24 +88,24 @@ class CoderRestClientService {
7588
fun workspaces(): List<Workspace> {
7689
val workspacesResponse = retroRestClient.workspaces("owner:me").execute()
7790
if (!workspacesResponse.isSuccessful) {
78-
throw WorkspaceResponseException("Could not retrieve Coder Workspaces:${workspacesResponse.code()}, reason: ${workspacesResponse.message()}")
91+
throw WorkspaceResponseException("Could not retrieve Coder Workspaces:${workspacesResponse.code()}, reason: ${workspacesResponse.message().ifBlank { "no reason provided" }}")
7992
}
8093

8194
return workspacesResponse.body()!!.workspaces
8295
}
8396

84-
private fun buildInfo(): BuildInfo {
97+
fun buildInfo(): BuildInfo {
8598
val buildInfoResponse = retroRestClient.buildInfo().execute()
8699
if (!buildInfoResponse.isSuccessful) {
87-
throw java.lang.IllegalStateException("Could not retrieve build information for Coder instance $coderURL, reason:${buildInfoResponse.message()}")
100+
throw java.lang.IllegalStateException("Could not retrieve build information for Coder instance $url, reason:${buildInfoResponse.message().ifBlank { "no reason provided" }}")
88101
}
89102
return buildInfoResponse.body()!!
90103
}
91104

92-
private fun template(templateID: UUID): Template {
105+
fun template(templateID: UUID): Template {
93106
val templateResponse = retroRestClient.template(templateID).execute()
94107
if (!templateResponse.isSuccessful) {
95-
throw TemplateResponseException("Failed to retrieve template with id: $templateID, reason: ${templateResponse.message()}")
108+
throw TemplateResponseException("Failed to retrieve template with id: $templateID, reason: ${templateResponse.message().ifBlank { "no reason provided" }}")
96109
}
97110
return templateResponse.body()!!
98111
}
@@ -101,7 +114,7 @@ class CoderRestClientService {
101114
val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.START, null, null, null, null)
102115
val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute()
103116
if (buildResponse.code() != HTTP_CREATED) {
104-
throw WorkspaceResponseException("Failed to build workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message()}")
117+
throw WorkspaceResponseException("Failed to build workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}")
105118
}
106119

107120
return buildResponse.body()!!
@@ -111,7 +124,7 @@ class CoderRestClientService {
111124
val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.STOP, null, null, null, null)
112125
val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute()
113126
if (buildResponse.code() != HTTP_CREATED) {
114-
throw WorkspaceResponseException("Failed to stop workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message()}")
127+
throw WorkspaceResponseException("Failed to stop workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}")
115128
}
116129

117130
return buildResponse.body()!!
@@ -123,9 +136,9 @@ class CoderRestClientService {
123136
val buildRequest = CreateWorkspaceBuildRequest(template.activeVersionID, lastWorkspaceTransition, null, null, null, null)
124137
val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute()
125138
if (buildResponse.code() != HTTP_CREATED) {
126-
throw WorkspaceResponseException("Failed to update workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message()}")
139+
throw WorkspaceResponseException("Failed to update workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}")
127140
}
128141

129142
return buildResponse.body()!!
130143
}
131-
}
144+
}

0 commit comments

Comments
 (0)