From d1b36d9320a7317820597e74ad689d2d1bf205b6 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 4 May 2023 11:25:26 -0800 Subject: [PATCH 1/9] 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. --- .../gateway/sdk/CoderRestClientService.kt | 55 ++++++++++++------- .../gateway/sdk/TemplateIconDownloader.kt | 6 +- .../steps/CoderLocateRemoteProjectStepView.kt | 4 +- .../views/steps/CoderWorkspacesStepView.kt | 34 ++++++------ 4 files changed, 56 insertions(+), 43 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt b/src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt index 0b99f2ae..244ebba8 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/CoderRestClientService.kt @@ -31,18 +31,30 @@ import java.util.UUID class CoderRestClientService { var isReady: Boolean = false private set - private lateinit var httpClient: OkHttpClient - private lateinit var retroRestClient: CoderV2RestFacade - private lateinit var sessionToken: String - lateinit var coderURL: URL lateinit var me: User lateinit var buildVersion: String + lateinit var client: CoderRestClient /** - * This must be called before anything else. It will authenticate with coder and retrieve a session token + * This must be called before anything else. It will authenticate and load + * information about the current user and the build version. + * * @throws [AuthenticationResponseException] if authentication failed. */ fun initClientSession(url: URL, token: String): User { + client = CoderRestClient(url, token) + me = client.me() + buildVersion = client.buildInfo().version + isReady = true + return me + } +} + +class CoderRestClient(var url: URL, private var token: String) { + private var httpClient: OkHttpClient + private var retroRestClient: CoderV2RestFacade + + init { val gson: Gson = GsonBuilder().registerTypeAdapter(Instant::class.java, InstantConverter()).setPrettyPrinting().create() val pluginVersion = PluginManagerCore.getPlugin(PluginId.getId("com.coder.gateway"))!! // this is the id from the plugin.xml @@ -54,18 +66,19 @@ class CoderRestClientService { .build() retroRestClient = Retrofit.Builder().baseUrl(url.toString()).client(httpClient).addConverterFactory(GsonConverterFactory.create(gson)).build().create(CoderV2RestFacade::class.java) + } + /** + * Retrieve the current user. + * @throws [AuthenticationResponseException] if authentication failed. + */ + fun me(): User { val userResponse = retroRestClient.me().execute() if (!userResponse.isSuccessful) { - throw AuthenticationResponseException("Could not retrieve information about logged user:${userResponse.code()}, reason: ${userResponse.message()}") + throw AuthenticationResponseException("Could not retrieve information about logged user:${userResponse.code()}, reason: ${userResponse.message().ifBlank { "no reason provided" }}") } - coderURL = url - sessionToken = token - me = userResponse.body()!! - buildVersion = buildInfo().version - isReady = true - return me + return userResponse.body()!! } /** @@ -75,24 +88,24 @@ class CoderRestClientService { fun workspaces(): List { val workspacesResponse = retroRestClient.workspaces("owner:me").execute() if (!workspacesResponse.isSuccessful) { - throw WorkspaceResponseException("Could not retrieve Coder Workspaces:${workspacesResponse.code()}, reason: ${workspacesResponse.message()}") + throw WorkspaceResponseException("Could not retrieve Coder Workspaces:${workspacesResponse.code()}, reason: ${workspacesResponse.message().ifBlank { "no reason provided" }}") } return workspacesResponse.body()!!.workspaces } - private fun buildInfo(): BuildInfo { + fun buildInfo(): BuildInfo { val buildInfoResponse = retroRestClient.buildInfo().execute() if (!buildInfoResponse.isSuccessful) { - throw java.lang.IllegalStateException("Could not retrieve build information for Coder instance $coderURL, reason:${buildInfoResponse.message()}") + throw java.lang.IllegalStateException("Could not retrieve build information for Coder instance $url, reason:${buildInfoResponse.message().ifBlank { "no reason provided" }}") } return buildInfoResponse.body()!! } - private fun template(templateID: UUID): Template { + fun template(templateID: UUID): Template { val templateResponse = retroRestClient.template(templateID).execute() if (!templateResponse.isSuccessful) { - throw TemplateResponseException("Failed to retrieve template with id: $templateID, reason: ${templateResponse.message()}") + throw TemplateResponseException("Failed to retrieve template with id: $templateID, reason: ${templateResponse.message().ifBlank { "no reason provided" }}") } return templateResponse.body()!! } @@ -101,7 +114,7 @@ class CoderRestClientService { val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.START, null, null, null, null) val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute() if (buildResponse.code() != HTTP_CREATED) { - throw WorkspaceResponseException("Failed to build workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message()}") + throw WorkspaceResponseException("Failed to build workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}") } return buildResponse.body()!! @@ -111,7 +124,7 @@ class CoderRestClientService { val buildRequest = CreateWorkspaceBuildRequest(null, WorkspaceTransition.STOP, null, null, null, null) val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute() if (buildResponse.code() != HTTP_CREATED) { - throw WorkspaceResponseException("Failed to stop workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message()}") + throw WorkspaceResponseException("Failed to stop workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}") } return buildResponse.body()!! @@ -123,9 +136,9 @@ class CoderRestClientService { val buildRequest = CreateWorkspaceBuildRequest(template.activeVersionID, lastWorkspaceTransition, null, null, null, null) val buildResponse = retroRestClient.createWorkspaceBuild(workspaceID, buildRequest).execute() if (buildResponse.code() != HTTP_CREATED) { - throw WorkspaceResponseException("Failed to update workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message()}") + throw WorkspaceResponseException("Failed to update workspace ${workspaceName}: ${buildResponse.code()}, reason: ${buildResponse.message().ifBlank { "no reason provided" }}") } return buildResponse.body()!! } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/coder/gateway/sdk/TemplateIconDownloader.kt b/src/main/kotlin/com/coder/gateway/sdk/TemplateIconDownloader.kt index a8575de8..fad38062 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/TemplateIconDownloader.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/TemplateIconDownloader.kt @@ -18,7 +18,7 @@ import javax.swing.Icon @Service(Service.Level.APP) class TemplateIconDownloader { - private val coderClient: CoderRestClientService = service() + private val clientService: CoderRestClientService = service() private val cache = mutableMapOf, Icon>() fun load(path: String, workspaceName: String): Icon { @@ -26,7 +26,7 @@ class TemplateIconDownloader { if (path.startsWith("http")) { url = path.toURL() } else if (!path.contains(":") && !path.contains("//")) { - url = coderClient.coderURL.withPath(path) + url = clientService.client.url.withPath(path) } if (url != null) { @@ -115,4 +115,4 @@ class TemplateIconDownloader { else -> CoderIcons.UNKNOWN } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt index 3b209edd..0a9959d4 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt @@ -84,7 +84,7 @@ import javax.swing.event.DocumentEvent class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolean) -> Unit) : CoderWorkspacesWizardStep, Disposable { private val cs = CoroutineScope(Dispatchers.Main) - private val coderClient: CoderRestClientService = ApplicationManager.getApplication().getService(CoderRestClientService::class.java) + private val clientService: CoderRestClientService = ApplicationManager.getApplication().getService(CoderRestClientService::class.java) private var ideComboBoxModel = DefaultComboBoxModel() @@ -177,7 +177,7 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea tfProject.text = if (selectedWorkspace.homeDirectory.isNullOrBlank()) "/home" else selectedWorkspace.homeDirectory titleLabel.text = CoderGatewayBundle.message("gateway.connector.view.coder.remoteproject.choose.text", selectedWorkspace.name) - terminalLink.url = coderClient.coderURL.withPath("/@${coderClient.me.username}/${selectedWorkspace.name}/terminal").toString() + terminalLink.url = clientService.client.url.withPath("/@${clientService.me.username}/${selectedWorkspace.name}/terminal").toString() ideResolvingJob = cs.launch { try { 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 62d4c1d3..01208316 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -100,7 +100,7 @@ private const val MOUSE_OVER_TEMPLATE_NAME_COLUMN_ON_ROW = "MOUSE_OVER_TEMPLATE_ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : CoderWorkspacesWizardStep, Disposable { private val cs = CoroutineScope(Dispatchers.Main) private var localWizardModel = CoderWorkspacesWizardModel() - private val coderClient: CoderRestClientService = service() + private val clientService: CoderRestClientService = service() private val iconDownloader: TemplateIconDownloader = service() private val settings: CoderSettingsState = service() @@ -145,7 +145,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod val workspace = tblView.selectedObject if (col == 2 && workspace != null) { - BrowserUtil.browse(coderClient.coderURL.toURI().resolve("/templates/${workspace.templateName}")) + BrowserUtil.browse(clientService.client.url.toURI().resolve("/templates/${workspace.templateName}")) } } } @@ -270,7 +270,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod private inner class GoToDashboardAction : AnActionButton(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.dashboard.text"), CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.dashboard.text"), CoderIcons.HOME) { override fun actionPerformed(p0: AnActionEvent) { - BrowserUtil.browse(coderClient.coderURL) + BrowserUtil.browse(clientService.client.url) } } @@ -282,7 +282,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod cs.launch { withContext(Dispatchers.IO) { try { - coderClient.startWorkspace(workspace.workspaceID, workspace.workspaceName) + clientService.client.startWorkspace(workspace.workspaceID, workspace.workspaceName) loadWorkspaces() } catch (e: WorkspaceResponseException) { logger.warn("Could not build workspace ${workspace.name}, reason: $e") @@ -301,7 +301,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod cs.launch { withContext(Dispatchers.IO) { try { - coderClient.updateWorkspace(workspace.workspaceID, workspace.workspaceName, workspace.lastBuildTransition, workspace.templateID) + clientService.client.updateWorkspace(workspace.workspaceID, workspace.workspaceName, workspace.lastBuildTransition, workspace.templateID) loadWorkspaces() } catch (e: WorkspaceResponseException) { logger.warn("Could not update workspace ${workspace.name}, reason: $e") @@ -322,7 +322,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod cs.launch { withContext(Dispatchers.IO) { try { - coderClient.stopWorkspace(workspace.workspaceID, workspace.workspaceName) + clientService.client.stopWorkspace(workspace.workspaceID, workspace.workspaceName) loadWorkspaces() } catch (e: WorkspaceResponseException) { logger.warn("Could not stop workspace ${workspace.name}, reason: $e") @@ -336,7 +336,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod private inner class CreateWorkspaceAction : AnActionButton(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.create.text"), CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.create.text"), CoderIcons.CREATE) { override fun actionPerformed(p0: AnActionEvent) { - BrowserUtil.browse(coderClient.coderURL.toURI().resolve("/templates")) + BrowserUtil.browse(clientService.client.url.toURI().resolve("/templates")) } } @@ -373,8 +373,8 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } private fun updateWorkspaceActions() { - goToDashboardAction.isEnabled = coderClient.isReady - createWorkspaceAction.isEnabled = coderClient.isReady + goToDashboardAction.isEnabled = clientService.isReady + createWorkspaceAction.isEnabled = clientService.isReady when (tableOfWorkspaces.selectedObject?.workspaceStatus) { WorkspaceStatus.RUNNING -> { startWorkspaceAction.isEnabled = false @@ -623,12 +623,12 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod */ private fun authenticate(url: URL, token: String) { logger.info("Authenticating to $url...") - coderClient.initClientSession(url, token) + clientService.initClientSession(url, token) try { - logger.info("Checking compatibility with Coder version ${coderClient.buildVersion}...") - CoderSemVer.checkVersionCompatibility(coderClient.buildVersion) - logger.info("${coderClient.buildVersion} is compatible") + logger.info("Checking compatibility with Coder version ${clientService.buildVersion}...") + CoderSemVer.checkVersionCompatibility(clientService.buildVersion) + logger.info("${clientService.buildVersion} is compatible") } catch (e: InvalidVersionException) { logger.warn(e) notificationBanner.apply { @@ -636,7 +636,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod showWarning( CoderGatewayBundle.message( "gateway.connector.view.coder.workspaces.invalid.coder.version", - coderClient.buildVersion + clientService.buildVersion ) ) } @@ -644,7 +644,7 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod logger.warn(e) notificationBanner.apply { component.isVisible = true - showWarning(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.unsupported.coder.version", coderClient.buildVersion)) + showWarning(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.unsupported.coder.version", clientService.buildVersion)) } } @@ -658,13 +658,13 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod val ws = withContext(Dispatchers.IO) { val timeBeforeRequestingWorkspaces = System.currentTimeMillis() try { - val ws = coderClient.workspaces() + val ws = clientService.client.workspaces() val ams = ws.flatMap { it.toAgentModels() }.toSet() val timeAfterRequestingWorkspaces = System.currentTimeMillis() logger.info("Retrieving the workspaces took: ${timeAfterRequestingWorkspaces - timeBeforeRequestingWorkspaces} millis") return@withContext ams } catch (e: Exception) { - logger.error("Could not retrieve workspaces for ${coderClient.me.username} on ${coderClient.coderURL}. Reason: $e") + logger.error("Could not retrieve workspaces for ${clientService.me.username} on ${clientService.client.url}. Reason: $e") emptySet() } } From f702e6123c2e78e259dd994663e1495b47efd968 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 4 May 2023 14:44:37 -0800 Subject: [PATCH 2/9] 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. --- .../com/coder/gateway/WorkspaceParams.kt | 18 ++++++++++++++---- .../models/CoderWorkspacesWizardModel.kt | 1 + .../models/RecentWorkspaceConnection.kt | 12 ++++++++---- .../models/RecentWorkspaceConnectionState.kt | 6 +++--- .../com/coder/gateway/sdk/CoderCLIManager.kt | 2 +- .../steps/CoderLocateRemoteProjectStepView.kt | 2 ++ .../views/steps/CoderWorkspacesStepView.kt | 5 +++++ 7 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/WorkspaceParams.kt b/src/main/kotlin/com/coder/gateway/WorkspaceParams.kt index b28507f9..c6ad4ae7 100644 --- a/src/main/kotlin/com/coder/gateway/WorkspaceParams.kt +++ b/src/main/kotlin/com/coder/gateway/WorkspaceParams.kt @@ -23,6 +23,7 @@ private const val IDE_PRODUCT_CODE = "ide_product_code" private const val IDE_BUILD_NUMBER = "ide_build_number" private const val IDE_PATH_ON_HOST = "ide_path_on_host" private const val WEB_TERMINAL_LINK = "web_terminal_link" +private const val CONFIG_DIRECTORY = "config_directory" private val localTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MMM-dd HH:mm") @@ -33,7 +34,8 @@ fun RecentWorkspaceConnection.toWorkspaceParams(): Map { PROJECT_PATH to this.projectPath!!, IDE_PRODUCT_CODE to IntelliJPlatformProduct.fromProductCode(this.ideProductCode!!)!!.productCode, IDE_BUILD_NUMBER to "${this.ideBuildNumber}", - WEB_TERMINAL_LINK to "${this.webTerminalLink}" + WEB_TERMINAL_LINK to "${this.webTerminalLink}", + CONFIG_DIRECTORY to "${this.configDirectory}" ) if (!this.downloadSource.isNullOrBlank()) { @@ -80,6 +82,12 @@ fun Map.withWebTerminalLink(webTerminalLink: String): Map.withConfigDirectory(dir: String): Map { + val map = this.toMutableMap() + map[CONFIG_DIRECTORY] = dir + return map +} + fun Map.areCoderType(): Boolean { return this[TYPE] == VALUE_FOR_TYPE && !this[CODER_WORKSPACE_HOSTNAME].isNullOrBlank() && !this[PROJECT_PATH].isNullOrBlank() } @@ -140,7 +148,8 @@ fun Map.toRecentWorkspaceConnection(): RecentWorkspaceConnection this[IDE_BUILD_NUMBER]!!, this[IDE_DOWNLOAD_LINK]!!, null, - this[WEB_TERMINAL_LINK]!! + this[WEB_TERMINAL_LINK]!!, + this[CONFIG_DIRECTORY]!! ) else RecentWorkspaceConnection( this.workspaceHostname(), this.projectPath(), @@ -149,6 +158,7 @@ fun Map.toRecentWorkspaceConnection(): RecentWorkspaceConnection this[IDE_BUILD_NUMBER]!!, null, this[IDE_PATH_ON_HOST], - this[WEB_TERMINAL_LINK]!! + this[WEB_TERMINAL_LINK]!!, + this[CONFIG_DIRECTORY]!! ) -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt b/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt index c553cb14..b2a8f9fb 100644 --- a/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt +++ b/src/main/kotlin/com/coder/gateway/models/CoderWorkspacesWizardModel.kt @@ -11,4 +11,5 @@ data class CoderWorkspacesWizardModel( var token: Pair? = null, var selectedWorkspace: WorkspaceAgentModel? = null, var useExistingToken: Boolean = false, + var configDirectory: String = "", ) diff --git a/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt b/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt index 707216aa..b8e34609 100644 --- a/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt +++ b/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt @@ -4,7 +4,7 @@ import com.intellij.openapi.components.BaseState import com.intellij.util.xmlb.annotations.Attribute class RecentWorkspaceConnection() : BaseState(), Comparable { - constructor(hostname: String, prjPath: String, openedAt: String, productCode: String, buildNumber: String, source: String?, idePath: String?, terminalLink: String) : this() { + constructor(hostname: String, prjPath: String, openedAt: String, productCode: String, buildNumber: String, source: String?, idePath: String?, terminalLink: String, config: String) : this() { coderWorkspaceHostname = hostname projectPath = prjPath lastOpened = openedAt @@ -13,6 +13,7 @@ class RecentWorkspaceConnection() : BaseState(), Comparable() fun add(connection: RecentWorkspaceConnection): Boolean { - // if the item is already there but with a different last update timestamp, remove it + // If the item is already there but with a different last updated + // timestamp or config directory, remove it. recentConnections.remove(connection) - // and add it again with the new timestamp val result = recentConnections.add(connection) if (result) incrementModificationCount() return result @@ -21,4 +21,4 @@ class RecentWorkspaceConnectionState : BaseState() { if (result) incrementModificationCount() return result } -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt b/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt index cd0c9802..4ea27219 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/CoderCLIManager.kt @@ -31,7 +31,7 @@ class CoderCLIManager @JvmOverloads constructor( ) { var remoteBinaryURL: URL var localBinaryPath: Path - private var coderConfigPath: Path + var coderConfigPath: Path init { val binaryName = getCoderCLIForOS(getOS(), getArch()) diff --git a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt index 0a9959d4..2a886eff 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderLocateRemoteProjectStepView.kt @@ -16,6 +16,7 @@ import com.coder.gateway.sdk.toURL import com.coder.gateway.sdk.withPath import com.coder.gateway.toWorkspaceParams import com.coder.gateway.views.LazyBrowserLink +import com.coder.gateway.withConfigDirectory import com.coder.gateway.withProjectPath import com.coder.gateway.withWebTerminalLink import com.coder.gateway.withWorkspaceHostname @@ -334,6 +335,7 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea .withWorkspaceHostname(CoderCLIManager.getHostName(deploymentURL, selectedWorkspace)) .withProjectPath(tfProject.text) .withWebTerminalLink("${terminalLink.url}") + .withConfigDirectory(wizardModel.configDirectory) ) } return true 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 01208316..3f6cc3c6 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -757,6 +757,11 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod ) cliManager.configSsh(tableOfWorkspaces.items) + // The config directory can be used to pull the URL and token in + // order to query this workspace's status in other flows, for + // example from the recent connections screen. + wizardModel.configDirectory = cliManager.coderConfigPath.toString() + logger.info("Opening IDE and Project Location window for ${workspace.name}") return true } From 04796e81f5ed0d084eab0853904fd9e0493fb657 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 4 May 2023 15:54:52 -0800 Subject: [PATCH 3/9] 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. --- .../kotlin/com/coder/gateway/WorkspaceParams.kt | 17 ++++++++++++++--- .../gateway/models/RecentWorkspaceConnection.kt | 6 +++++- .../steps/CoderLocateRemoteProjectStepView.kt | 4 +++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/WorkspaceParams.kt b/src/main/kotlin/com/coder/gateway/WorkspaceParams.kt index c6ad4ae7..a30c2b76 100644 --- a/src/main/kotlin/com/coder/gateway/WorkspaceParams.kt +++ b/src/main/kotlin/com/coder/gateway/WorkspaceParams.kt @@ -24,6 +24,7 @@ private const val IDE_BUILD_NUMBER = "ide_build_number" private const val IDE_PATH_ON_HOST = "ide_path_on_host" private const val WEB_TERMINAL_LINK = "web_terminal_link" private const val CONFIG_DIRECTORY = "config_directory" +private const val NAME = "name" private val localTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MMM-dd HH:mm") @@ -35,7 +36,8 @@ fun RecentWorkspaceConnection.toWorkspaceParams(): Map { IDE_PRODUCT_CODE to IntelliJPlatformProduct.fromProductCode(this.ideProductCode!!)!!.productCode, IDE_BUILD_NUMBER to "${this.ideBuildNumber}", WEB_TERMINAL_LINK to "${this.webTerminalLink}", - CONFIG_DIRECTORY to "${this.configDirectory}" + CONFIG_DIRECTORY to "${this.configDirectory}", + NAME to "${this.name}" ) if (!this.downloadSource.isNullOrBlank()) { @@ -88,6 +90,13 @@ fun Map.withConfigDirectory(dir: String): Map { return map } +fun Map.withName(name: String): Map { + val map = this.toMutableMap() + map[NAME] = name + return map +} + + fun Map.areCoderType(): Boolean { return this[TYPE] == VALUE_FOR_TYPE && !this[CODER_WORKSPACE_HOSTNAME].isNullOrBlank() && !this[PROJECT_PATH].isNullOrBlank() } @@ -149,7 +158,8 @@ fun Map.toRecentWorkspaceConnection(): RecentWorkspaceConnection this[IDE_DOWNLOAD_LINK]!!, null, this[WEB_TERMINAL_LINK]!!, - this[CONFIG_DIRECTORY]!! + this[CONFIG_DIRECTORY]!!, + this[NAME]!!, ) else RecentWorkspaceConnection( this.workspaceHostname(), this.projectPath(), @@ -159,6 +169,7 @@ fun Map.toRecentWorkspaceConnection(): RecentWorkspaceConnection null, this[IDE_PATH_ON_HOST], this[WEB_TERMINAL_LINK]!!, - this[CONFIG_DIRECTORY]!! + this[CONFIG_DIRECTORY]!!, + this[NAME]!!, ) } diff --git a/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt b/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt index b8e34609..f5155eba 100644 --- a/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt +++ b/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt @@ -4,7 +4,7 @@ import com.intellij.openapi.components.BaseState import com.intellij.util.xmlb.annotations.Attribute class RecentWorkspaceConnection() : BaseState(), Comparable { - constructor(hostname: String, prjPath: String, openedAt: String, productCode: String, buildNumber: String, source: String?, idePath: String?, terminalLink: String, config: String) : this() { + constructor(hostname: String, prjPath: String, openedAt: String, productCode: String, buildNumber: String, source: String?, idePath: String?, terminalLink: String, config: String, name: String) : this() { coderWorkspaceHostname = hostname projectPath = prjPath lastOpened = openedAt @@ -14,6 +14,7 @@ class RecentWorkspaceConnection() : BaseState(), Comparable - logger.info("Retrieving IDEs...(attempt $attempt)") + logger.info("Retrieving IDEs... (attempt $attempt)") if (attempt > 1) { cbIDE.renderer = IDECellRenderer(CoderGatewayBundle.message("gateway.connector.view.coder.retrieve.ides.retry", attempt)) } @@ -336,6 +337,7 @@ class CoderLocateRemoteProjectStepView(private val setNextButtonEnabled: (Boolea .withProjectPath(tfProject.text) .withWebTerminalLink("${terminalLink.url}") .withConfigDirectory(wizardModel.configDirectory) + .withName(selectedWorkspace.name) ) } return true From 7a40aa0ff80f7554f1a6e60f2623f1e52ebc0647 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 4 May 2023 15:04:00 -0800 Subject: [PATCH 4/9] Standardize some casing --- .../messages/CoderGatewayBundle.properties | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/main/resources/messages/CoderGatewayBundle.properties b/src/main/resources/messages/CoderGatewayBundle.properties index c5e7e8b0..fc2b3750 100644 --- a/src/main/resources/messages/CoderGatewayBundle.properties +++ b/src/main/resources/messages/CoderGatewayBundle.properties @@ -11,11 +11,11 @@ gateway.connector.view.coder.workspaces.header.text=Coder Workspaces gateway.connector.view.coder.workspaces.comment=Self-hosted developer workspaces in the cloud or on-premises. Coder empowers developers with secure, consistent, and fast developer workspaces. gateway.connector.view.coder.workspaces.connect.text=Connect gateway.connector.view.coder.workspaces.cli.downloader.dialog.title=Authenticate and setup Coder -gateway.connector.view.coder.workspaces.next.text=Select IDE and Project -gateway.connector.view.coder.workspaces.dashboard.text=Open Dashboard -gateway.connector.view.coder.workspaces.start.text=Start Workspace -gateway.connector.view.coder.workspaces.stop.text=Stop Workspace -gateway.connector.view.coder.workspaces.update.text=Update Workspace Template +gateway.connector.view.coder.workspaces.next.text=Select IDE and project +gateway.connector.view.coder.workspaces.dashboard.text=Open dashboard +gateway.connector.view.coder.workspaces.start.text=Start workspace +gateway.connector.view.coder.workspaces.stop.text=Stop workspace +gateway.connector.view.coder.workspaces.update.text=Update workspace template gateway.connector.view.coder.workspaces.create.text=Create workspace gateway.connector.view.coder.workspaces.unsupported.os.info=Gateway supports only Linux machines. Support for macOS and Windows is planned. gateway.connector.view.coder.workspaces.invalid.coder.version=Could not parse Coder version {0}. Coder Gateway plugin might not be compatible with this version. Connect to a Coder workspace manually @@ -37,10 +37,12 @@ gateway.connector.view.coder.remoteproject.choose.text=Choose IDE and project fo gateway.connector.view.coder.remoteproject.ide.download.comment=This IDE will be downloaded from jetbrains.com and installed to the default path on the remote host. gateway.connector.view.coder.remoteproject.ide.installed.comment=This IDE is already installed and will be used as-is. gateway.connector.view.coder.remoteproject.ide.none.comment=No IDE selected. -gateway.connector.recentconnections.title=Recent Coder Workspaces -gateway.connector.recentconnections.new.wizard.button.tooltip=Open a new Coder Workspace -gateway.connector.recentconnections.remove.button.tooltip=Remove from Recent Connections -gateway.connector.recentconnections.terminal.button.tooltip=Open SSH Web Terminal +gateway.connector.recent-connections.title=Recent Coder workspaces +gateway.connector.recent-connections.new.wizard.button.tooltip=Open a new Coder workspace +gateway.connector.recent-connections.remove.button.tooltip=Remove from recent connections +gateway.connector.recent-connections.terminal.button.tooltip=Open SSH web terminal +gateway.connector.recent-connections.start.button.tooltip=Start workspace +gateway.connector.recent-connections.stop.button.tooltip=Stop workspace gateway.connector.coder.connection.provider.title=Connecting to Coder workspace... gateway.connector.coder.connecting=Connecting... gateway.connector.coder.connecting.retry=Connecting (attempt {0})... From e743907acd864c86df8d358d162ab6a593c7319c Mon Sep 17 00:00:00 2001 From: Asher Date: Tue, 2 May 2023 16:56:18 -0800 Subject: [PATCH 5/9] 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. --- ...erGatewayRecentWorkspaceConnectionsView.kt | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt b/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt index 130d2ea5..1cdbcc9e 100644 --- a/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt +++ b/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt @@ -73,8 +73,9 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: override fun textChanged(e: DocumentEvent) { val toSearchFor = this@applyToComponent.text val filteredConnections = recentConnectionsService.getAllRecentConnections() - .filter { it.coderWorkspaceHostname?.lowercase(Locale.getDefault())?.contains(toSearchFor) ?: false || it.projectPath?.lowercase(Locale.getDefault())?.contains(toSearchFor) ?: false } - updateContentView(filteredConnections.groupBy { it.coderWorkspaceHostname }) + .filter { it.coderWorkspaceHostname != null } + .filter { it.coderWorkspaceHostname!!.lowercase(Locale.getDefault()).contains(toSearchFor) || it.projectPath?.lowercase(Locale.getDefault())?.contains(toSearchFor) ?: false } + updateContentView(filteredConnections.groupBy { it.coderWorkspaceHostname!! }) } }) }.component @@ -105,23 +106,24 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: override fun getRecentsTitle() = CoderGatewayBundle.message("gateway.connector.title") override fun updateRecentView() { - updateContentView(recentConnectionsService.getAllRecentConnections().groupBy { it.coderWorkspaceHostname }) + val groupedConnections = recentConnectionsService.getAllRecentConnections() + .filter { it.coderWorkspaceHostname != null } + .groupBy { it.coderWorkspaceHostname!! } + updateContentView(groupedConnections) } - private fun updateContentView(groupedConnections: Map>) { + private fun updateContentView(groupedConnections: Map>) { recentWorkspacesContentPanel.viewport.view = panel { groupedConnections.entries.forEach { (hostname, recentConnections) -> row { - if (hostname != null) { - label(hostname).applyToComponent { - font = JBFont.h3().asBold() - }.align(AlignX.LEFT).gap(RightGap.SMALL) - actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recentconnections.terminal.button.tooltip"), "", CoderIcons.OPEN_TERMINAL) { - override fun actionPerformed(e: AnActionEvent) { - BrowserUtil.browse(recentConnections[0].webTerminalLink ?: "") - } - }) - } + label(hostname).applyToComponent { + font = JBFont.h3().asBold() + }.align(AlignX.LEFT).gap(RightGap.SMALL) + actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recentconnections.terminal.button.tooltip"), "", CoderIcons.OPEN_TERMINAL) { + override fun actionPerformed(e: AnActionEvent) { + BrowserUtil.browse(recentConnections[0].webTerminalLink ?: "") + } + }) }.topGap(TopGap.MEDIUM) recentConnections.forEach { connectionDetails -> @@ -141,7 +143,7 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recentconnections.remove.button.tooltip"), "", CoderIcons.DELETE) { override fun actionPerformed(e: AnActionEvent) { recentConnectionsService.removeConnection(connectionDetails) - updateContentView(recentConnectionsService.getAllRecentConnections().groupBy { it.coderWorkspaceHostname }) + updateRecentView() } }) } From 5b9fdcb382011291ac9d01272a847ec09f99ac1a Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 5 May 2023 10:53:00 -0800 Subject: [PATCH 6/9] Break out toAgentModels We will need this in the recent connections view as well. --- .../coder/gateway/sdk/v2/models/Workspace.kt | 51 +++++++++++++- .../views/steps/CoderWorkspacesStepView.kt | 69 +++---------------- 2 files changed, 60 insertions(+), 60 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/sdk/v2/models/Workspace.kt b/src/main/kotlin/com/coder/gateway/sdk/v2/models/Workspace.kt index 8ccecaa2..129489c6 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/v2/models/Workspace.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/v2/models/Workspace.kt @@ -1,5 +1,10 @@ package com.coder.gateway.sdk.v2.models +import com.coder.gateway.models.WorkspaceAgentModel +import com.coder.gateway.models.WorkspaceAndAgentStatus +import com.coder.gateway.models.WorkspaceVersionStatus +import com.coder.gateway.sdk.Arch +import com.coder.gateway.sdk.OS import com.google.gson.annotations.SerializedName import java.time.Instant import java.util.UUID @@ -24,4 +29,48 @@ data class Workspace( @SerializedName("autostart_schedule") val autostartSchedule: String?, @SerializedName("ttl_ms") val ttlMillis: Long?, @SerializedName("last_used_at") val lastUsedAt: Instant, -) \ No newline at end of file +) + +fun Workspace.toAgentModels(): Set { + 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, + ) + + 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 + ) + return setOf(wm) + } + return wam +} 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 3f6cc3c6..73007ea4 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -7,7 +7,6 @@ import com.coder.gateway.models.TokenSource import com.coder.gateway.models.WorkspaceAgentModel import com.coder.gateway.models.WorkspaceAndAgentStatus import com.coder.gateway.models.WorkspaceVersionStatus -import com.coder.gateway.sdk.Arch import com.coder.gateway.sdk.CoderCLIManager import com.coder.gateway.sdk.CoderRestClientService import com.coder.gateway.sdk.CoderSemVer @@ -20,8 +19,8 @@ import com.coder.gateway.sdk.ex.AuthenticationResponseException 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.v2.models.toAgentModels import com.coder.gateway.sdk.withPath import com.coder.gateway.services.CoderSettingsState import com.intellij.ide.ActivityTracker @@ -659,7 +658,15 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod val timeBeforeRequestingWorkspaces = System.currentTimeMillis() try { val ws = clientService.client.workspaces() - val ams = ws.flatMap { it.toAgentModels() }.toSet() + val ams = ws.flatMap { it.toAgentModels() } + ams.forEach { + cs.launch(Dispatchers.IO) { + it.templateIcon = iconDownloader.load(it.templateIconPath, it.name) + withContext(Dispatchers.Main) { + tableOfWorkspaces.updateUI() + } + } + } val timeAfterRequestingWorkspaces = System.currentTimeMillis() logger.info("Retrieving the workspaces took: ${timeAfterRequestingWorkspaces - timeBeforeRequestingWorkspaces} millis") return@withContext ams @@ -675,62 +682,6 @@ class CoderWorkspacesStepView(val setNextButtonEnabled: (Boolean) -> Unit) : Cod } } - private fun Workspace.toAgentModels(): Set { - 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() - } - } - 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 setOf(wm) - } - return wam - } - override fun onPrevious() { super.onPrevious() logger.info("Going back to the main view") From fb407cd2f9e06787f6428eb7eaede934341b1472 Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 5 May 2023 11:04:13 -0800 Subject: [PATCH 7/9] Split agent status icon and label So we can show just the icon in the recent connections view. --- .../com/coder/gateway/icons/CoderIcons.kt | 6 ++- .../gateway/models/WorkspaceAndAgentStatus.kt | 54 +++++++++---------- .../views/steps/CoderWorkspacesStepView.kt | 6 +-- src/main/resources/off.svg | 6 +++ src/main/resources/pending.svg | 7 +++ src/main/resources/running.svg | 6 +++ 6 files changed, 54 insertions(+), 31 deletions(-) create mode 100644 src/main/resources/off.svg create mode 100644 src/main/resources/pending.svg create mode 100644 src/main/resources/running.svg diff --git a/src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt b/src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt index c2199a41..1930b0fa 100644 --- a/src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt +++ b/src/main/kotlin/com/coder/gateway/icons/CoderIcons.kt @@ -8,6 +8,10 @@ object CoderIcons { val OPEN_TERMINAL = IconLoader.getIcon("open_terminal.svg", javaClass) + val PENDING = IconLoader.getIcon("pending.svg", javaClass) + val RUNNING = IconLoader.getIcon("running.svg", javaClass) + val OFF = IconLoader.getIcon("off.svg", javaClass) + val HOME = IconLoader.getIcon("homeFolder.svg", javaClass) val CREATE = IconLoader.getIcon("create.svg", javaClass) val RUN = IconLoader.getIcon("run.svg", javaClass) @@ -55,4 +59,4 @@ object CoderIcons { val Y = IconLoader.getIcon("y.svg", javaClass) val Z = IconLoader.getIcon("z.svg", javaClass) -} \ 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 index 2ffaa43b..1238e147 100644 --- a/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt +++ b/src/main/kotlin/com/coder/gateway/models/WorkspaceAndAgentStatus.kt @@ -1,44 +1,46 @@ package com.coder.gateway.models +import com.coder.gateway.icons.CoderIcons 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 +import javax.swing.Icon /** * 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) { +enum class WorkspaceAndAgentStatus(val icon: Icon, 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."), + QUEUED(CoderIcons.PENDING, "Queued", "The workspace is queueing to start."), + STARTING(CoderIcons.PENDING, "Starting", "The workspace is starting."), + FAILED(CoderIcons.OFF, "Failed", "The workspace has failed to start."), + DELETING(CoderIcons.PENDING, "Deleting", "The workspace is being deleted."), + DELETED(CoderIcons.OFF, "Deleted", "The workspace has been deleted."), + STOPPING(CoderIcons.PENDING, "Stopping", "The workspace is stopping."), + STOPPED(CoderIcons.OFF, "Stopped", "The workspace has stopped."), + CANCELING(CoderIcons.PENDING, "Canceling action", "The workspace is being canceled."), + CANCELED(CoderIcons.OFF, "Canceled action", "The workspace has been canceled."), + RUNNING(CoderIcons.RUN, "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."); + CONNECTING(CoderIcons.PENDING, "Connecting", "The agent is connecting."), + DISCONNECTED(CoderIcons.OFF, "Disconnected", "The agent has disconnected."), + TIMEOUT(CoderIcons.PENDING, "Timeout", "The agent is taking longer than expected to connect."), + AGENT_STARTING(CoderIcons.PENDING, "Starting", "The startup script is running."), + AGENT_STARTING_READY(CoderIcons.RUNNING, "Starting", "The startup script is still running but the agent is ready to accept connections."), + CREATED(CoderIcons.PENDING, "Created", "The agent has been created."), + START_ERROR(CoderIcons.RUNNING, "Started with error", "The agent is ready but the startup script errored."), + START_TIMEOUT(CoderIcons.PENDING, "Starting", "The startup script is taking longer than expected."), + START_TIMEOUT_READY(CoderIcons.RUNNING, "Starting", "The startup script is taking longer than expected but the agent is ready to accept connections."), + SHUTTING_DOWN(CoderIcons.PENDING, "Shutting down", "The agent is shutting down."), + SHUTDOWN_ERROR(CoderIcons.OFF, "Shutdown with error", "The agent shut down but the shutdown script errored."), + SHUTDOWN_TIMEOUT(CoderIcons.OFF, "Shutting down", "The shutdown script is taking longer than expected."), + OFF(CoderIcons.OFF, "Off", "The agent has shut down."), + READY(CoderIcons.RUNNING, "Ready", "The agent is ready to accept connections."); fun statusColor(): JBColor = when (this) { READY, AGENT_STARTING_READY, START_TIMEOUT_READY -> JBColor.GREEN @@ -100,7 +102,5 @@ enum class WorkspaceAndAgentStatus(val label: String, val description: String) { 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/views/steps/CoderWorkspacesStepView.kt b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt index 73007ea4..066b71eb 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -5,7 +5,6 @@ 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.WorkspaceAndAgentStatus import com.coder.gateway.models.WorkspaceVersionStatus import com.coder.gateway.sdk.CoderCLIManager import com.coder.gateway.sdk.CoderRestClientService @@ -860,12 +859,13 @@ class WorkspacesTableModel : ListTableModel( override fun getRenderer(item: WorkspaceAgentModel?): TableCellRenderer { return object : DefaultTableCellRenderer() { + private val workspace = item 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 = WorkspaceAndAgentStatus.from(value).statusColor() - toolTipText = WorkspaceAndAgentStatus.from(value).description + foreground = workspace?.agentStatus?.statusColor() + toolTipText = workspace?.agentStatus?.description } font = table.tableHeader.font border = JBUI.Borders.empty(0, 8) diff --git a/src/main/resources/off.svg b/src/main/resources/off.svg new file mode 100644 index 00000000..fed5a568 --- /dev/null +++ b/src/main/resources/off.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/main/resources/pending.svg b/src/main/resources/pending.svg new file mode 100644 index 00000000..2c98bace --- /dev/null +++ b/src/main/resources/pending.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/main/resources/running.svg b/src/main/resources/running.svg new file mode 100644 index 00000000..ff92e3f1 --- /dev/null +++ b/src/main/resources/running.svg @@ -0,0 +1,6 @@ + + + + + + From 15e13ef7acc92d1287e51103e03d7870034239a3 Mon Sep 17 00:00:00 2001 From: Asher Date: Fri, 5 May 2023 13:10:15 -0800 Subject: [PATCH 8/9] 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. --- ...erGatewayRecentWorkspaceConnectionsView.kt | 205 ++++++++++++++++-- 1 file changed, 184 insertions(+), 21 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt b/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt index 1cdbcc9e..e38bef7b 100644 --- a/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt +++ b/src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt @@ -6,6 +6,11 @@ import com.coder.gateway.CoderGatewayBundle import com.coder.gateway.CoderGatewayConstants import com.coder.gateway.icons.CoderIcons import com.coder.gateway.models.RecentWorkspaceConnection +import com.coder.gateway.models.WorkspaceAgentModel +import com.coder.gateway.sdk.CoderRestClient +import com.coder.gateway.sdk.toURL +import com.coder.gateway.sdk.v2.models.WorkspaceStatus +import com.coder.gateway.sdk.v2.models.toAgentModels import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService import com.coder.gateway.toWorkspaceParams import com.intellij.icons.AllIcons @@ -13,9 +18,11 @@ import com.intellij.ide.BrowserUtil import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.components.service +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.DumbAwareAction import com.intellij.openapi.ui.panel.ComponentPanelBuilder import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager +import com.intellij.ui.AnimatedIcon import com.intellij.ui.DocumentAdapter import com.intellij.ui.SearchTextField import com.intellij.ui.components.ActionLink @@ -26,23 +33,45 @@ import com.intellij.ui.dsl.builder.BottomGap import com.intellij.ui.dsl.builder.RightGap import com.intellij.ui.dsl.builder.TopGap import com.intellij.ui.dsl.builder.panel +import com.intellij.ui.util.maximumWidth +import com.intellij.ui.util.minimumWidth +import com.intellij.util.io.readText import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import com.jetbrains.gateway.api.GatewayRecentConnections import com.jetbrains.gateway.api.GatewayUI import com.jetbrains.gateway.ssh.IntelliJPlatformProduct import com.jetbrains.rd.util.lifetime.Lifetime import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.awt.Component import java.awt.Dimension -import java.util.* +import java.nio.file.Path +import java.util.Locale import javax.swing.JComponent import javax.swing.JLabel import javax.swing.event.DocumentEvent +/** + * DeploymentInfo contains everything needed to query the API for a deployment + * along with the latest workspace responses. + */ +data class DeploymentInfo( + // Null if unable to create the client (config directory did not exist). + var client: CoderRestClient? = null, + // Null if we have not fetched workspaces yet. + var workspaces: List? = null, + // Null if there have not been any errors yet. + var error: String? = null, +) + class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: (Component) -> Unit) : GatewayRecentConnections, Disposable { private val recentConnectionsService = service() private val cs = CoroutineScope(Dispatchers.Main) @@ -50,16 +79,24 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: private val recentWorkspacesContentPanel = JBScrollPane() private lateinit var searchBar: SearchTextField + private var filterString: String? = null override val id = CoderGatewayConstants.GATEWAY_RECENT_CONNECTIONS_ID override val recentsIcon = CoderIcons.LOGO_16 + /** + * API clients and workspaces grouped by deployment and keyed by their + * config directory. + */ + private var deployments: Map = emptyMap() + private var poller: Job? = null + override fun createRecentsView(lifetime: Lifetime): JComponent { return panel { indent { row { - label(CoderGatewayBundle.message("gateway.connector.recentconnections.title")).applyToComponent { + label(CoderGatewayBundle.message("gateway.connector.recent-connections.title")).applyToComponent { font = JBFont.h3().asBold() } panel { @@ -71,17 +108,14 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: textEditor.border = JBUI.Borders.empty(2, 5, 2, 0) addDocumentListener(object : DocumentAdapter() { override fun textChanged(e: DocumentEvent) { - val toSearchFor = this@applyToComponent.text - val filteredConnections = recentConnectionsService.getAllRecentConnections() - .filter { it.coderWorkspaceHostname != null } - .filter { it.coderWorkspaceHostname!!.lowercase(Locale.getDefault()).contains(toSearchFor) || it.projectPath?.lowercase(Locale.getDefault())?.contains(toSearchFor) ?: false } - updateContentView(filteredConnections.groupBy { it.coderWorkspaceHostname!! }) + filterString = this@applyToComponent.text.trim() + updateContentView() } }) }.component actionButton( - object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recentconnections.new.wizard.button.tooltip"), null, AllIcons.General.Add) { + object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recent-connections.new.wizard.button.tooltip"), null, AllIcons.General.Add) { override fun actionPerformed(e: AnActionEvent) { setContentCallback(CoderGatewayConnectorWizardWrapperView().component) } @@ -106,27 +140,79 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: override fun getRecentsTitle() = CoderGatewayBundle.message("gateway.connector.title") override fun updateRecentView() { - val groupedConnections = recentConnectionsService.getAllRecentConnections() - .filter { it.coderWorkspaceHostname != null } - .groupBy { it.coderWorkspaceHostname!! } - updateContentView(groupedConnections) + triggerWorkspacePolling() + updateContentView() } - private fun updateContentView(groupedConnections: Map>) { + private fun updateContentView() { + val connections = recentConnectionsService.getAllRecentConnections() + .filter { it.coderWorkspaceHostname != null } + .filter { matchesFilter(it) } + .groupBy { it.coderWorkspaceHostname!! } recentWorkspacesContentPanel.viewport.view = panel { - groupedConnections.entries.forEach { (hostname, recentConnections) -> + connections.forEach { (hostname, connections) -> + // The config directory and name will not exist on connections + // made with 2.3.0 and earlier. + val name = connections.firstNotNullOfOrNull { it.name } + val workspaceName = name?.split(".", limit = 2)?.first() + val configDirectory = connections.firstNotNullOfOrNull { it.configDirectory } + val deployment = deployments[configDirectory] + val workspace = deployment?.workspaces + ?.firstOrNull { it.name == name || it.workspaceName == workspaceName } row { - label(hostname).applyToComponent { + (if (workspace != null) { + icon(workspace.agentStatus.icon).applyToComponent { + foreground = workspace.agentStatus.statusColor() + toolTipText = workspace.agentStatus.description + } + } else if (configDirectory == null || workspaceName == null) { + icon(CoderIcons.UNKNOWN).applyToComponent { + toolTipText = "Unable to determine workspace status because the configuration directory and/or name were not recorded. To fix, add the connection again." + } + } else if (deployment?.error != null) { + icon(UIUtil.getBalloonErrorIcon()).applyToComponent { + toolTipText = deployment.error + } + } else if (deployment?.workspaces != null) { + icon(UIUtil.getBalloonErrorIcon()).applyToComponent { + toolTipText = "Workspace $workspaceName does not exist" + } + } else { + icon(AnimatedIcon.Default.INSTANCE).applyToComponent { + toolTipText = "Querying workspace status..." + } + }).align(AlignX.LEFT).gap(RightGap.SMALL).applyToComponent { + maximumWidth = JBUI.scale(16) + minimumWidth = JBUI.scale(16) + } + label(hostname.removePrefix("coder-jetbrains--")).applyToComponent { font = JBFont.h3().asBold() }.align(AlignX.LEFT).gap(RightGap.SMALL) - actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recentconnections.terminal.button.tooltip"), "", CoderIcons.OPEN_TERMINAL) { + label("").resizableColumn().align(AlignX.FILL) + actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recent-connections.start.button.tooltip"), "", CoderIcons.RUN) { + override fun actionPerformed(e: AnActionEvent) { + if (workspace != null) { + deployment.client?.startWorkspace(workspace.workspaceID, workspace.workspaceName) + cs.launch { fetchWorkspaces() } + } + } + }).applyToComponent { isEnabled = listOf(WorkspaceStatus.STOPPED, WorkspaceStatus.FAILED).contains(workspace?.workspaceStatus) }.gap(RightGap.SMALL) + actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recent-connections.stop.button.tooltip"), "", CoderIcons.STOP) { override fun actionPerformed(e: AnActionEvent) { - BrowserUtil.browse(recentConnections[0].webTerminalLink ?: "") + if (workspace != null) { + deployment.client?.stopWorkspace(workspace.workspaceID, workspace.workspaceName) + cs.launch { fetchWorkspaces() } + } + } + }).applyToComponent { isEnabled = workspace?.workspaceStatus == WorkspaceStatus.RUNNING }.gap(RightGap.SMALL) + actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recent-connections.terminal.button.tooltip"), "", CoderIcons.OPEN_TERMINAL) { + override fun actionPerformed(e: AnActionEvent) { + BrowserUtil.browse(connections[0].webTerminalLink ?: "") } }) }.topGap(TopGap.MEDIUM) - recentConnections.forEach { connectionDetails -> + connections.forEach { connectionDetails -> val product = IntelliJPlatformProduct.fromProductCode(connectionDetails.ideProductCode!!)!! row { icon(product.icon) @@ -140,7 +226,7 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: foreground = JBUI.CurrentTheme.ContextHelp.FOREGROUND font = ComponentPanelBuilder.getCommentFont(font) } - actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recentconnections.remove.button.tooltip"), "", CoderIcons.DELETE) { + actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recent-connections.remove.button.tooltip"), "", CoderIcons.DELETE) { override fun actionPerformed(e: AnActionEvent) { recentConnectionsService.removeConnection(connectionDetails) updateRecentView() @@ -151,11 +237,88 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: } }.apply { background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() - border = JBUI.Borders.empty(12, 0, 0, 12) + border = JBUI.Borders.empty(12, 0, 12, 12) } } + /** + * Return true if the connection matches the current filter. + */ + private fun matchesFilter(connection: RecentWorkspaceConnection): Boolean { + return filterString.isNullOrBlank() + || connection.coderWorkspaceHostname?.lowercase(Locale.getDefault())?.contains(filterString!!) == true + || connection.projectPath?.lowercase(Locale.getDefault())?.contains(filterString!!) == true + } + + /** + * Start polling for workspaces if not already started. + */ + private fun triggerWorkspacePolling() { + deployments = recentConnectionsService.getAllRecentConnections() + .mapNotNull { it.configDirectory }.toSet() + .associateWith { dir -> + deployments[dir] ?: try { + val url = Path.of(dir).resolve("url").readText() + val token = Path.of(dir).resolve("session").readText() + DeploymentInfo(CoderRestClient(url.toURL(), token)) + } catch (e: Exception) { + logger.error("Unable to create client from $dir", e) + DeploymentInfo(error = "Error trying to read $dir: ${e.message}") + } + } + + if (poller?.isActive == true) { + logger.info("Refusing to start already-started poller") + return + } + + logger.info("Starting poll loop") + poller = cs.launch { + while (isActive) { + if (recentWorkspacesContentPanel.isShowing) { + fetchWorkspaces() + } else { + logger.info("View not visible; aborting poll") + poller?.cancel() + } + delay(5000) + } + } + } + + /** + * Update each deployment with their latest workspaces. + */ + private suspend fun fetchWorkspaces() { + withContext(Dispatchers.IO) { + deployments.values + .filter { it.error == null && it.client != null} + .forEach { deployment -> + val url = deployment.client!!.url + try { + deployment.workspaces = deployment.client!! + .workspaces().flatMap { it.toAgentModels() } + } catch (e: Exception) { + logger.error("Failed to fetch workspaces from $url", e) + deployment.error = e.message ?: "Request failed without further details" + } + } + } + withContext(Dispatchers.Main) { + updateContentView() + } + } + + // Note that this is *not* called when you navigate away from the page so + // check for visibility if you want to avoid work while the panel is not + // displaying. override fun dispose() { + logger.info("Disposing recent view") cs.cancel() + poller?.cancel() + } + + companion object { + val logger = Logger.getInstance(CoderGatewayRecentWorkspaceConnectionsView::class.java.simpleName) } -} \ No newline at end of file +} From fd9f8596f72018661c5f432b584d00d706ec2190 Mon Sep 17 00:00:00 2001 From: Asher Date: Mon, 8 May 2023 13:16:56 -0800 Subject: [PATCH 9/9] Simplify recent workspace connection constructor --- .../models/RecentWorkspaceConnection.kt | 47 +++++-------------- 1 file changed, 12 insertions(+), 35 deletions(-) diff --git a/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt b/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt index f5155eba..4194be6c 100644 --- a/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt +++ b/src/main/kotlin/com/coder/gateway/models/RecentWorkspaceConnection.kt @@ -3,51 +3,28 @@ package com.coder.gateway.models import com.intellij.openapi.components.BaseState import com.intellij.util.xmlb.annotations.Attribute -class RecentWorkspaceConnection() : BaseState(), Comparable { - constructor(hostname: String, prjPath: String, openedAt: String, productCode: String, buildNumber: String, source: String?, idePath: String?, terminalLink: String, config: String, name: String) : this() { - coderWorkspaceHostname = hostname - projectPath = prjPath - lastOpened = openedAt - ideProductCode = productCode - ideBuildNumber = buildNumber - downloadSource = source - idePathOnHost = idePath - webTerminalLink = terminalLink - configDirectory = config - this.name = name - } - +class RecentWorkspaceConnection( @get:Attribute - var coderWorkspaceHostname by string() - + var coderWorkspaceHostname: String? = null, @get:Attribute - var projectPath by string() - + var projectPath: String? = null, @get:Attribute - var lastOpened by string() - + var lastOpened: String? = null, @get:Attribute - var ideProductCode by string() - + var ideProductCode: String? = null, @get:Attribute - var ideBuildNumber by string() - + var ideBuildNumber: String? = null, @get:Attribute - var downloadSource by string() - - + var downloadSource: String? = null, @get:Attribute - var idePathOnHost by string() - + var idePathOnHost: String? = null, @get:Attribute - var webTerminalLink by string() - + var webTerminalLink: String? = null, @get:Attribute - var configDirectory by string() - + var configDirectory: String? = null, @get:Attribute - var name by string() - + var name: String? = null, +) : BaseState(), Comparable { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false