From 433c4d0fa1fedb2dcf1eeb19e78dfa325b238410 Mon Sep 17 00:00:00 2001 From: Anatolii Bazko Date: Thu, 21 Mar 2024 15:40:46 +0100 Subject: [PATCH] chore: UI improvements (#37) * chore: UI improvements Signed-off-by: Anatolii Bazko * chore: Done't allow to connect if connection has been already established Signed-off-by: Anatolii Bazko --------- Signed-off-by: Anatolii Bazko --- .../devspaces/gateway/DevSpacesConnection.kt | 98 +++++-- .../gateway/DevSpacesConnectionProvider.kt | 16 +- .../devspaces/gateway/DevSpacesContext.kt | 6 +- .../gateway/openshift/DevWorkspace.kt | 92 ++++++ .../gateway/openshift/DevWorkspaces.kt | 106 +++++-- .../devspaces/gateway/openshift/Pods.kt | 12 +- .../devspaces/gateway/openshift/Projects.kt | 8 +- .../devspaces/gateway/server/ProjectStatus.kt | 6 +- .../devspaces/gateway/server/RemoteServer.kt | 89 ++++-- .../gateway/view/DevSpacesWizardView.kt | 22 +- .../gateway/view/InformationDialog.kt | 3 - .../devspaces/gateway/view/LoaderDialog.kt | 44 +++ .../DevSpacesDevWorkspaceSelectingStepView.kt | 146 ---------- .../DevSpacesOpenShiftConnectionStepView.kt | 41 ++- ...DevSpacesRemoteServerConnectionStepView.kt | 275 ++++++++++++++++++ .../messages/DevSpacesBundle.properties | 16 +- 16 files changed, 679 insertions(+), 301 deletions(-) create mode 100644 src/main/kotlin/com/github/devspaces/gateway/openshift/DevWorkspace.kt create mode 100644 src/main/kotlin/com/github/devspaces/gateway/view/LoaderDialog.kt delete mode 100644 src/main/kotlin/com/github/devspaces/gateway/view/steps/DevSpacesDevWorkspaceSelectingStepView.kt create mode 100644 src/main/kotlin/com/github/devspaces/gateway/view/steps/DevSpacesRemoteServerConnectionStepView.kt diff --git a/src/main/kotlin/com/github/devspaces/gateway/DevSpacesConnection.kt b/src/main/kotlin/com/github/devspaces/gateway/DevSpacesConnection.kt index f2ca57b..eeccf2c 100644 --- a/src/main/kotlin/com/github/devspaces/gateway/DevSpacesConnection.kt +++ b/src/main/kotlin/com/github/devspaces/gateway/DevSpacesConnection.kt @@ -13,54 +13,98 @@ package com.github.devspaces.gateway import com.github.devspaces.gateway.openshift.DevWorkspaces import com.github.devspaces.gateway.openshift.Pods -import com.github.devspaces.gateway.openshift.Utils import com.github.devspaces.gateway.server.RemoteServer import com.jetbrains.gateway.thinClientLink.LinkedClientManager import com.jetbrains.gateway.thinClientLink.ThinClientHandle import com.jetbrains.rd.util.lifetime.Lifetime -import org.bouncycastle.util.Arrays -import java.io.Closeable +import io.kubernetes.client.openapi.ApiException import java.io.IOException import java.net.URI class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) { @Throws(Exception::class) @Suppress("UnstableApiUsage") - fun connect(): ThinClientHandle { - val isStarted = Utils.getValue(devSpacesContext.devWorkspace, arrayOf("spec", "started")) as Boolean - val dwName = Utils.getValue(devSpacesContext.devWorkspace, arrayOf("metadata", "name")) as String - val dwNamespace = Utils.getValue(devSpacesContext.devWorkspace, arrayOf("metadata", "namespace")) as String + fun connect( + onConnected: () -> Unit, + onDisconnected: () -> Unit, + onDevWorkspaceStopped: () -> Unit, + ): ThinClientHandle { + if (devSpacesContext.isConnected) + throw IOException(String.format("Already connected to %s", devSpacesContext.devWorkspace.metadata.name)) - if (!isStarted) { - DevWorkspaces(devSpacesContext.client).start(dwNamespace, dwName) + devSpacesContext.isConnected = true + try { + return doConnection(onConnected, onDevWorkspaceStopped, onDisconnected) + } catch (e: Exception) { + devSpacesContext.isConnected = false + throw e } - DevWorkspaces(devSpacesContext.client).waitRunning(dwNamespace, dwName) + } + + @Throws(Exception::class) + @Suppress("UnstableApiUsage") + private fun doConnection( + onConnected: () -> Unit, + onDevWorkspaceStopped: () -> Unit, + onDisconnected: () -> Unit + ): ThinClientHandle { + startAndWaitDevWorkspace() - val remoteServer = RemoteServer(devSpacesContext) - remoteServer.waitProjects() + val remoteServer = RemoteServer(devSpacesContext).also { it.waitProjectsReady() } val projectStatus = remoteServer.getProjectStatus() - if (projectStatus.joinLink.isEmpty()) throw IOException( - String.format( - "Connection link to the remote server not found in the DevWorkspace: %s", - dwName + val client = LinkedClientManager + .getInstance() + .startNewClient( + Lifetime.Eternal, + URI(projectStatus.joinLink), + "", + onConnected ) - ) - val client = LinkedClientManager.getInstance().startNewClient(Lifetime.Eternal, URI(projectStatus.joinLink), "") val forwarder = Pods(devSpacesContext.client).forward(remoteServer.pod, 5990, 5990) + client.run { - lifetime.onTermination(forwarder) - lifetime.onTermination( - Closeable { - val projectStatus = remoteServer.getProjectStatus() - if (Arrays.isNullOrEmpty(projectStatus.projects)) { - DevWorkspaces(devSpacesContext.client).stop(dwNamespace, dwName) - } - } - ) + lifetime.onTermination { forwarder.close() } + lifetime.onTermination { + if (remoteServer.waitProjectsTerminated()) + DevWorkspaces(devSpacesContext.client) + .stop( + devSpacesContext.devWorkspace.metadata.namespace, + devSpacesContext.devWorkspace.metadata.name + ) + .also { onDevWorkspaceStopped() } + } + lifetime.onTermination { devSpacesContext.isConnected = false } + lifetime.onTermination(onDisconnected) } return client } + + @Throws(IOException::class, ApiException::class) + private fun startAndWaitDevWorkspace() { + if (!devSpacesContext.devWorkspace.spec.started) { + DevWorkspaces(devSpacesContext.client) + .start( + devSpacesContext.devWorkspace.metadata.namespace, + devSpacesContext.devWorkspace.metadata.name + ) + } + + if (!DevWorkspaces(devSpacesContext.client) + .waitPhase( + devSpacesContext.devWorkspace.metadata.namespace, + devSpacesContext.devWorkspace.metadata.name, + DevWorkspaces.RUNNING, + DevWorkspaces.RUNNING_TIMEOUT + ) + ) throw IOException( + String.format( + "DevWorkspace '%s' is not running after %d seconds", + devSpacesContext.devWorkspace.metadata.name, + DevWorkspaces.RUNNING_TIMEOUT + ) + ) + } } diff --git a/src/main/kotlin/com/github/devspaces/gateway/DevSpacesConnectionProvider.kt b/src/main/kotlin/com/github/devspaces/gateway/DevSpacesConnectionProvider.kt index 95221e4..c8b7e3f 100644 --- a/src/main/kotlin/com/github/devspaces/gateway/DevSpacesConnectionProvider.kt +++ b/src/main/kotlin/com/github/devspaces/gateway/DevSpacesConnectionProvider.kt @@ -13,7 +13,6 @@ package com.github.devspaces.gateway import com.github.devspaces.gateway.openshift.DevWorkspaces import com.github.devspaces.gateway.openshift.OpenShiftClientFactory -import com.github.devspaces.gateway.openshift.Utils import com.intellij.openapi.diagnostic.thisLogger import com.intellij.ui.dsl.builder.Align.Companion.CENTER import com.intellij.ui.dsl.builder.panel @@ -32,7 +31,10 @@ private const val DW_NAME = "dwName" */ class DevSpacesConnectionProvider : GatewayConnectionProvider { - override suspend fun connect(parameters: Map, requestor: ConnectionRequestor): GatewayConnectionHandle? { + override suspend fun connect( + parameters: Map, + requestor: ConnectionRequestor + ): GatewayConnectionHandle? { thisLogger().debug("Launched Dev Spaces connection provider", parameters) val dwNamespace = parameters[DW_NAMESPACE] @@ -49,15 +51,9 @@ class DevSpacesConnectionProvider : GatewayConnectionProvider { val ctx = DevSpacesContext() ctx.client = OpenShiftClientFactory().create() + ctx.devWorkspace = DevWorkspaces(ctx.client).get(dwNamespace, dwName) - // TODO: probably, we don't need to specify `dwNamespace` here - // as `ctx.client` should know it from the local `.kube/config` - val devWorkspaces = DevWorkspaces(ctx.client).list(dwNamespace) as Map<*, *> - val devWorkspaceItems = devWorkspaces["items"] as List<*> - val list = devWorkspaceItems.filter { (Utils.getValue(it, arrayOf("metadata", "name")) as String) == dwName } - ctx.devWorkspace = list[0]!! - - val thinClient = DevSpacesConnection(ctx).connect() + val thinClient = DevSpacesConnection(ctx).connect({}, {}, {}) val connectionFrameComponent = panel { indent { diff --git a/src/main/kotlin/com/github/devspaces/gateway/DevSpacesContext.kt b/src/main/kotlin/com/github/devspaces/gateway/DevSpacesContext.kt index 3b0b292..f72cd14 100644 --- a/src/main/kotlin/com/github/devspaces/gateway/DevSpacesContext.kt +++ b/src/main/kotlin/com/github/devspaces/gateway/DevSpacesContext.kt @@ -11,9 +11,11 @@ */ package com.github.devspaces.gateway +import com.github.devspaces.gateway.openshift.DevWorkspace import io.kubernetes.client.openapi.ApiClient -class DevSpacesContext() { +class DevSpacesContext { lateinit var client: ApiClient - lateinit var devWorkspace: Any + lateinit var devWorkspace: DevWorkspace + var isConnected = false } \ No newline at end of file diff --git a/src/main/kotlin/com/github/devspaces/gateway/openshift/DevWorkspace.kt b/src/main/kotlin/com/github/devspaces/gateway/openshift/DevWorkspace.kt new file mode 100644 index 0000000..78757fd --- /dev/null +++ b/src/main/kotlin/com/github/devspaces/gateway/openshift/DevWorkspace.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.github.devspaces.gateway.openshift + +import java.util.Collections.emptyMap + +data class DevWorkspace( + val metadata: DevWorkspaceObjectMeta, + val spec: DevWorkspaceSpec, + val status: DevWorkspaceStatus +) { + companion object { + fun from(map: Any?) = object { + val metadata = Utils.getValue(map, arrayOf("metadata")) ?: emptyMap() + val spec = Utils.getValue(map, arrayOf("spec")) ?: emptyMap() + val status = Utils.getValue(map, arrayOf("status")) ?: emptyMap() + + val data = DevWorkspace( + DevWorkspaceObjectMeta.from(metadata), + DevWorkspaceSpec.from(spec), + DevWorkspaceStatus.from(status) + ) + }.data + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DevWorkspace + + if (metadata.name != other.metadata.name) return false + if (metadata.namespace != other.metadata.namespace) return false + + return true + } +} + +data class DevWorkspaceObjectMeta( + val name: String, + val namespace: String +) { + companion object { + fun from(map: Any) = object { + val name = Utils.getValue(map, arrayOf("name")) + val namespace = Utils.getValue(map, arrayOf("namespace")) + + val data = DevWorkspaceObjectMeta( + name as String, + namespace as String + ) + }.data + } +} + +data class DevWorkspaceSpec( + val started: Boolean +) { + companion object { + fun from(map: Any) = object { + val started = Utils.getValue(map, arrayOf("started")) ?: false + + val data = DevWorkspaceSpec( + started as Boolean + ) + }.data + } +} + +data class DevWorkspaceStatus( + val phase: String +) { + companion object { + fun from(map: Any) = object { + val phase = Utils.getValue(map, arrayOf("phase")) ?: "" + + val data = DevWorkspaceStatus( + phase as String + ) + }.data + } +} + diff --git a/src/main/kotlin/com/github/devspaces/gateway/openshift/DevWorkspaces.kt b/src/main/kotlin/com/github/devspaces/gateway/openshift/DevWorkspaces.kt index bec9e5e..b0c33c1 100644 --- a/src/main/kotlin/com/github/devspaces/gateway/openshift/DevWorkspaces.kt +++ b/src/main/kotlin/com/github/devspaces/gateway/openshift/DevWorkspaces.kt @@ -11,21 +11,28 @@ */ package com.github.devspaces.gateway.openshift +import com.google.gson.reflect.TypeToken import io.kubernetes.client.openapi.ApiClient import io.kubernetes.client.openapi.ApiException import io.kubernetes.client.openapi.apis.CustomObjectsApi +import io.kubernetes.client.util.Watch import java.io.IOException import java.util.concurrent.Executors import java.util.concurrent.TimeUnit class DevWorkspaces(private val client: ApiClient) { - - private var devWorkspaceStartingTimeout: Long = 300 + companion object { + val FAILED: String = "Failed" + val RUNNING: String = "Running" + val STOPPED: String = "Stopped" + val STARTING: String = "Starting" + val RUNNING_TIMEOUT: Long = 300 + } @Throws(ApiException::class) - fun list(namespace: String): Any { + fun list(namespace: String): List { val customApi = CustomObjectsApi(client) - return customApi.listNamespacedCustomObject( + val response = customApi.listNamespacedCustomObject( "workspace.devfile.io", "v1alpha2", namespace, @@ -41,22 +48,24 @@ class DevWorkspaces(private val client: ApiClient) { -1, false ) + + val dwItems = Utils.getValue(response, arrayOf("items")) as List<*> + return dwItems + .stream() + .map { dwItem -> DevWorkspace.from(dwItem) } + .toList() } - fun get(namespace: String, name: String): Any { + fun get(namespace: String, name: String): DevWorkspace { val customApi = CustomObjectsApi(client) - return customApi.getNamespacedCustomObject( + val dwObj = customApi.getNamespacedCustomObject( "workspace.devfile.io", "v1alpha2", namespace, "devworkspaces", name ) - } - - @Throws(ApiException::class) - fun patch(namespace: String, name: String, body: Any) { - doPatch(namespace, name, body) + return DevWorkspace.from(dwObj) } @Throws(ApiException::class) @@ -72,37 +81,43 @@ class DevWorkspaces(private val client: ApiClient) { } @Throws(ApiException::class, IOException::class) - fun waitRunning(namespace: String, name: String) { - val dwPhase = java.util.concurrent.atomic.AtomicReference() + fun waitPhase( + namespace: String, + name: String, + desiredPhase: String, + timeout: Long + ): Boolean { + var phaseIsDesiredState = false + + val watcher = createWatcher(namespace, String.format("metadata.name=%s", name)) val executor = Executors.newSingleThreadScheduledExecutor() - executor.scheduleAtFixedRate( + executor.schedule( { - val devWorkspace = get(namespace, name) - dwPhase.set(Utils.getValue(devWorkspace, arrayOf("status", "phase")) as String) - - if (dwPhase.get() == "Running" || dwPhase.get() == "Failed") { + try { + for (item in watcher) { + val devWorkspace = DevWorkspace.from(item.`object`) + if (desiredPhase == devWorkspace.status.phase) { + phaseIsDesiredState = true + break + } + } + } finally { + watcher.close() executor.shutdown() } - }, 0, 5, TimeUnit.SECONDS + }, + 0, + TimeUnit.SECONDS ) try { - executor.awaitTermination(devWorkspaceStartingTimeout, TimeUnit.SECONDS) + executor.awaitTermination(timeout, TimeUnit.SECONDS) } finally { + watcher.close() executor.shutdown() } - if (dwPhase.get() == "Failed") throw IOException( - String.format("DevWorkspace '%s' failed to start", name) - ) - - if (dwPhase.get() != "Running") throw IOException( - String.format( - "DevWorkspace '%s' is not running after %d seconds", - name, - devWorkspaceStartingTimeout - ) - ) + return phaseIsDesiredState } @Throws(ApiException::class) @@ -120,4 +135,31 @@ class DevWorkspaces(private val client: ApiClient) { null ) } -} \ No newline at end of file + + // Example: + // https://github.com/kubernetes-client/java/blob/master/examples/examples-release-18/src/main/java/io/kubernetes/client/examples/WatchExample.java + private fun createWatcher(namespace: String, fieldSelector: String = "", labelSelector: String = ""): Watch { + val customObjectsApi = CustomObjectsApi(client) + return Watch.createWatch( + client, + customObjectsApi.listNamespacedCustomObjectCall( + "workspace.devfile.io", + "v1alpha2", + namespace, + "devworkspaces", + "false", + false, + "", + fieldSelector, + labelSelector, + -1, + "", + "", + 0, + true, + null + ), + object : TypeToken>() {}.type + ) + } +} diff --git a/src/main/kotlin/com/github/devspaces/gateway/openshift/Pods.kt b/src/main/kotlin/com/github/devspaces/gateway/openshift/Pods.kt index 42fa175..474365a 100644 --- a/src/main/kotlin/com/github/devspaces/gateway/openshift/Pods.kt +++ b/src/main/kotlin/com/github/devspaces/gateway/openshift/Pods.kt @@ -30,13 +30,9 @@ import java.nio.channels.* class Pods(private val client: ApiClient) { - @Throws(ApiException::class) - fun list(namespace: String): V1PodList { - return doList(namespace) - } - // Sample: - // https://github.com/kubernetes-client/java/blob/master/examples/examples-release-19/src/main/java/io/kubernetes/client/examples/ExecExample.java + // Example: + // https://github.com/kubernetes-client/java/blob/master/examples/examples-release-18/src/main/java/io/kubernetes/client/examples/ExecExample.java @Throws(IOException::class) fun exec(pod: V1Pod, command: Array, container: String): String { val output = ByteArrayOutputStream() @@ -55,8 +51,8 @@ class Pods(private val client: ApiClient) { return output.toString() } - // Sample: - // https://github.com/kubernetes-client/java/blob/master/examples/examples-release-19/src/main/java/io/kubernetes/client/examples/PortForwardExample.java + // Example: + // https://github.com/kubernetes-client/java/blob/master/examples/examples-release-18/src/main/java/io/kubernetes/client/examples/PortForwardExample.java @Throws(IOException::class) fun forward(pod: V1Pod, localPort: Int, remotePort: Int): Closeable { val serverSocket = ServerSocket(localPort, 50, InetAddress.getLoopbackAddress()) diff --git a/src/main/kotlin/com/github/devspaces/gateway/openshift/Projects.kt b/src/main/kotlin/com/github/devspaces/gateway/openshift/Projects.kt index f0aba23..ab455c6 100644 --- a/src/main/kotlin/com/github/devspaces/gateway/openshift/Projects.kt +++ b/src/main/kotlin/com/github/devspaces/gateway/openshift/Projects.kt @@ -17,9 +17,9 @@ import io.kubernetes.client.openapi.apis.CustomObjectsApi class Projects(private val client: ApiClient) { @Throws(ApiException::class) - fun list(): Any { + fun list(): List<*> { val customApi = CustomObjectsApi(client) - return customApi.listClusterCustomObject( + val response = customApi.listClusterCustomObject( "project.openshift.io", "v1", "projects", @@ -33,6 +33,8 @@ class Projects(private val client: ApiClient) { "", -1, false - ) + ) as Map<*, *> + + return response["items"] as List<*> } } \ No newline at end of file diff --git a/src/main/kotlin/com/github/devspaces/gateway/server/ProjectStatus.kt b/src/main/kotlin/com/github/devspaces/gateway/server/ProjectStatus.kt index ad35a50..5c7e838 100644 --- a/src/main/kotlin/com/github/devspaces/gateway/server/ProjectStatus.kt +++ b/src/main/kotlin/com/github/devspaces/gateway/server/ProjectStatus.kt @@ -20,6 +20,11 @@ data class ProjectStatus( val runtimeVersion: String, val projects: Array ) { + companion object { + fun empty(): ProjectStatus { + return ProjectStatus("", "", "", "", "", emptyArray()) + } + } override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false @@ -45,6 +50,5 @@ data class ProjectStatus( result = 31 * result + projects.contentHashCode() return result } - } diff --git a/src/main/kotlin/com/github/devspaces/gateway/server/RemoteServer.kt b/src/main/kotlin/com/github/devspaces/gateway/server/RemoteServer.kt index 562a694..1ae3e21 100644 --- a/src/main/kotlin/com/github/devspaces/gateway/server/RemoteServer.kt +++ b/src/main/kotlin/com/github/devspaces/gateway/server/RemoteServer.kt @@ -14,8 +14,9 @@ package com.github.devspaces.gateway.server import com.github.devspaces.gateway.DevSpacesContext import com.github.devspaces.gateway.openshift.Pods -import com.github.devspaces.gateway.openshift.Utils +import com.google.common.base.Strings import com.google.gson.Gson +import com.intellij.openapi.diagnostic.thisLogger import io.kubernetes.client.openapi.models.V1Container import io.kubernetes.client.openapi.models.V1Pod import org.bouncycastle.util.Arrays @@ -28,7 +29,8 @@ import java.util.concurrent.atomic.AtomicBoolean class RemoteServer(private val devSpacesContext: DevSpacesContext) { var pod: V1Pod private var container: V1Container - private var waitingTimeout: Long = 60 + private var readyTimeout: Long = 60 + private var terminationTimeout: Long = 10 init { pod = findPod() @@ -36,54 +38,85 @@ class RemoteServer(private val devSpacesContext: DevSpacesContext) { } fun getProjectStatus(): ProjectStatus { - val result = Pods(devSpacesContext.client).exec( - pod, arrayOf( - "/bin/sh", - "-c", - "/idea-server/bin/remote-dev-server.sh status \$PROJECT_SOURCE | awk '/STATUS:/{p=1; next} p'" - ), container.name - ).trim() + Pods(devSpacesContext.client) + .exec( + pod, + arrayOf( + "/bin/sh", + "-c", + "/idea-server/bin/remote-dev-server.sh status \$PROJECT_SOURCE | awk '/STATUS:/{p=1; next} p'" + ), + container.name + ) + .trim() + .also { + return if (Strings.isNullOrEmpty(it)) ProjectStatus.empty() + else Gson().fromJson(it, ProjectStatus::class.java) + } + } + + @Throws(IOException::class) + fun waitProjectsReady() { + doWaitProjectsState(true, readyTimeout) + .also { + if (!it) throw IOException( + String.format( + "Projects are not ready after %d seconds.", + readyTimeout + ) + ) + } + } - return Gson().fromJson(result, ProjectStatus::class.java) + @Throws(IOException::class) + fun waitProjectsTerminated(): Boolean { + return doWaitProjectsState(false, terminationTimeout) } @Throws(IOException::class) - fun waitProjects() { - val projectsReady = AtomicBoolean() + fun doWaitProjectsState(isReadyState: Boolean, timeout: Long): Boolean { + val projectsInDesiredState = AtomicBoolean() val executor = Executors.newSingleThreadScheduledExecutor() executor.scheduleAtFixedRate( { - val projectStatus = getProjectStatus() - if (!Arrays.isNullOrEmpty(projectStatus.projects)) { - projectsReady.set(true) - executor.shutdown() + try { + getProjectStatus().also { + if (isReadyState == !Arrays.isNullOrEmpty(it.projects)) { + projectsInDesiredState.set(true) + executor.shutdown() + } + } + } catch (e: Exception) { + thisLogger().debug("Failed to check project status", e) } }, 0, 5, TimeUnit.SECONDS ) try { - executor.awaitTermination(waitingTimeout, TimeUnit.SECONDS) + executor.awaitTermination(timeout, TimeUnit.SECONDS) } finally { executor.shutdown() } - if (!projectsReady.get()) throw IOException( - String.format( - "Projects are not ready after %d seconds.", - waitingTimeout - ) - ) + return projectsInDesiredState.get() } @Throws(IOException::class) private fun findPod(): V1Pod { - val name = Utils.getValue(devSpacesContext.devWorkspace, arrayOf("metadata", "name")) as String - val namespace = Utils.getValue(devSpacesContext.devWorkspace, arrayOf("metadata", "namespace")) as String - val selector = String.format("controller.devfile.io/devworkspace_name=%s", name) + val selector = + String.format( + "controller.devfile.io/devworkspace_name=%s", + devSpacesContext.devWorkspace.metadata.name + ) - return Pods(devSpacesContext.client).findFirst(namespace, selector) ?: throw IOException( + return Pods(devSpacesContext.client) + .findFirst( + devSpacesContext.devWorkspace.metadata.namespace, + selector + ) ?: throw IOException( String.format( - "DevWorkspace '%s' is not running.", name + "DevWorkspace '%s' is not running.", + devSpacesContext.devWorkspace.metadata.name ) ) } diff --git a/src/main/kotlin/com/github/devspaces/gateway/view/DevSpacesWizardView.kt b/src/main/kotlin/com/github/devspaces/gateway/view/DevSpacesWizardView.kt index 4e0f585..b3158ae 100644 --- a/src/main/kotlin/com/github/devspaces/gateway/view/DevSpacesWizardView.kt +++ b/src/main/kotlin/com/github/devspaces/gateway/view/DevSpacesWizardView.kt @@ -11,12 +11,8 @@ */ package com.github.devspaces.gateway.view -import com.github.devspaces.gateway.DevSpacesConnection import com.github.devspaces.gateway.DevSpacesContext -import com.github.devspaces.gateway.openshift.DevWorkspaces -import com.github.devspaces.gateway.openshift.Utils -import com.github.devspaces.gateway.server.RemoteServer -import com.github.devspaces.gateway.view.steps.DevSpacesDevWorkspaceSelectingStepView +import com.github.devspaces.gateway.view.steps.DevSpacesRemoteServerConnectionStepView import com.github.devspaces.gateway.view.steps.DevSpacesOpenShiftConnectionStepView import com.github.devspaces.gateway.view.steps.DevSpacesWizardStep import com.intellij.openapi.Disposable @@ -27,12 +23,10 @@ import com.intellij.ui.dsl.builder.panel import com.intellij.util.ui.JBUI import com.intellij.util.ui.components.BorderLayoutPanel import com.jetbrains.gateway.api.GatewayUI -import com.jetbrains.rd.util.lifetime.waitTermination -import okio.Closeable import java.awt.Component import javax.swing.JButton -class DevSpacesWizardView(private val devSpacesContext: DevSpacesContext) : BorderLayoutPanel(), Disposable { +class DevSpacesWizardView(devSpacesContext: DevSpacesContext) : BorderLayoutPanel(), Disposable { private var steps = arrayListOf() private var currentStep = 0 @@ -41,7 +35,7 @@ class DevSpacesWizardView(private val devSpacesContext: DevSpacesContext) : Bord init { steps.add(DevSpacesOpenShiftConnectionStepView(devSpacesContext)) - steps.add(DevSpacesDevWorkspaceSelectingStepView(devSpacesContext)) + steps.add(DevSpacesRemoteServerConnectionStepView(devSpacesContext)) addToBottom(createButtons()) applyStep(0) @@ -75,16 +69,8 @@ class DevSpacesWizardView(private val devSpacesContext: DevSpacesContext) : Bord } } - @Suppress("UnstableApiUsage") private fun nextStep() { - if (!steps[currentStep].onNext()) return - - if (isLastStep()) { - val client = DevSpacesConnection(devSpacesContext).connect() - client.lifetime.waitTermination() - } else { - applyStep(+1) - } + if (steps[currentStep].onNext()) applyStep(+1) } private fun previousStep() { diff --git a/src/main/kotlin/com/github/devspaces/gateway/view/InformationDialog.kt b/src/main/kotlin/com/github/devspaces/gateway/view/InformationDialog.kt index a10d177..03c8142 100644 --- a/src/main/kotlin/com/github/devspaces/gateway/view/InformationDialog.kt +++ b/src/main/kotlin/com/github/devspaces/gateway/view/InformationDialog.kt @@ -13,11 +13,8 @@ package com.github.devspaces.gateway.view import com.intellij.openapi.ui.DialogWrapper import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBTextArea -import com.intellij.ui.components.JBTextField import com.intellij.ui.dsl.builder.AlignX import com.intellij.ui.dsl.builder.panel -import com.intellij.util.ui.JBUI import java.awt.Component import javax.swing.Action import javax.swing.JComponent diff --git a/src/main/kotlin/com/github/devspaces/gateway/view/LoaderDialog.kt b/src/main/kotlin/com/github/devspaces/gateway/view/LoaderDialog.kt new file mode 100644 index 0000000..73a58e0 --- /dev/null +++ b/src/main/kotlin/com/github/devspaces/gateway/view/LoaderDialog.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.github.devspaces.gateway.view + +import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.dsl.builder.Align +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.panel +import java.awt.Component +import java.awt.event.ActionListener +import javax.swing.Action +import javax.swing.JComponent + +class LoaderDialog(private var text: String, parent: Component) : DialogWrapper(parent, false) { + init { + super.init() + this.setUndecorated(true) + } + + fun hide() { + this.close(0) + } + + override fun createActions(): Array { + return arrayOf() + } + + override fun createCancelAction(): ActionListener? { + return null + } + + override fun createCenterPanel(): JComponent { + return panel { row { label(text).resizableColumn().align(Align.CENTER) } } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/github/devspaces/gateway/view/steps/DevSpacesDevWorkspaceSelectingStepView.kt b/src/main/kotlin/com/github/devspaces/gateway/view/steps/DevSpacesDevWorkspaceSelectingStepView.kt deleted file mode 100644 index d8eeca3..0000000 --- a/src/main/kotlin/com/github/devspaces/gateway/view/steps/DevSpacesDevWorkspaceSelectingStepView.kt +++ /dev/null @@ -1,146 +0,0 @@ -/* - * Copyright (c) 2024 Red Hat, Inc. - * This program and the accompanying materials are made - * available under the terms of the Eclipse Public License 2.0 - * which is available at https://www.eclipse.org/legal/epl-2.0/ - * - * SPDX-License-Identifier: EPL-2.0 - * - * Contributors: - * Red Hat, Inc. - initial API and implementation - */ -package com.github.devspaces.gateway.view.steps - -import com.github.devspaces.gateway.DevSpacesBundle -import com.github.devspaces.gateway.DevSpacesContext -import com.github.devspaces.gateway.openshift.DevWorkspaces -import com.github.devspaces.gateway.openshift.Projects -import com.github.devspaces.gateway.openshift.Utils -import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager -import com.intellij.ui.components.JBLabel -import com.intellij.ui.components.JBList -import com.intellij.ui.components.JBScrollPane -import com.intellij.ui.dsl.builder.AlignX -import com.intellij.ui.dsl.builder.RightGap -import com.intellij.ui.dsl.builder.panel -import com.intellij.ui.util.minimumHeight -import com.intellij.util.ui.JBFont -import com.intellij.util.ui.JBUI -import java.awt.Component -import javax.swing.* -import javax.swing.event.ListSelectionEvent -import javax.swing.event.ListSelectionListener - -class DevSpacesDevWorkspaceSelectingStepView(private var devSpacesContext: DevSpacesContext) : DevSpacesWizardStep { - override val nextActionText = DevSpacesBundle.message("connector.wizard_step.devworkspace_selecting.button.next") - override val previousActionText = - DevSpacesBundle.message("connector.wizard_step.devworkspace_selecting.button.previous") - - private var listDWDataModel = DefaultListModel() - private var listDevWorkspaces = JBList(listDWDataModel) - - private lateinit var stopDevWorkspaceButton: JButton - - override val component = panel { - row { - label(DevSpacesBundle.message("connector.wizard_step.devworkspace_selecting.title")).applyToComponent { - font = JBFont.h2().asBold() - } - } - row { - cell(JBScrollPane(listDevWorkspaces)).align(AlignX.FILL) - } - row { - label("").resizableColumn().align(AlignX.FILL) - - stopDevWorkspaceButton = - button(DevSpacesBundle.message("connector.wizard_step.devworkspace_selecting.button.stop")) { - stopDevWorkspace() - }.gap(RightGap.SMALL).align(AlignX.RIGHT).component - - button(DevSpacesBundle.message("connector.wizard_step.devworkspace_selecting.button.refresh")) { - refreshDevWorkspaces() - }.align(AlignX.RIGHT) - } - }.apply { - background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() - border = JBUI.Borders.empty(8) - } - - override fun onInit() { - listDevWorkspaces.selectionModel.addListSelectionListener(DevWorkspaceSelection()) - listDevWorkspaces.cellRenderer = DevWorkspaceListRenderer() - listDevWorkspaces.minimumHeight = 150 - listDevWorkspaces.setEmptyText(DevSpacesBundle.message("connector.wizard_step.devworkspace_selecting.list.empty_text")) - - refreshDevWorkspaces() - - listDevWorkspaces.selectedIndex = if (listDWDataModel.size > 0) 0 else -1 - } - - override fun onPrevious(): Boolean { - return true - } - - override fun onNext(): Boolean { - if (!listDevWorkspaces.isSelectionEmpty) { - devSpacesContext.devWorkspace = listDWDataModel.get(listDevWorkspaces.selectedIndex) - return true - } - return false - } - - private fun refreshDevWorkspaces() { - listDWDataModel.clear() - - val projects = Projects(devSpacesContext.client).list() as Map<*, *> - val projectItems = projects["items"] as List<*> - - projectItems.forEach { projectItem -> - val name = Utils.getValue(projectItem, arrayOf("metadata", "name")) as String - - val devWorkspaces = DevWorkspaces(devSpacesContext.client).list(name) as Map<*, *> - val devWorkspaceItems = devWorkspaces["items"] as List<*> - devWorkspaceItems.forEach { devWorkspaceItem -> listDWDataModel.addElement(devWorkspaceItem) } - } - } - - private fun stopDevWorkspace() { - if (listDevWorkspaces.selectedIndex != -1) { - val devWorkspace = listDWDataModel.get(listDevWorkspaces.selectedIndex) - val dwName = Utils.getValue(devWorkspace, arrayOf("metadata", "name")) as String - val dwNamespace = Utils.getValue(devWorkspace, arrayOf("metadata", "namespace")) as String - DevWorkspaces(devSpacesContext.client).stop(dwNamespace, dwName) - } - } - - inner class DevWorkspaceListRenderer : ListCellRenderer { - override fun getListCellRendererComponent( - list: JList?, - devWorkspace: Any, - index: Int, - isSelected: Boolean, - cellHasFocus: Boolean - ): Component { - val name = Utils.getValue(devWorkspace, arrayOf("metadata", "name")) as String - val phase = Utils.getValue(devWorkspace, arrayOf("status", "phase")) as String - val item = JBLabel(String.format("[%s] %s", phase, name)) - item.font = JBFont.h4().asPlain() - return item - } - } - - inner class DevWorkspaceSelection() : ListSelectionListener { - override fun valueChanged(e: ListSelectionEvent) { - val selectionModel = (e.source as ListSelectionModel) - - if (selectionModel.isSelectionEmpty) { - stopDevWorkspaceButton.isEnabled = false - return - } - - val devWorkspace = listDWDataModel.get(selectionModel.minSelectionIndex) - stopDevWorkspaceButton.isEnabled = Utils.getValue(devWorkspace, arrayOf("spec", "started")) as Boolean - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/com/github/devspaces/gateway/view/steps/DevSpacesOpenShiftConnectionStepView.kt b/src/main/kotlin/com/github/devspaces/gateway/view/steps/DevSpacesOpenShiftConnectionStepView.kt index c0196ff..fb1b0ec 100644 --- a/src/main/kotlin/com/github/devspaces/gateway/view/steps/DevSpacesOpenShiftConnectionStepView.kt +++ b/src/main/kotlin/com/github/devspaces/gateway/view/steps/DevSpacesOpenShiftConnectionStepView.kt @@ -17,6 +17,7 @@ import com.github.devspaces.gateway.openshift.OpenShiftClientFactory import com.github.devspaces.gateway.openshift.Projects import com.github.devspaces.gateway.settings.DevSpacesSettings import com.github.devspaces.gateway.view.InformationDialog +import com.google.gson.Gson import com.intellij.openapi.components.service import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager import com.intellij.ui.components.JBTextField @@ -25,6 +26,7 @@ import com.intellij.ui.dsl.builder.panel import com.intellij.util.ui.JBFont import com.intellij.util.ui.JBUI import io.kubernetes.client.openapi.ApiClient +import io.kubernetes.client.openapi.ApiException import io.kubernetes.client.openapi.auth.ApiKeyAuth import io.kubernetes.client.util.Config @@ -32,6 +34,7 @@ class DevSpacesOpenShiftConnectionStepView(private var devSpacesContext: DevSpac private var tfServer = JBTextField("") private var tfToken = JBTextField() + private var settingsAreLoaded = false private val settings = service() override val nextActionText = DevSpacesBundle.message("connector.wizard_step.openshift_connection.button.next") @@ -76,33 +79,41 @@ class DevSpacesOpenShiftConnectionStepView(private var devSpacesContext: DevSpac try { Projects(client).list() } catch (e: Exception) { - val dialog = InformationDialog("Failed to connect to OpenShift API server", e.message.orEmpty(), component) - dialog.show() + var errorMsg = e.message.orEmpty() + if (e is ApiException) { + val response = Gson().fromJson(e.responseBody, Map::class.java) + errorMsg = String.format("Reason: %s", String.format(response["message"] as String)) + } + + InformationDialog("Connection failed", errorMsg, component).show() throw e } } private fun loadOpenShiftConnectionSettings() { - var server = settings.state.server.orEmpty() - var token = settings.state.token.orEmpty() - - if (server.isEmpty() && token.isEmpty()) { + // load from kubeconfig first + try { val config = Config.defaultClient() - server = config.basePath + tfServer.text = config.basePath val auth = config.authentications["BearerToken"] - if (auth is ApiKeyAuth) token = auth.apiKey - - - if (server.isEmpty() && token.isEmpty()) return + if (auth is ApiKeyAuth) tfToken.text = auth.apiKey + } catch (e: Exception) { + // Do nothing } - tfServer.text = server - tfToken.text = token + // then from settings + if (tfServer.text.isEmpty() || tfToken.text.isEmpty()) { + tfServer.text = settings.state.server.orEmpty() + tfToken.text = settings.state.token.orEmpty() + settingsAreLoaded = true + } } private fun saveOpenShiftConnectionSettings() { - settings.state.server = tfServer.text - settings.state.token = tfToken.text + if (settingsAreLoaded) { + settings.state.server = tfServer.text + settings.state.token = tfToken.text + } } } \ No newline at end of file diff --git a/src/main/kotlin/com/github/devspaces/gateway/view/steps/DevSpacesRemoteServerConnectionStepView.kt b/src/main/kotlin/com/github/devspaces/gateway/view/steps/DevSpacesRemoteServerConnectionStepView.kt new file mode 100644 index 0000000..f753e51 --- /dev/null +++ b/src/main/kotlin/com/github/devspaces/gateway/view/steps/DevSpacesRemoteServerConnectionStepView.kt @@ -0,0 +1,275 @@ +/* + * Copyright (c) 2024 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package com.github.devspaces.gateway.view.steps + +import com.github.devspaces.gateway.DevSpacesBundle +import com.github.devspaces.gateway.DevSpacesConnection +import com.github.devspaces.gateway.DevSpacesContext +import com.github.devspaces.gateway.openshift.DevWorkspace +import com.github.devspaces.gateway.openshift.DevWorkspaces +import com.github.devspaces.gateway.openshift.Projects +import com.github.devspaces.gateway.openshift.Utils +import com.github.devspaces.gateway.view.InformationDialog +import com.github.devspaces.gateway.view.LoaderDialog +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.thisLogger +import com.intellij.openapi.wm.impl.welcomeScreen.WelcomeScreenUIManager +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBList +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.dsl.builder.AlignX +import com.intellij.ui.dsl.builder.RightGap +import com.intellij.ui.dsl.builder.panel +import com.intellij.util.ui.JBFont +import com.intellij.util.ui.JBUI +import java.awt.Component +import java.awt.EventQueue +import javax.swing.DefaultListModel +import javax.swing.JButton +import javax.swing.JList +import javax.swing.ListCellRenderer +import javax.swing.event.ListSelectionEvent +import javax.swing.event.ListSelectionListener + +class DevSpacesRemoteServerConnectionStepView(private var devSpacesContext: DevSpacesContext) : DevSpacesWizardStep { + override val nextActionText = DevSpacesBundle.message("connector.wizard_step.remote_server_connection.button.next") + override val previousActionText = + DevSpacesBundle.message("connector.wizard_step.remote_server_connection.button.previous") + + private var listDWDataModel = DefaultListModel() + private var listDevWorkspaces = JBList(listDWDataModel) + + private lateinit var stopDevWorkspaceButton: JButton + + override val component = panel { + row { + label(DevSpacesBundle.message("connector.wizard_step.remote_server_connection.title")).applyToComponent { + font = JBFont.h2().asBold() + } + } + row { + cell(JBScrollPane(listDevWorkspaces)).align(AlignX.FILL) + } + row { + label("").resizableColumn().align(AlignX.FILL) + + stopDevWorkspaceButton = + button(DevSpacesBundle.message("connector.wizard_step.remote_server_connection.button.stop")) { + stopDevWorkspace() + }.gap(RightGap.SMALL).align(AlignX.RIGHT).component + button( + DevSpacesBundle.message("connector.wizard_step.remote_server_connection.button.refresh") + ) { + refreshAllDevWorkspaces() + refreshStopButton() + }.gap(RightGap.SMALL).align(AlignX.RIGHT) + } + }.apply { + background = WelcomeScreenUIManager.getMainAssociatedComponentBackground() + border = JBUI.Borders.empty(8) + } + + override fun onInit() { + listDevWorkspaces.selectionModel.addListSelectionListener(DevWorkspaceSelection()) + listDevWorkspaces.cellRenderer = DevWorkspaceListRenderer() + listDevWorkspaces.setEmptyText(DevSpacesBundle.message("connector.wizard_step.remote_server_connection.list.empty_text")) + refreshAllDevWorkspaces() + refreshStopButton() + } + + override fun onPrevious(): Boolean { + return true + } + + override fun onNext(): Boolean { + if (!listDevWorkspaces.isSelectionEmpty) { + connect() + } + return false + } + + private fun refreshAllDevWorkspaces() { + val d = LoaderDialog( + DevSpacesBundle.message("connector.loader.devspaces.fetching.text"), + component + ) + ApplicationManager.getApplication().invokeLaterOnWriteThread { d.show() } + + Thread { + try { + doRefreshAllDevWorkspaces() + } finally { + EventQueue.invokeLater { d.hide() } + } + }.start() + } + + private fun refreshDevWorkspace(namespace: String, name: String) { + val refreshedDevWorkspace = DevWorkspaces(devSpacesContext.client).get(namespace, name) + + listDWDataModel + .indexOf(refreshedDevWorkspace) + .also { + if (it != -1) listDWDataModel[it] = refreshedDevWorkspace + } + } + + private fun doRefreshAllDevWorkspaces() { + val devWorkspaces = ArrayList() + + Projects(devSpacesContext.client) + .list() + .onEach { project -> + (Utils.getValue(project, arrayOf("metadata", "name")) as String) + .also { + devWorkspaces.addAll(DevWorkspaces(devSpacesContext.client).list(it)) + } + } + + + val selectedIndex = listDevWorkspaces.selectedIndex + + listDWDataModel.apply { + clear() + devWorkspaces.forEach { dw -> addElement(dw) } + } + + listDevWorkspaces.selectedIndex = selectedIndex + } + + private fun stopDevWorkspace() { + if (!listDevWorkspaces.isSelectionEmpty) { + listDWDataModel + .get(listDevWorkspaces.selectedIndex) + .also { + DevWorkspaces(devSpacesContext.client) + .stop( + it.metadata.namespace, + it.metadata.name + ) + + Thread { + if (waitDevWorkspaceStopped(it)) { + refreshDevWorkspace( + it.metadata.namespace, + it.metadata.name + ) + refreshStopButton() + } + }.start() + } + } + } + + private fun connect() { + if (devSpacesContext.isConnected) { + InformationDialog( + "Connection failed", + String.format( + "Already connected to %s", + devSpacesContext.devWorkspace.metadata.name + ), + component + ).show() + return + } + + listDWDataModel + .get(listDevWorkspaces.selectedIndex) + .also { + devSpacesContext.devWorkspace = it + } + + val loaderDialog = + LoaderDialog( + DevSpacesBundle.message("connector.loader.devspaces.connecting.text"), + component + ) + ApplicationManager.getApplication().invokeLaterOnWriteThread { loaderDialog.show() } + + Thread { + try { + DevSpacesConnection(devSpacesContext).connect( + { + EventQueue.invokeLater { loaderDialog.hide() } + refreshDevWorkspace( + devSpacesContext.devWorkspace.metadata.namespace, + devSpacesContext.devWorkspace.metadata.name + ) + refreshStopButton() + }, + { + }, + { + if (waitDevWorkspaceStopped(devSpacesContext.devWorkspace)) { + refreshDevWorkspace( + devSpacesContext.devWorkspace.metadata.namespace, + devSpacesContext.devWorkspace.metadata.name + ) + refreshStopButton() + } + } + ) + } catch (e: Exception) { + EventQueue.invokeLater { loaderDialog.hide() } + refreshDevWorkspace( + devSpacesContext.devWorkspace.metadata.namespace, + devSpacesContext.devWorkspace.metadata.name + ) + refreshStopButton() + thisLogger().error("Remote server connection failed.", e) + } + }.start() + } + + private fun waitDevWorkspaceStopped(devWorkspace: DevWorkspace): Boolean { + return DevWorkspaces(devSpacesContext.client) + .waitPhase( + devWorkspace.metadata.namespace, + devWorkspace.metadata.name, + DevWorkspaces.STOPPED, + 30 + ) + } + + private fun refreshStopButton() { + stopDevWorkspaceButton.isEnabled = + !listDevWorkspaces.isSelectionEmpty + && listDWDataModel.get(listDevWorkspaces.minSelectionIndex).spec.started + } + + inner class DevWorkspaceListRenderer : ListCellRenderer { + override fun getListCellRendererComponent( + list: JList?, + devWorkspace: DevWorkspace, + index: Int, + isSelected: Boolean, + cellHasFocus: Boolean + ): Component { + return JBLabel( + String.format( + "[%s] %s", + devWorkspace.status.phase, + devWorkspace.metadata.name + ) + ).also { + it.font = JBFont.h4().asPlain() + } + } + } + + inner class DevWorkspaceSelection : ListSelectionListener { + override fun valueChanged(e: ListSelectionEvent) { + refreshStopButton() + } + } +} \ No newline at end of file diff --git a/src/main/resources/messages/DevSpacesBundle.properties b/src/main/resources/messages/DevSpacesBundle.properties index c0acee3..bf835ba 100644 --- a/src/main/resources/messages/DevSpacesBundle.properties +++ b/src/main/resources/messages/DevSpacesBundle.properties @@ -10,12 +10,12 @@ connector.wizard_step.openshift_connection.button.previous=Exit connector.wizard_step.openshift_connection.button.next=Check connection and continue # Wizard selecting DevWorkspace step -connector.wizard_step.devworkspace_selecting.title=Select running DevWorkspace -connector.wizard_step.devworkspace_selecting.button.previous=Back -connector.wizard_step.devworkspace_selecting.button.next=Connect -connector.wizard_step.devworkspace_selecting.button.stop=Stop -connector.wizard_step.devworkspace_selecting.button.refresh=Refresh -connector.wizard_step.devworkspace_selecting.list.empty_text=There are no running DevWorkspace +connector.wizard_step.remote_server_connection.title=Select running DevWorkspace +connector.wizard_step.remote_server_connection.button.previous=Back +connector.wizard_step.remote_server_connection.button.next=Connect +connector.wizard_step.remote_server_connection.button.stop=Stop +connector.wizard_step.remote_server_connection.button.refresh=Refresh +connector.wizard_step.remote_server_connection.list.empty_text=There are no DevWorkspaces -connector.wizard.status_label.client_started=Status: Client started -connector.wizard.status_label.client_terminated=Status: Disconnected \ No newline at end of file +connector.loader.devspaces.fetching.text=Fetching DevWorkspaces... +connector.loader.devspaces.connecting.text=Connecting to the remote server...