Skip to content

Commit 720e69f

Browse files
committed
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.
1 parent fb407cd commit 720e69f

File tree

1 file changed

+184
-21
lines changed

1 file changed

+184
-21
lines changed

src/main/kotlin/com/coder/gateway/views/CoderGatewayRecentWorkspaceConnectionsView.kt

Lines changed: 184 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,23 @@ import com.coder.gateway.CoderGatewayBundle
66
import com.coder.gateway.CoderGatewayConstants
77
import com.coder.gateway.icons.CoderIcons
88
import com.coder.gateway.models.RecentWorkspaceConnection
9+
import com.coder.gateway.models.WorkspaceAgentModel
10+
import com.coder.gateway.sdk.CoderRestClient
11+
import com.coder.gateway.sdk.toURL
12+
import com.coder.gateway.sdk.v2.models.WorkspaceStatus
13+
import com.coder.gateway.sdk.v2.models.toAgentModels
914
import com.coder.gateway.services.CoderRecentWorkspaceConnectionsService
1015
import com.coder.gateway.toWorkspaceParams
1116
import com.intellij.icons.AllIcons
1217
import com.intellij.ide.BrowserUtil
1318
import com.intellij.openapi.Disposable
1419
import com.intellij.openapi.actionSystem.AnActionEvent
1520
import com.intellij.openapi.components.service
21+
import com.intellij.openapi.diagnostic.Logger
1622
import com.intellij.openapi.project.DumbAwareAction
1723
import com.intellij.openapi.ui.panel.ComponentPanelBuilder
1824
import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager
25+
import com.intellij.ui.AnimatedIcon
1926
import com.intellij.ui.DocumentAdapter
2027
import com.intellij.ui.SearchTextField
2128
import com.intellij.ui.components.ActionLink
@@ -26,40 +33,70 @@ import com.intellij.ui.dsl.builder.BottomGap
2633
import com.intellij.ui.dsl.builder.RightGap
2734
import com.intellij.ui.dsl.builder.TopGap
2835
import com.intellij.ui.dsl.builder.panel
36+
import com.intellij.ui.util.maximumWidth
37+
import com.intellij.ui.util.minimumWidth
38+
import com.intellij.util.io.readText
2939
import com.intellij.util.ui.JBFont
3040
import com.intellij.util.ui.JBUI
41+
import com.intellij.util.ui.UIUtil
3142
import com.jetbrains.gateway.api.GatewayRecentConnections
3243
import com.jetbrains.gateway.api.GatewayUI
3344
import com.jetbrains.gateway.ssh.IntelliJPlatformProduct
3445
import com.jetbrains.rd.util.lifetime.Lifetime
3546
import kotlinx.coroutines.CoroutineScope
3647
import kotlinx.coroutines.Dispatchers
48+
import kotlinx.coroutines.Job
3749
import kotlinx.coroutines.cancel
50+
import kotlinx.coroutines.delay
51+
import kotlinx.coroutines.isActive
3852
import kotlinx.coroutines.launch
53+
import kotlinx.coroutines.withContext
3954
import java.awt.Component
4055
import java.awt.Dimension
41-
import java.util.*
56+
import java.nio.file.Path
57+
import java.util.Locale
4258
import javax.swing.JComponent
4359
import javax.swing.JLabel
4460
import javax.swing.event.DocumentEvent
4561

