diff --git a/CHANGELOG.md b/CHANGELOG.md index fd3a2a37..3542a403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,17 @@ ### Added - ability to open a template in the Dashboard +- ability to sort by workspace name, or by template name or by workspace status +- a new token is requested when the one persisted is expired ### Changed - renamed the plugin from `Coder Gateway` to `Gateway` - workspaces and agents are now resolved and displayed progressively ### Fixed -- icon rendering on macOS +- icon rendering on `macOS` +- `darwin` agents are now recognized as `macOS` +- unsupported OS warning is displayed only for running workspaces ## 2.1.3 - 2022-12-09 diff --git a/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentModel.kt b/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentModel.kt index 80b65e57..3a2bdbf8 100644 --- a/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentModel.kt +++ b/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentModel.kt @@ -12,7 +12,8 @@ data class WorkspaceAgentModel( val name: String, val templateID: UUID, val templateName: String, - val templateIcon: Icon, + val templateIconPath: String, + var templateIcon: Icon?, val status: WorkspaceVersionStatus, val agentStatus: WorkspaceAgentStatus, val lastBuildTransition: WorkspaceTransition, diff --git a/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentStatus.kt b/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentStatus.kt index cdb67a3e..44a71614 100644 --- a/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentStatus.kt +++ b/src/main/kotlin/com/coder/gateway/models/WorkspaceAgentStatus.kt @@ -3,12 +3,19 @@ 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 @@ -28,5 +35,7 @@ enum class WorkspaceAgentStatus(val label: String) { 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/sdk/TemplateIconDownloader.kt b/src/main/kotlin/com/coder/gateway/sdk/TemplateIconDownloader.kt index c4fe0db4..4b23fa6c 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/TemplateIconDownloader.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/TemplateIconDownloader.kt @@ -13,6 +13,7 @@ import javax.swing.Icon @Service(Service.Level.APP) class TemplateIconDownloader { private val coderClient: CoderRestClientService = service() + private val cache = mutableMapOf, Icon>() fun load(path: String, templateName: String): Icon { var url: URL? = null @@ -23,9 +24,15 @@ class TemplateIconDownloader { } if (url != null) { + val cachedIcon = cache[Pair(templateName, path)] + if (cachedIcon != null) { + return cachedIcon + } var img = ImageLoader.loadFromUrl(url) if (img != null) { - return IconUtil.toRetinaAwareIcon(Scalr.resize(ImageUtil.toBufferedImage(img), Scalr.Method.ULTRA_QUALITY, 32)) + val icon = IconUtil.toRetinaAwareIcon(Scalr.resize(ImageUtil.toBufferedImage(img), Scalr.Method.ULTRA_QUALITY, 32)) + cache[Pair(templateName, path)] = icon + return icon } } diff --git a/src/main/kotlin/com/coder/gateway/sdk/os.kt b/src/main/kotlin/com/coder/gateway/sdk/os.kt index c8682b32..9a272a98 100644 --- a/src/main/kotlin/com/coder/gateway/sdk/os.kt +++ b/src/main/kotlin/com/coder/gateway/sdk/os.kt @@ -1,11 +1,13 @@ package com.coder.gateway.sdk +import java.util.Locale + fun getOS(): OS? { return OS.from(System.getProperty("os.name")) } fun getArch(): Arch? { - return Arch.from(System.getProperty("os.arch").toLowerCase()) + return Arch.from(System.getProperty("os.arch").lowercase(Locale.getDefault())) } enum class OS { @@ -22,7 +24,7 @@ enum class OS { LINUX } - os.contains("mac", true) -> { + os.contains("mac", true) || os.contains("darwin", true) -> { MAC } 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 66f3753d..e0002255 100644 --- a/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt +++ b/src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt @@ -36,11 +36,12 @@ import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.progress.Task +import com.intellij.openapi.rd.util.launchUnderBackgroundProgress import com.intellij.openapi.ui.panel.ComponentPanelBuilder import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager import com.intellij.ui.AnActionButton import com.intellij.ui.AppIcon -import com.intellij.ui.JBColor +import com.intellij.ui.RelativeFont import com.intellij.ui.ToolbarDecorator import com.intellij.ui.components.JBTextField import com.intellij.ui.components.dialog @@ -57,6 +58,7 @@ import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import com.intellij.util.ui.ListTableModel import com.intellij.util.ui.table.IconTableCellRenderer +import com.jetbrains.rd.util.lifetime.LifetimeDefinition import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -64,7 +66,6 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.zeroturnaround.exec.ProcessExecutor import java.awt.Component @@ -121,9 +122,9 @@ class CoderWorkspacesStepView(val enableNextButtonCallback: (Boolean) -> Unit) : setSelectionMode(ListSelectionModel.SINGLE_SELECTION) selectionModel.addListSelectionListener { enableNextButtonCallback(selectedObject != null && selectedObject?.agentStatus == RUNNING && selectedObject?.agentOS == OS.LINUX) - if (selectedObject?.agentOS != OS.LINUX) { + if (selectedObject?.agentStatus == RUNNING && selectedObject?.agentOS != OS.LINUX) { notificationBanner.apply { - isVisible = true + component.isVisible = true showInfo(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.unsupported.os.info")) } } else { @@ -317,7 +318,13 @@ class CoderWorkspacesStepView(val enableNextButtonCallback: (Boolean) -> Unit) : tfUrl?.text = url poller?.cancel() - loginAndLoadWorkspace(token) + try { + coderClient.initClientSession(url.toURL(), token) + loginAndLoadWorkspace(token) + } catch (e: AuthenticationResponseException) { + // probably the token is expired + askTokenAndOpenSession() + } } } updateWorkspaceActions() @@ -391,62 +398,54 @@ class CoderWorkspacesStepView(val enableNextButtonCallback: (Boolean) -> Unit) : appPropertiesService.setValue(SESSION_TOKEN, token) val cliManager = CoderCLIManager(localWizardModel.coderURL.toURL(), coderClient.buildVersion) - localWizardModel.apply { this.token = token buildVersion = coderClient.buildVersion localCliPath = cliManager.localCli.toAbsolutePath().toString() } - val authTask = object : Task.Modal(null, CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.cli.downloader.dialog.title"), false) { - override fun run(pi: ProgressIndicator) { - pi.apply { - isIndeterminate = false - text = "Retrieving Workspaces..." - fraction = 0.1 - } - runBlocking { - loadWorkspaces() - } + LifetimeDefinition().launchUnderBackgroundProgress(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.cli.downloader.dialog.title"), canBeCancelled = false, isIndeterminate = true) { + this.indicator.apply { + isIndeterminate = false + text = "Retrieving Workspaces..." + fraction = 0.1 + } - pi.apply { - isIndeterminate = false - text = "Downloading Coder CLI..." - fraction = 0.3 - } + loadWorkspaces() - cliManager.downloadCLI() - if (getOS() != OS.WINDOWS) { - pi.fraction = 0.4 - val chmodOutput = ProcessExecutor().command("chmod", "+x", localWizardModel.localCliPath).readOutput(true).execute().outputUTF8() - logger.info("chmod +x ${cliManager.localCli.toAbsolutePath()} $chmodOutput") - } - pi.apply { - text = "Configuring Coder CLI..." - fraction = 0.5 - } + this.indicator.apply { + isIndeterminate = false + text = "Downloading Coder CLI..." + fraction = 0.3 + } - val loginOutput = ProcessExecutor().command(localWizardModel.localCliPath, "login", localWizardModel.coderURL, "--token", localWizardModel.token).readOutput(true).execute().outputUTF8() - logger.info("coder-cli login output: $loginOutput") - pi.fraction = 0.8 - val sshConfigOutput = ProcessExecutor().command(localWizardModel.localCliPath, "config-ssh", "--yes", "--use-previous-options").readOutput(true).execute().outputUTF8() - logger.info("Result of `${localWizardModel.localCliPath} config-ssh --yes --use-previous-options`: $sshConfigOutput") + cliManager.downloadCLI() + if (getOS() != OS.WINDOWS) { + this.indicator.fraction = 0.4 + val chmodOutput = ProcessExecutor().command("chmod", "+x", localWizardModel.localCliPath).readOutput(true).execute().outputUTF8() + logger.info("chmod +x ${cliManager.localCli.toAbsolutePath()} $chmodOutput") + } + this.indicator.apply { + text = "Configuring Coder CLI..." + fraction = 0.5 + } - pi.apply { - text = "Remove old Coder CLI versions..." - fraction = 0.9 - } - cliManager.removeOldCli() + val loginOutput = ProcessExecutor().command(localWizardModel.localCliPath, "login", localWizardModel.coderURL, "--token", localWizardModel.token).readOutput(true).execute().outputUTF8() + logger.info("coder-cli login output: $loginOutput") + this.indicator.fraction = 0.8 + val sshConfigOutput = ProcessExecutor().command(localWizardModel.localCliPath, "config-ssh", "--yes", "--use-previous-options").readOutput(true).execute().outputUTF8() + logger.info("Result of `${localWizardModel.localCliPath} config-ssh --yes --use-previous-options`: $sshConfigOutput") - pi.fraction = 1.0 + this.indicator.apply { + text = "Remove old Coder CLI versions..." + fraction = 0.9 } - } + cliManager.removeOldCli() - cs.launch { - ProgressManager.getInstance().run(authTask) + this.indicator.fraction = 1.0 + updateWorkspaceActions() + triggerWorkspacePolling() } - updateWorkspaceActions() - triggerWorkspacePolling() } private fun askToken(): String? { @@ -483,75 +482,66 @@ class CoderWorkspacesStepView(val enableNextButtonCallback: (Boolean) -> Unit) : } private suspend fun loadWorkspaces() { - withContext(Dispatchers.IO) { + val ws = withContext(Dispatchers.IO) { val timeBeforeRequestingWorkspaces = System.currentTimeMillis() try { val ws = coderClient.workspaces() + val ams = ws.flatMap { it.toAgentModels() }.toSet() val timeAfterRequestingWorkspaces = System.currentTimeMillis() logger.info("Retrieving the workspaces took: ${timeAfterRequestingWorkspaces - timeBeforeRequestingWorkspaces} millis") - ws.resolveAndDisplayAgents() + return@withContext ams } catch (e: Exception) { logger.error("Could not retrieve workspaces for ${coderClient.me.username} on ${coderClient.coderURL}. Reason: $e") + emptySet() + } + } + withContext(Dispatchers.Main) { + val selectedWorkspace = tableOfWorkspaces.selectedObject?.name + listTableModelOfWorkspaces.items = ws.toList() + if (selectedWorkspace != null) { + tableOfWorkspaces.selectItem(selectedWorkspace) } } } - private fun List.resolveAndDisplayAgents() { - this.forEach { workspace -> - cs.launch(Dispatchers.IO) { - val timeBeforeRequestingAgents = System.currentTimeMillis() - workspace.agentModels().forEach { am -> + 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.templateName) withContext(Dispatchers.Main) { - val selectedWorkspace = tableOfWorkspaces.selectedObject?.name - if (listTableModelOfWorkspaces.indexOf(am) >= 0) { - val index = listTableModelOfWorkspaces.indexOf(am) - listTableModelOfWorkspaces.setItem(index, am) - } else { - listTableModelOfWorkspaces.addRow(am) - } - if (selectedWorkspace != null) { - tableOfWorkspaces.selectItem(selectedWorkspace) - } + tableOfWorkspaces.updateUI() } } - val timeAfterRequestingAgents = System.currentTimeMillis() - logger.info("Retrieving the agents for ${workspace.name} took: ${timeAfterRequestingAgents - timeBeforeRequestingAgents} millis") + setOf(wm) } - } - } - - private fun Workspace.agentModels(): List { - return try { - val agents = coderClient.workspaceAgentsByTemplate(this) - when (agents.size) { - 0 -> { - listOf( - WorkspaceAgentModel( - this.id, - this.name, - this.name, - this.templateID, - this.templateName, - iconDownloader.load(this@agentModels.templateIcon, this.name), - WorkspaceVersionStatus.from(this), - WorkspaceAgentStatus.from(this), - this.latestBuild.transition, - null, - null, - null - ) - ) - } - else -> agents.map { agent -> + else -> { + val wam = this.latestBuild.resources.filter { it.agents != null }.flatMap { it.agents!! }.map { agent -> val workspaceWithAgentName = "${this.name}.${agent.name}" - WorkspaceAgentModel( + val wm = WorkspaceAgentModel( this.id, this.name, workspaceWithAgentName, this.templateID, this.templateName, - iconDownloader.load(this@agentModels.templateIcon, workspaceWithAgentName), + this.templateIcon, + null, WorkspaceVersionStatus.from(this), WorkspaceAgentStatus.from(this), this.latestBuild.transition, @@ -559,26 +549,41 @@ class CoderWorkspacesStepView(val enableNextButtonCallback: (Boolean) -> Unit) : Arch.from(agent.architecture), agent.directory ) - }.toList() + cs.launch(Dispatchers.IO) { + wm.templateIcon = iconDownloader.load(wm.templateIconPath, wm.templateName) + 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.templateName) + withContext(Dispatchers.Main) { + tableOfWorkspaces.updateUI() + } + } + return setOf(wm) + } + return wam } - } catch (e: Exception) { - logger.warn("Agent(s) for ${this.name} could not be retrieved. Reason: $e") - listOf( - WorkspaceAgentModel( - this.id, - this.name, - this.name, - this.templateID, - this.templateName, - iconDownloader.load(this@agentModels.templateIcon, this.name), - WorkspaceVersionStatus.from(this), - WorkspaceAgentStatus.from(this), - this.latestBuild.transition, - null, - null, - null - ) - ) } } @@ -627,7 +632,7 @@ class CoderWorkspacesStepView(val enableNextButtonCallback: (Boolean) -> Unit) : private class WorkspaceIconColumnInfo(columnName: String) : ColumnInfo(columnName) { override fun valueOf(workspace: WorkspaceAgentModel?): String? { - return workspace?.agentOS?.name + return workspace?.templateName } override fun getRenderer(item: WorkspaceAgentModel?): TableCellRenderer { @@ -644,7 +649,7 @@ class CoderWorkspacesStepView(val enableNextButtonCallback: (Boolean) -> Unit) : 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(10) + border = JBUI.Borders.empty(8, 8) } return this } @@ -652,11 +657,21 @@ class CoderWorkspacesStepView(val enableNextButtonCallback: (Boolean) -> Unit) : } } - private class WorkspaceNameColumnInfo(columnName: String) : ColumnInfo(columnName) { + private inner class WorkspaceNameColumnInfo(columnName: String) : ColumnInfo(columnName) { override fun valueOf(workspace: WorkspaceAgentModel?): String? { return workspace?.name } + 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) + } + } + 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 { @@ -664,21 +679,32 @@ class CoderWorkspacesStepView(val enableNextButtonCallback: (Boolean) -> Unit) : if (value is String) { text = value } - font = JBFont.h3().asBold() - border = JBUI.Borders.empty() + + font = RelativeFont.BOLD.derive(this@CoderWorkspacesStepView.tableOfWorkspaces.tableHeader.font) + border = JBUI.Borders.empty(0, 8) return this } } } } - private class WorkspaceTemplateNameColumnInfo(columnName: String) : ColumnInfo(columnName) { + private inner class WorkspaceTemplateNameColumnInfo(columnName: String) : ColumnInfo(columnName) { override fun valueOf(workspace: WorkspaceAgentModel?): String? { return workspace?.templateName } + 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) + } + } + override fun getRenderer(item: WorkspaceAgentModel?): TableCellRenderer { - val simpleH3 = JBFont.h3() + val simpleH3 = this@CoderWorkspacesStepView.tableOfWorkspaces.tableHeader.font val h3AttributesWithUnderlining = simpleH3.attributes as MutableMap h3AttributesWithUnderlining[TextAttribute.UNDERLINE] = UNDERLINE_ON @@ -689,7 +715,7 @@ class CoderWorkspacesStepView(val enableNextButtonCallback: (Boolean) -> Unit) : if (value is String) { text = value } - border = JBUI.Borders.empty() + border = JBUI.Borders.empty(0, 8) if (table.getClientProperty(MOUSE_OVER_TEMPLATE_NAME_COLUMN_ON_ROW) != null) { val mouseOverRow = table.getClientProperty(MOUSE_OVER_TEMPLATE_NAME_COLUMN_ON_ROW) as Int @@ -705,7 +731,7 @@ class CoderWorkspacesStepView(val enableNextButtonCallback: (Boolean) -> Unit) : } } - private class WorkspaceVersionColumnInfo(columnName: String) : ColumnInfo(columnName) { + private inner class WorkspaceVersionColumnInfo(columnName: String) : ColumnInfo(columnName) { override fun valueOf(workspace: WorkspaceAgentModel?): String? { return workspace?.status?.label } @@ -717,39 +743,43 @@ class CoderWorkspacesStepView(val enableNextButtonCallback: (Boolean) -> Unit) : if (value is String) { text = value } - font = JBFont.h3() - border = JBUI.Borders.empty() + font = this@CoderWorkspacesStepView.tableOfWorkspaces.tableHeader.font + border = JBUI.Borders.empty(0, 8) return this } } } } - private class WorkspaceStatusColumnInfo(columnName: String) : ColumnInfo(columnName) { + private inner class WorkspaceStatusColumnInfo(columnName: String) : ColumnInfo(columnName) { override fun valueOf(workspace: WorkspaceAgentModel?): String? { return workspace?.agentStatus?.label } + 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 { super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column) if (value is String) { text = value + foreground = WorkspaceAgentStatus.from(value).statusColor() } - font = JBFont.h3() - border = JBUI.Borders.empty() - foreground = (table.model as ListTableModel).getRowValue(row).statusColor() + font = this@CoderWorkspacesStepView.tableOfWorkspaces.tableHeader.font + border = JBUI.Borders.empty(0, 8) return this } } } - - private fun WorkspaceAgentModel.statusColor() = when (this.agentStatus) { - RUNNING -> JBColor.GREEN - FAILED -> JBColor.RED - else -> if (JBColor.isBright()) JBColor.LIGHT_GRAY else JBColor.DARK_GRAY - } } private fun TableView.selectItem(workspaceName: String?) { diff --git a/src/main/resources/version/CoderSupportedVersions.properties b/src/main/resources/version/CoderSupportedVersions.properties index 280fb807..c7aa3527 100644 --- a/src/main/resources/version/CoderSupportedVersions.properties +++ b/src/main/resources/version/CoderSupportedVersions.properties @@ -1,2 +1,2 @@ minCompatibleCoderVersion=0.12.9 -maxCompatibleCoderVersion=0.13.5 +maxCompatibleCoderVersion=0.13.6