62+
/**
63+
* DeploymentInfo contains everything needed to query the API for a deployment
64+
* along with the latest workspace responses.
65+
*/
66+
data class DeploymentInfo(
67+
// Null if unable to create the client (config directory did not exist).
68+
var client: CoderRestClient? = null,
69+
// Null if we have not fetched workspaces yet.
70+
var workspaces: List<WorkspaceAgentModel>? = null,
71+
// Null if there have not been any errors yet.
72+
var error: String? = null,
73+
)
74+
4675
class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback: (Component) -> Unit) : GatewayRecentConnections, Disposable {
4776
private val recentConnectionsService = service<CoderRecentWorkspaceConnectionsService>()
4877
private val cs = CoroutineScope(Dispatchers.Main)
4978

5079
private val recentWorkspacesContentPanel = JBScrollPane()
5180

5281
private lateinit var searchBar: SearchTextField
82+
private var filterString: String? = null
5383

5484
override val id = CoderGatewayConstants.GATEWAY_RECENT_CONNECTIONS_ID
5585

5686
override val recentsIcon = CoderIcons.LOGO_16
5787

88+
/**
89+
* API clients and workspaces grouped by deployment (designated here by
90+
* their config directory).
91+
*/
92+
private var deployments: Map<String, DeploymentInfo> = emptyMap()
93+
private var poller: Job? = null
94+
5895
override fun createRecentsView(lifetime: Lifetime): JComponent {
5996
return panel {
6097
indent {
6198
row {
62-
label(CoderGatewayBundle.message("gateway.connector.recentconnections.title")).applyToComponent {
99+
label(CoderGatewayBundle.message("gateway.connector.recent-connections.title")).applyToComponent {
63100
font = JBFont.h3().asBold()
64101
}
65102
panel {
@@ -71,17 +108,14 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback:
71108
textEditor.border = JBUI.Borders.empty(2, 5, 2, 0)
72109
addDocumentListener(object : DocumentAdapter() {
73110
override fun textChanged(e: DocumentEvent) {
74-
val toSearchFor = this@applyToComponent.text
75-
val filteredConnections = recentConnectionsService.getAllRecentConnections()
76-
.filter { it.coderWorkspaceHostname != null }
77-
.filter { it.coderWorkspaceHostname!!.lowercase(Locale.getDefault()).contains(toSearchFor) || it.projectPath?.lowercase(Locale.getDefault())?.contains(toSearchFor) ?: false }
78-
updateContentView(filteredConnections.groupBy { it.coderWorkspaceHostname!! })
111+
filterString = this@applyToComponent.text.trim()
112+
updateContentView()
79113
}
80114
})
81115
}.component
82116

83117
actionButton(
84-
object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recentconnections.new.wizard.button.tooltip"), null, AllIcons.General.Add) {
118+
object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recent-connections.new.wizard.button.tooltip"), null, AllIcons.General.Add) {
85119
override fun actionPerformed(e: AnActionEvent) {
86120
setContentCallback(CoderGatewayConnectorWizardWrapperView().component)
87121
}
@@ -106,27 +140,79 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback:
106140
override fun getRecentsTitle() = CoderGatewayBundle.message("gateway.connector.title")
107141

108142
override fun updateRecentView() {
109-
val groupedConnections = recentConnectionsService.getAllRecentConnections()
110-
.filter { it.coderWorkspaceHostname != null }
111-
.groupBy { it.coderWorkspaceHostname!! }
112-
updateContentView(groupedConnections)
143+
triggerWorkspacePolling()
144+
updateContentView()
113145
}
114146

115-
private fun updateContentView(groupedConnections: Map<String, List<RecentWorkspaceConnection>>) {
147+
private fun updateContentView() {
148+
val connections = recentConnectionsService.getAllRecentConnections()
149+
.filter { it.coderWorkspaceHostname != null }
150+
.filter { matchesFilter(it) }
151+
.groupBy { it.coderWorkspaceHostname!! }
116152
recentWorkspacesContentPanel.viewport.view = panel {
117-
groupedConnections.entries.forEach { (hostname, recentConnections) ->
153+
connections.forEach { (hostname, connections) ->
154+
// The config directory and name will not exist on connections
155+
// made with 2.3.0 and earlier.
156+
val name = connections.firstNotNullOfOrNull { it.name }
157+
val workspaceName = name?.split(".", limit = 2)?.first()
158+
val configDirectory = connections.firstNotNullOfOrNull { it.configDirectory }
159+
val deployment = deployments[configDirectory]
160+
val workspace = deployment?.workspaces
161+
?.firstOrNull { it.name == name || it.workspaceName == workspaceName }
118162
row {
119-
label(hostname).applyToComponent {
163+
(if (workspace != null) {
164+
icon(workspace.agentStatus.icon).applyToComponent {
165+
foreground = workspace.agentStatus.statusColor()
166+
toolTipText = workspace.agentStatus.description
167+
}
168+
} else if (configDirectory == null || workspaceName == null) {
169+
icon(CoderIcons.UNKNOWN).applyToComponent {
170+
toolTipText = "Unable to determine workspace status because the configuration directory and/or name were not recorded. To fix, add the connection again."
171+
}
172+
} else if (deployment?.error != null) {
173+
icon(UIUtil.getBalloonErrorIcon()).applyToComponent {
174+
toolTipText = deployment.error
175+
}
176+
} else if (deployment?.workspaces != null) {
177+
icon(UIUtil.getBalloonErrorIcon()).applyToComponent {
178+
toolTipText = "Workspace $workspaceName does not exist"
179+
}
180+
} else {
181+
icon(AnimatedIcon.Default.INSTANCE).applyToComponent {
182+
toolTipText = "Querying workspace status..."
183+
}
184+
}).align(AlignX.LEFT).gap(RightGap.SMALL).applyToComponent {
185+
maximumWidth = JBUI.scale(16)
186+
minimumWidth = JBUI.scale(16)
187+
}
188+
label(hostname.removePrefix("coder-jetbrains--")).applyToComponent {
120189
font = JBFont.h3().asBold()
121190
}.align(AlignX.LEFT).gap(RightGap.SMALL)
122-
actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recentconnections.terminal.button.tooltip"), "", CoderIcons.OPEN_TERMINAL) {
191+
label("").resizableColumn().align(AlignX.FILL)
192+
actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recent-connections.start.button.tooltip"), "", CoderIcons.RUN) {
193+
override fun actionPerformed(e: AnActionEvent) {
194+
if (workspace != null) {
195+
deployment.client?.startWorkspace(workspace.workspaceID, workspace.workspaceName)
196+
cs.launch { fetchWorkspaces() }
197+
}
198+
}
199+
}).applyToComponent { isEnabled = listOf(WorkspaceStatus.STOPPED, WorkspaceStatus.FAILED).contains(workspace?.workspaceStatus) }.gap(RightGap.SMALL)
200+
actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recent-connections.stop.button.tooltip"), "", CoderIcons.STOP) {
123201
override fun actionPerformed(e: AnActionEvent) {
124-
BrowserUtil.browse(recentConnections[0].webTerminalLink ?: "")
202+
if (workspace != null) {
203+
deployment.client?.stopWorkspace(workspace.workspaceID, workspace.workspaceName)
204+
cs.launch { fetchWorkspaces() }
205+
}
206+
}
207+
}).applyToComponent { isEnabled = workspace?.workspaceStatus == WorkspaceStatus.RUNNING }.gap(RightGap.SMALL)
208+
actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recent-connections.terminal.button.tooltip"), "", CoderIcons.OPEN_TERMINAL) {
209+
override fun actionPerformed(e: AnActionEvent) {
210+
BrowserUtil.browse(connections[0].webTerminalLink ?: "")
125211
}
126212
})
127213
}.topGap(TopGap.MEDIUM)
128214

129-
recentConnections.forEach { connectionDetails ->
215+
connections.forEach { connectionDetails ->
130216
val product = IntelliJPlatformProduct.fromProductCode(connectionDetails.ideProductCode!!)!!
131217
row {
132218
icon(product.icon)
@@ -140,7 +226,7 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback:
140226
foreground = JBUI.CurrentTheme.ContextHelp.FOREGROUND
141227
font = ComponentPanelBuilder.getCommentFont(font)
142228
}
143-
actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recentconnections.remove.button.tooltip"), "", CoderIcons.DELETE) {
229+
actionButton(object : DumbAwareAction(CoderGatewayBundle.message("gateway.connector.recent-connections.remove.button.tooltip"), "", CoderIcons.DELETE) {
144230
override fun actionPerformed(e: AnActionEvent) {
145231
recentConnectionsService.removeConnection(connectionDetails)
146232
updateRecentView()
@@ -151,11 +237,88 @@ class CoderGatewayRecentWorkspaceConnectionsView(private val setContentCallback:
151237
}
152238
}.apply {
153239
background = WelcomeScreenUIManager.getMainAssociatedComponentBackground()
154-
border = JBUI.Borders.empty(12, 0, 0, 12)
240+
border = JBUI.Borders.empty(12, 0, 12, 12)
155241
}
156242
}
157243

244+
/**
245+
* Return true if the connection matches the current filter.
246+
*/
247+
private fun matchesFilter(connection: RecentWorkspaceConnection): Boolean {
248+
return filterString.isNullOrBlank()
249+
|| connection.coderWorkspaceHostname?.lowercase(Locale.getDefault())?.contains(filterString!!) == true
250+
|| connection.projectPath?.lowercase(Locale.getDefault())?.contains(filterString!!) == true
251+
}
252+
253+
/**
254+
* Start polling for workspaces if not already started.
255+
*/
256+
private fun triggerWorkspacePolling() {
257+
deployments = recentConnectionsService.getAllRecentConnections()
258+
.mapNotNull { it.configDirectory }.toSet()
259+
.associateWith { dir ->
260+
deployments[dir] ?: try {
261+
val url = Path.of(dir).resolve("url").readText()
262+
val token = Path.of(dir).resolve("session").readText()
263+
DeploymentInfo(CoderRestClient(url.toURL(), token))
264+
} catch (e: Exception) {
265+
logger.error("Unable to create client from $dir", e)
266+
DeploymentInfo(error = "Error trying to read $dir: ${e.message}")
267+
}
268+
}
269+
270+
if (poller?.isActive == true) {
271+
logger.info("Refusing to start already-started poller")
272+
return
273+
}
274+
275+
logger.info("Starting poll loop")
276+
poller = cs.launch {
277+
while (isActive) {
278+
if (recentWorkspacesContentPanel.isShowing) {
279+
fetchWorkspaces()
280+
} else {
281+
logger.info("View not visible; aborting poll")
282+
poller?.cancel()
283+
}
284+
delay(5000)
285+
}
286+
}
287+
}
288+
289+
/**
290+
* Update each deployment with their latest workspaces.
291+
*/
292+
private suspend fun fetchWorkspaces() {
293+
withContext(Dispatchers.IO) {
294+
deployments.values
295+
.filter { it.error == null && it.client != null}
296+
.forEach { deployment ->
297+
val url = deployment.client!!.url
298+
try {
299+
deployment.workspaces = deployment.client!!
300+
.workspaces().flatMap { it.toAgentModels() }
301+
} catch (e: Exception) {
302+
logger.error("Failed to fetch workspaces from $url", e)
303+
deployment.error = e.message ?: "Request failed without further details"
304+
}
305+
}
306+
}
307+
withContext(Dispatchers.Main) {
308+
updateContentView()
309+
}
310+
}
311+
312+
// Note that this is *not* called when you navigate away from the page so
313+
// check for visibility if you want to avoid work while the panel is not
314+
// displaying.
158315
override fun dispose() {
316+
logger.info("Disposing recent view")
159317
cs.cancel()
318+
poller?.cancel()
319+
}
320+
321+
companion object {
322+
val logger = Logger.getInstance(CoderGatewayRecentWorkspaceConnectionsView::class.java.simpleName)
160323
}
161-
}
324+
}

0 commit comments

Comments
 (0)