diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f5d6da2..3855bb39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ## [Unreleased] +### Added + +- Stop Resource action to the dashboard + ## [0.7.0] - 2024-05-08 ### Changed diff --git a/gradle.properties b/gradle.properties index dd01d109..13c060cf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ pluginGroup = me.rafaelldi.aspire pluginName = aspire-plugin pluginRepositoryUrl = https://github.com/rafaelldi/aspire-plugin # SemVer format -> https://semver.org -pluginVersion = 0.7.0 +pluginVersion = 0.7.1 # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html pluginSinceBuild = 241 pluginUntilBuild = 241.* diff --git a/src/dotnet/aspire-session-host/Resources/resource_service.proto b/src/dotnet/aspire-session-host/Resources/resource_service.proto index 28ac5059..dc29a19e 100644 --- a/src/dotnet/aspire-session-host/Resources/resource_service.proto +++ b/src/dotnet/aspire-session-host/Resources/resource_service.proto @@ -14,7 +14,7 @@ message ApplicationInformationRequest { } message ApplicationInformationResponse { - string application_name = 1; + string application_name = 1; } //////////////////////////////////////////// @@ -24,215 +24,215 @@ message ApplicationInformationResponse { // When a command is to be executed, an instance of ResourceCommandRequest is constructed // using data from this message. message ResourceCommand { - // Unique identifier for the command. Not intended for display. - string command_type = 1; - // The display name of the command, to be shown in the UI. May be localized. - string display_name = 2; - // When present, this message must be shown to the user and their confirmation obtained - // before sending the request for this command to be executed. - // The user will be presented with Ok/Cancel options. - optional string confirmation_message = 3; - // Optional parameter that configures the command in some way. - // Clients must return any value provided by the server when invoking - // the command. - optional google.protobuf.Value parameter = 4; + // Unique identifier for the command. Not intended for display. + string command_type = 1; + // The display name of the command, to be shown in the UI. May be localized. + string display_name = 2; + // When present, this message must be shown to the user and their confirmation obtained + // before sending the request for this command to be executed. + // The user will be presented with Ok/Cancel options. + optional string confirmation_message = 3; + // Optional parameter that configures the command in some way. + // Clients must return any value provided by the server when invoking + // the command. + optional google.protobuf.Value parameter = 4; } // Represents a request to execute a command. // Sent by the dashboard to DashboardService.ExecuteResourceCommand. // Constructed with data from a corresponding message ResourceCommandRequest { - // Unique identifier for the command. - // Copied from the ResourceCommand that this request object is initialized from. - string command_type = 1; - // The name of the resource to apply the command to. Matches Resource.name. - // Copied from the ResourceCommand that this request object is initialized from. - string resource_name = 2; - // The unique name of the resource type. Matches ResourceType.unique_name and Resource.resource_type. - // Copied from the ResourceCommand that this request object is initialized from. - string resource_type = 3; - // An optional parameter to accompany the command. - // Copied from the ResourceCommand that this request object is initialized from. - optional google.protobuf.Value parameter = 4; + // Unique identifier for the command. + // Copied from the ResourceCommand that this request object is initialized from. + string command_type = 1; + // The name of the resource to apply the command to. Matches Resource.name. + // Copied from the ResourceCommand that this request object is initialized from. + string resource_name = 2; + // The unique name of the resource type. Matches ResourceType.unique_name and Resource.resource_type. + // Copied from the ResourceCommand that this request object is initialized from. + string resource_type = 3; + // An optional parameter to accompany the command. + // Copied from the ResourceCommand that this request object is initialized from. + optional google.protobuf.Value parameter = 4; } enum ResourceCommandResponseKind { - UNDEFINED = 0; - SUCCEEDED = 1; - FAILED = 2; - CANCELLED = 3; + UNDEFINED = 0; + SUCCEEDED = 1; + FAILED = 2; + CANCELLED = 3; } message ResourceCommandResponse { - ResourceCommandResponseKind kind = 1; - optional string error_message = 2; + ResourceCommandResponseKind kind = 1; + optional string error_message = 2; } //////////////////////////////////////////// message ResourceType { - // Unique name for the resource type. Equivalent to Resource.resource_type - // If "display_name" is omitted, this value will be used in UIs. - string unique_name = 1; + // Unique name for the resource type. Equivalent to Resource.resource_type + // If "display_name" is omitted, this value will be used in UIs. + string unique_name = 1; - // Display string for references to this type in UI. May be localized. - // If this value is omitted, UIs will show "unique_name" instead. - optional string display_name = 2; + // Display string for references to this type in UI. May be localized. + // If this value is omitted, UIs will show "unique_name" instead. + optional string display_name = 2; - // Any commands that may be executed against resources of this type, avoiding - // the need to copy the value to every Resource instance. - // - // Note that these commands must apply to matching resources at any time. - // - // If the set of commands changes over time, use the "commands" property - // of the Resource itself. - repeated ResourceCommand commands = 3; + // Any commands that may be executed against resources of this type, avoiding + // the need to copy the value to every Resource instance. + // + // Note that these commands must apply to matching resources at any time. + // + // If the set of commands changes over time, use the "commands" property + // of the Resource itself. + repeated ResourceCommand commands = 3; } //////////////////////////////////////////// message EnvironmentVariable { - string name = 1; - optional string value = 2; - bool is_from_spec = 3; + string name = 1; + optional string value = 2; + bool is_from_spec = 3; } message Endpoint { - string endpoint_url = 1; - string proxy_url = 2; + string endpoint_url = 1; + string proxy_url = 2; } message Service { - string name = 1; - optional string allocated_address = 2; - optional int32 allocated_port = 3; + string name = 1; + optional string allocated_address = 2; + optional int32 allocated_port = 3; } message Url { - // The name of the url - string name = 1; - // The uri of the url. Format is scheme://host:port/{*path} - string full_url = 2; - // Determines if this url shows up in the details view only by default. - // If true, the url will not be shown in the list of urls in the top level resources view. - bool is_internal = 3; + // The name of the url + string name = 1; + // The uri of the url. Format is scheme://host:port/{*path} + string full_url = 2; + // Determines if this url shows up in the details view only by default. + // If true, the url will not be shown in the list of urls in the top level resources view. + bool is_internal = 3; } message ResourceProperty { - // Name of the data item, e.g. "container.id", "executable.pid", "project.path", ... - string name = 1; - // TODO move display_name to reference data, sent once when the connection starts - // Optional display name, may be localized - optional string display_name = 2; - // The data value. May be null, a number, a string, a boolean, a dictionary of values (Struct), or a list of values (ValueList). - google.protobuf.Value value = 3; + // Name of the data item, e.g. "container.id", "executable.pid", "project.path", ... + string name = 1; + // TODO move display_name to reference data, sent once when the connection starts + // Optional display name, may be localized + optional string display_name = 2; + // The data value. May be null, a number, a string, a boolean, a dictionary of values (Struct), or a list of values (ValueList). + google.protobuf.Value value = 3; } // Models the full state of an resource (container, executable, project, etc) at a particular point in time. message Resource { - string name = 1; - string resource_type = 2; - string display_name = 3; - string uid = 4; - optional string state = 5; - optional google.protobuf.Timestamp created_at = 6; - repeated EnvironmentVariable environment = 7; - - // Deprecated and replaced with urls - optional int32 expected_endpoints_count = 8 [deprecated = true]; - repeated Endpoint endpoints = 9 [deprecated = true]; - repeated Service services = 10 [deprecated = true]; - - repeated ResourceCommand commands = 11; - - // Properties holding data not modeled directly on the message. - // - // For: - // - Containers: image, container_id, ports - // - Executables: process_id, executable_path, working_directory, arguments - // - Projects: process_id, project_path - repeated ResourceProperty properties = 12; - - // The list of urls that this resource exposes - repeated Url urls = 13; - - // The style of the state. This is used to determine the state icon. - // Supported styles are "success", "info", "warning" and "error". Any other style - // will be treated as "unknown". - optional string state_style = 14; + string name = 1; + string resource_type = 2; + string display_name = 3; + string uid = 4; + optional string state = 5; + optional google.protobuf.Timestamp created_at = 6; + repeated EnvironmentVariable environment = 7; + + // Deprecated and replaced with urls + optional int32 expected_endpoints_count = 8 [deprecated = true]; + repeated Endpoint endpoints = 9 [deprecated = true]; + repeated Service services = 10 [deprecated = true]; + + repeated ResourceCommand commands = 11; + + // Properties holding data not modeled directly on the message. + // + // For: + // - Containers: image, container_id, ports + // - Executables: process_id, executable_path, working_directory, arguments + // - Projects: process_id, project_path + repeated ResourceProperty properties = 12; + + // The list of urls that this resource exposes + repeated Url urls = 13; + + // The style of the state. This is used to determine the state icon. + // Supported styles are "success", "info", "warning" and "error". Any other style + // will be treated as "unknown". + optional string state_style = 14; } //////////////////////////////////////////// // Models a snapshot of resource state message InitialResourceData { - repeated Resource resources = 1; - repeated ResourceType resource_types = 2; + repeated Resource resources = 1; + repeated ResourceType resource_types = 2; } //////////////////////////////////////////// message ResourceDeletion { - string resource_name = 1; - string resource_type = 2; + string resource_name = 1; + string resource_type = 2; } message WatchResourcesChange { - oneof kind { - ResourceDeletion delete = 1; - Resource upsert = 2; - } + oneof kind { + ResourceDeletion delete = 1; + Resource upsert = 2; + } } message WatchResourcesChanges { - repeated WatchResourcesChange value = 1; + repeated WatchResourcesChange value = 1; } //////////////////////////////////////////// // Initiates a subscription for data about resources. message WatchResourcesRequest { - // True if the client is establishing this connection because a prior one closed unexpectedly. - optional bool is_reconnect = 1; + // True if the client is establishing this connection because a prior one closed unexpectedly. + optional bool is_reconnect = 1; } // A message received from the server when watching resources. Has multiple types of payload. message WatchResourcesUpdate { - oneof kind { - // The current resource state, along with other reference data such as the set of resource types that may exist. - // Received once upon connection, before any changes. - InitialResourceData initial_data = 1; - // One or more deltas to apply. - WatchResourcesChanges changes = 2; - } + oneof kind { + // The current resource state, along with other reference data such as the set of resource types that may exist. + // Received once upon connection, before any changes. + InitialResourceData initial_data = 1; + // One or more deltas to apply. + WatchResourcesChanges changes = 2; + } } //////////////////////////////////////////// message ConsoleLogLine { - string text = 1; - // Indicates whether this line came from STDERR or not. - optional bool is_std_err = 2; - int32 line_number = 3; + string text = 1; + // Indicates whether this line came from STDERR or not. + optional bool is_std_err = 2; + int32 line_number = 3; } // Initiates a subscription for the logs of a resource. message WatchResourceConsoleLogsRequest { - // Specifies the resource to watch logs from. - string resource_name = 1; + // Specifies the resource to watch logs from. + string resource_name = 1; } // A message received from the server when watching resource logs. // Contains potentially many lines to be appended to the log. message WatchResourceConsoleLogsUpdate { - repeated ConsoleLogLine log_lines = 1; + repeated ConsoleLogLine log_lines = 1; } //////////////////////////////////////////// service DashboardService { - rpc GetApplicationInformation(ApplicationInformationRequest) returns (ApplicationInformationResponse); - rpc WatchResources(WatchResourcesRequest) returns (stream WatchResourcesUpdate); - rpc WatchResourceConsoleLogs(WatchResourceConsoleLogsRequest) returns (stream WatchResourceConsoleLogsUpdate); - rpc ExecuteResourceCommand(ResourceCommandRequest) returns (ResourceCommandResponse); + rpc GetApplicationInformation(ApplicationInformationRequest) returns (ApplicationInformationResponse); + rpc WatchResources(WatchResourcesRequest) returns (stream WatchResourcesUpdate); + rpc WatchResourceConsoleLogs(WatchResourceConsoleLogsRequest) returns (stream WatchResourceConsoleLogsUpdate); + rpc ExecuteResourceCommand(ResourceCommandRequest) returns (ResourceCommandResponse); } diff --git a/src/main/kotlin/me/rafaelldi/aspire/actions/dashboard/DebugResourceAction.kt b/src/main/kotlin/me/rafaelldi/aspire/actions/dashboard/DebugResourceAction.kt new file mode 100644 index 00000000..7ec22ac2 --- /dev/null +++ b/src/main/kotlin/me/rafaelldi/aspire/actions/dashboard/DebugResourceAction.kt @@ -0,0 +1,60 @@ +package me.rafaelldi.aspire.actions.dashboard + +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import kotlinx.coroutines.launch +import me.rafaelldi.aspire.AspireService +import me.rafaelldi.aspire.generated.ResourceState +import me.rafaelldi.aspire.generated.ResourceType +import me.rafaelldi.aspire.sessionHost.SessionManager +import me.rafaelldi.aspire.util.ASPIRE_RESOURCE_STATE +import me.rafaelldi.aspire.util.ASPIRE_RESOURCE_TYPE +import me.rafaelldi.aspire.util.ASPIRE_RESOURCE_UID + +class DebugResourceAction : AnAction() { + override fun actionPerformed(event: AnActionEvent) { + val project = event.project ?: return + val resourceUid = event.getData(ASPIRE_RESOURCE_UID) ?: return + val resourceType = event.getData(ASPIRE_RESOURCE_TYPE) ?: return + val resourceState = event.getData(ASPIRE_RESOURCE_STATE) ?: return + + if (resourceType != ResourceType.Project || resourceState != ResourceState.Finished) { + return + } + + AspireService.getInstance(project).scope.launch { + SessionManager.getInstance(project).debugResource(resourceUid) + } + } + + override fun update(event: AnActionEvent) { + val project = event.project + if (project == null) { + event.presentation.isEnabledAndVisible = false + return + } + + val resourceUid = event.getData(ASPIRE_RESOURCE_UID) + val resourceType = event.getData(ASPIRE_RESOURCE_TYPE) + val resourceState = event.getData(ASPIRE_RESOURCE_STATE) + if (resourceUid == null || resourceType == null || resourceState == null) { + event.presentation.isEnabledAndVisible = false + return + } + + if (resourceType != ResourceType.Project || resourceState != ResourceState.Finished) { + event.presentation.isEnabledAndVisible = false + return + } + + if (!SessionManager.getInstance(project).isResourceStopped(resourceUid)) { + event.presentation.isEnabledAndVisible = false + return + } + + event.presentation.isEnabledAndVisible = true + } + + override fun getActionUpdateThread() = ActionUpdateThread.EDT +} \ No newline at end of file diff --git a/src/main/kotlin/me/rafaelldi/aspire/actions/dashboard/StartResourceAction.kt b/src/main/kotlin/me/rafaelldi/aspire/actions/dashboard/StartResourceAction.kt new file mode 100644 index 00000000..462a0c8c --- /dev/null +++ b/src/main/kotlin/me/rafaelldi/aspire/actions/dashboard/StartResourceAction.kt @@ -0,0 +1,60 @@ +package me.rafaelldi.aspire.actions.dashboard + +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import kotlinx.coroutines.launch +import me.rafaelldi.aspire.AspireService +import me.rafaelldi.aspire.generated.ResourceState +import me.rafaelldi.aspire.generated.ResourceType +import me.rafaelldi.aspire.sessionHost.SessionManager +import me.rafaelldi.aspire.util.ASPIRE_RESOURCE_STATE +import me.rafaelldi.aspire.util.ASPIRE_RESOURCE_TYPE +import me.rafaelldi.aspire.util.ASPIRE_RESOURCE_UID + +class StartResourceAction : AnAction() { + override fun actionPerformed(event: AnActionEvent) { + val project = event.project ?: return + val resourceUid = event.getData(ASPIRE_RESOURCE_UID) ?: return + val resourceType = event.getData(ASPIRE_RESOURCE_TYPE) ?: return + val resourceState = event.getData(ASPIRE_RESOURCE_STATE) ?: return + + if (resourceType != ResourceType.Project || resourceState != ResourceState.Finished) { + return + } + + AspireService.getInstance(project).scope.launch { + SessionManager.getInstance(project).startResource(resourceUid) + } + } + + override fun update(event: AnActionEvent) { + val project = event.project + if (project == null) { + event.presentation.isEnabledAndVisible = false + return + } + + val resourceUid = event.getData(ASPIRE_RESOURCE_UID) + val resourceType = event.getData(ASPIRE_RESOURCE_TYPE) + val resourceState = event.getData(ASPIRE_RESOURCE_STATE) + if (resourceUid == null || resourceType == null || resourceState == null) { + event.presentation.isEnabledAndVisible = false + return + } + + if (resourceType != ResourceType.Project || resourceState != ResourceState.Finished) { + event.presentation.isEnabledAndVisible = false + return + } + + if (!SessionManager.getInstance(project).isResourceStopped(resourceUid)) { + event.presentation.isEnabledAndVisible = false + return + } + + event.presentation.isEnabledAndVisible = true + } + + override fun getActionUpdateThread() = ActionUpdateThread.EDT +} \ No newline at end of file diff --git a/src/main/kotlin/me/rafaelldi/aspire/actions/dashboard/StopResourceAction.kt b/src/main/kotlin/me/rafaelldi/aspire/actions/dashboard/StopResourceAction.kt new file mode 100644 index 00000000..38b15c3b --- /dev/null +++ b/src/main/kotlin/me/rafaelldi/aspire/actions/dashboard/StopResourceAction.kt @@ -0,0 +1,56 @@ +package me.rafaelldi.aspire.actions.dashboard + +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import me.rafaelldi.aspire.generated.ResourceState +import me.rafaelldi.aspire.generated.ResourceType +import me.rafaelldi.aspire.sessionHost.SessionManager +import me.rafaelldi.aspire.util.ASPIRE_RESOURCE_STATE +import me.rafaelldi.aspire.util.ASPIRE_RESOURCE_TYPE +import me.rafaelldi.aspire.util.ASPIRE_RESOURCE_UID + +class StopResourceAction : AnAction() { + override fun actionPerformed(event: AnActionEvent) { + val project = event.project ?: return + val resourceUid = event.getData(ASPIRE_RESOURCE_UID) ?: return + val resourceType = event.getData(ASPIRE_RESOURCE_TYPE) ?: return + val resourceState = event.getData(ASPIRE_RESOURCE_STATE) ?: return + + if (resourceType != ResourceType.Project || resourceState != ResourceState.Running) { + return + } + + SessionManager.getInstance(project).stopResource(resourceUid) + } + + override fun update(event: AnActionEvent) { + val project = event.project + if (project == null) { + event.presentation.isEnabledAndVisible = false + return + } + + val resourceUid = event.getData(ASPIRE_RESOURCE_UID) + val resourceType = event.getData(ASPIRE_RESOURCE_TYPE) + val resourceState = event.getData(ASPIRE_RESOURCE_STATE) + if (resourceUid == null || resourceType == null || resourceState == null) { + event.presentation.isEnabledAndVisible = false + return + } + + if (resourceType != ResourceType.Project || resourceState != ResourceState.Running) { + event.presentation.isEnabledAndVisible = false + return + } + + if (!SessionManager.getInstance(project).isResourceRunning(resourceUid)) { + event.presentation.isEnabledAndVisible = false + return + } + + event.presentation.isEnabledAndVisible = true + } + + override fun getActionUpdateThread() = ActionUpdateThread.EDT +} \ No newline at end of file diff --git a/src/main/kotlin/me/rafaelldi/aspire/run/AspireHostProjectConfig.kt b/src/main/kotlin/me/rafaelldi/aspire/run/AspireHostConfig.kt similarity index 93% rename from src/main/kotlin/me/rafaelldi/aspire/run/AspireHostProjectConfig.kt rename to src/main/kotlin/me/rafaelldi/aspire/run/AspireHostConfig.kt index 6d1af998..cd7197b1 100644 --- a/src/main/kotlin/me/rafaelldi/aspire/run/AspireHostProjectConfig.kt +++ b/src/main/kotlin/me/rafaelldi/aspire/run/AspireHostConfig.kt @@ -3,7 +3,7 @@ package me.rafaelldi.aspire.run import com.jetbrains.rd.util.lifetime.Lifetime import java.nio.file.Path -data class AspireHostProjectConfig( +data class AspireHostConfig( val debugSessionToken: String, val debugSessionPort: Int, val aspireHostProjectPath: Path, diff --git a/src/main/kotlin/me/rafaelldi/aspire/run/AspireHostProgramRunner.kt b/src/main/kotlin/me/rafaelldi/aspire/run/AspireHostProgramRunner.kt index 28a8ba10..cbce0d20 100644 --- a/src/main/kotlin/me/rafaelldi/aspire/run/AspireHostProgramRunner.kt +++ b/src/main/kotlin/me/rafaelldi/aspire/run/AspireHostProgramRunner.kt @@ -77,7 +77,7 @@ class AspireHostProgramRunner : DotNetProgramRunner() { val aspireHostLifetime = environment.project.lifetime.createNested() - val config = AspireHostProjectConfig( + val config = AspireHostConfig( debugSessionToken, debugSessionPort, aspireHostProjectPath, @@ -103,7 +103,7 @@ class AspireHostProgramRunner : DotNetProgramRunner() { .startAspireHostService(config, sessionHostModel) SessionHostManager.getInstance(environment.project) - .addSessionHost(config, protocol.wire.serverPort, sessionHostModel) + .startSessionHost(config, protocol.wire.serverPort, sessionHostModel) }.asPromise() return sessionHostPromise.then { diff --git a/src/main/kotlin/me/rafaelldi/aspire/services/AspireResourceService.kt b/src/main/kotlin/me/rafaelldi/aspire/services/AspireResourceService.kt index 12f94be5..f7442ce0 100644 --- a/src/main/kotlin/me/rafaelldi/aspire/services/AspireResourceService.kt +++ b/src/main/kotlin/me/rafaelldi/aspire/services/AspireResourceService.kt @@ -23,6 +23,8 @@ class AspireResourceService( private val hostService: AspireHostService, private val project: Project ) { + var uid: String + private set var name: String private set var type: ResourceType @@ -74,6 +76,7 @@ class AspireResourceService( init { val model = wrapper.model.valueOrNull + uid = model?.uid ?: "" name = model?.name ?: "" type = model?.type ?: ResourceType.Unknown displayName = model?.displayName ?: "" @@ -150,6 +153,7 @@ class AspireResourceService( } private fun update(resourceModel: ResourceModel) { + uid = resourceModel.uid name = resourceModel.name type = resourceModel.type displayName = resourceModel.displayName diff --git a/src/main/kotlin/me/rafaelldi/aspire/services/AspireResourceServiceViewDescriptor.kt b/src/main/kotlin/me/rafaelldi/aspire/services/AspireResourceServiceViewDescriptor.kt index 7417a2f1..ac5a1edc 100644 --- a/src/main/kotlin/me/rafaelldi/aspire/services/AspireResourceServiceViewDescriptor.kt +++ b/src/main/kotlin/me/rafaelldi/aspire/services/AspireResourceServiceViewDescriptor.kt @@ -4,6 +4,9 @@ package me.rafaelldi.aspire.services import com.intellij.execution.services.ServiceViewDescriptor import com.intellij.ide.projectView.PresentationData +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.DataProvider +import com.intellij.openapi.actionSystem.DefaultActionGroup import com.intellij.openapi.rd.util.withUiContext import com.intellij.ui.SimpleTextAttributes import com.intellij.ui.components.JBTabbedPane @@ -15,6 +18,9 @@ import me.rafaelldi.aspire.services.components.ResourceConsolePanel import me.rafaelldi.aspire.services.components.ResourceDashboardPanel import me.rafaelldi.aspire.services.components.ResourceMetricPanel import me.rafaelldi.aspire.settings.AspireSettings +import me.rafaelldi.aspire.util.ASPIRE_RESOURCE_STATE +import me.rafaelldi.aspire.util.ASPIRE_RESOURCE_TYPE +import me.rafaelldi.aspire.util.ASPIRE_RESOURCE_UID import me.rafaelldi.aspire.util.getIcon import java.awt.BorderLayout import javax.swing.JPanel @@ -22,7 +28,11 @@ import kotlin.time.Duration.Companion.seconds class AspireResourceServiceViewDescriptor( private val resourceService: AspireResourceService -) : ServiceViewDescriptor { +) : ServiceViewDescriptor, DataProvider { + + private val toolbarActions = DefaultActionGroup( + ActionManager.getInstance().getAction("Aspire.Resource.Stop") + ) private val metricPanelDelegate = lazy { ResourceMetricPanel(resourceService) } private val metricPanel by metricPanelDelegate @@ -53,6 +63,12 @@ class AspireResourceServiceViewDescriptor( } } + private fun update() { + if (metricPanelDelegate.isInitialized()) { + metricPanel.update() + } + } + override fun getPresentation() = PresentationData().apply { val icon = getIcon(resourceService.type, resourceService.state) setIcon(icon) @@ -61,9 +77,13 @@ class AspireResourceServiceViewDescriptor( override fun getContentComponent() = mainPanel - private fun update() { - if (metricPanelDelegate.isInitialized()) { - metricPanel.update() - } - } + override fun getToolbarActions() = toolbarActions + + override fun getDataProvider() = this + + override fun getData(dataId: String) = + if (ASPIRE_RESOURCE_UID.`is`(dataId)) resourceService.uid + else if (ASPIRE_RESOURCE_TYPE.`is`(dataId)) resourceService.type + else if (ASPIRE_RESOURCE_STATE.`is`(dataId)) resourceService.state + else null } \ No newline at end of file diff --git a/src/main/kotlin/me/rafaelldi/aspire/services/AspireServiceManager.kt b/src/main/kotlin/me/rafaelldi/aspire/services/AspireServiceManager.kt index 885a6499..01a44285 100644 --- a/src/main/kotlin/me/rafaelldi/aspire/services/AspireServiceManager.kt +++ b/src/main/kotlin/me/rafaelldi/aspire/services/AspireServiceManager.kt @@ -18,7 +18,7 @@ import me.rafaelldi.aspire.generated.ResourceState import me.rafaelldi.aspire.generated.ResourceType import me.rafaelldi.aspire.generated.ResourceWrapper import me.rafaelldi.aspire.run.AspireHostConfiguration -import me.rafaelldi.aspire.run.AspireHostProjectConfig +import me.rafaelldi.aspire.run.AspireHostConfig import java.nio.file.Path import java.util.concurrent.ConcurrentHashMap import kotlin.io.path.Path @@ -101,7 +101,7 @@ class AspireServiceManager(private val project: Project) { } suspend fun startAspireHostService( - aspireHostConfig: AspireHostProjectConfig, + aspireHostConfig: AspireHostConfig, sessionHostModel: AspireSessionHostModel ) { val hostPathString = aspireHostConfig.aspireHostProjectPath.absolutePathString() diff --git a/src/main/kotlin/me/rafaelldi/aspire/services/components/ResourceDashboardPanel.kt b/src/main/kotlin/me/rafaelldi/aspire/services/components/ResourceDashboardPanel.kt index 5d814cde..fee7cc59 100644 --- a/src/main/kotlin/me/rafaelldi/aspire/services/components/ResourceDashboardPanel.kt +++ b/src/main/kotlin/me/rafaelldi/aspire/services/components/ResourceDashboardPanel.kt @@ -1,11 +1,14 @@ package me.rafaelldi.aspire.services.components +import com.intellij.icons.AllIcons import com.intellij.ide.BrowserUtil +import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.ui.DialogPanel import com.intellij.ui.ScrollPaneFactory import com.intellij.ui.SideBorder import com.intellij.ui.dsl.builder.BottomGap import com.intellij.ui.dsl.builder.RightGap +import com.intellij.ui.dsl.builder.actionButton import com.intellij.ui.dsl.builder.panel import com.intellij.util.ui.JBUI import com.intellij.util.ui.UIUtil @@ -59,6 +62,12 @@ class ResourceDashboardPanel(resourceService: AspireResourceService) : BorderLay separator() .gap(RightGap.SMALL) copyableLabel(state.name, color = UIUtil.FontColor.BRIGHTER) + .gap(RightGap.COLUMNS) + } + + if (resourceData.type == ResourceType.Project) { + val stopAction = ActionManager.getInstance().getAction("Aspire.Resource.Stop") + actionButton(stopAction) } } separator() diff --git a/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionHostLauncher.kt b/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionHostLauncher.kt index 0db87078..10c27c6d 100644 --- a/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionHostLauncher.kt +++ b/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionHostLauncher.kt @@ -17,7 +17,7 @@ import com.intellij.openapi.util.Key import com.jetbrains.rd.util.lifetime.LifetimeDefinition import com.jetbrains.rider.runtime.RiderDotNetActiveRuntimeHost import com.jetbrains.rider.runtime.dotNetCore.DotNetCoreRuntime -import me.rafaelldi.aspire.run.AspireHostProjectConfig +import me.rafaelldi.aspire.run.AspireHostConfig import me.rafaelldi.aspire.settings.AspireSettings import me.rafaelldi.aspire.util.decodeAnsiCommandsToString import java.nio.charset.StandardCharsets @@ -47,7 +47,7 @@ class SessionHostLauncher(private val project: Project) { } fun launchSessionHost( - aspireHostConfig: AspireHostProjectConfig, + aspireHostConfig: AspireHostConfig, sessionHostRdPort: Int, aspireHostLifetime: LifetimeDefinition ) { @@ -90,7 +90,7 @@ class SessionHostLauncher(private val project: Project) { private fun getCommandLine( dotnet: DotNetCoreRuntime, - aspireHostConfig: AspireHostProjectConfig, + aspireHostConfig: AspireHostConfig, rdPort: Int ): GeneralCommandLine { val settings = AspireSettings.getInstance() diff --git a/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionHostManager.kt b/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionHostManager.kt index e17dcf5e..3f7157d1 100644 --- a/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionHostManager.kt +++ b/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionHostManager.kt @@ -17,7 +17,7 @@ import me.rafaelldi.aspire.generated.AspireSessionHostModel import me.rafaelldi.aspire.generated.LogReceived import me.rafaelldi.aspire.generated.ProcessStarted import me.rafaelldi.aspire.generated.ProcessTerminated -import me.rafaelldi.aspire.run.AspireHostProjectConfig +import me.rafaelldi.aspire.run.AspireHostConfig @Service(Service.Level.PROJECT) class SessionHostManager(private val project: Project, private val scope: CoroutineScope) { @@ -27,8 +27,8 @@ class SessionHostManager(private val project: Project, private val scope: Corout private val LOG = logger() } - suspend fun addSessionHost( - aspireHostConfig: AspireHostProjectConfig, + suspend fun startSessionHost( + aspireHostConfig: AspireHostConfig, protocolServerPort: Int, sessionHostModel: AspireSessionHostModel ) { diff --git a/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionManager.kt b/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionManager.kt index 81588149..d5471ba5 100644 --- a/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionManager.kt +++ b/src/main/kotlin/me/rafaelldi/aspire/sessionHost/SessionManager.kt @@ -1,5 +1,6 @@ package me.rafaelldi.aspire.sessionHost +import com.intellij.database.util.common.removeIf import com.intellij.openapi.application.EDT import com.intellij.openapi.components.Service import com.intellij.openapi.components.service @@ -8,7 +9,7 @@ import com.intellij.openapi.project.Project import com.intellij.util.application import com.jetbrains.rd.framework.util.setSuspend import com.jetbrains.rd.util.lifetime.Lifetime -import com.jetbrains.rd.util.lifetime.LifetimeDefinition +import com.jetbrains.rd.util.lifetime.SequentialLifetimes import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow @@ -18,7 +19,7 @@ import kotlinx.coroutines.withContext import me.rafaelldi.aspire.generated.AspireSessionHostModel import me.rafaelldi.aspire.generated.SessionCreationResult import me.rafaelldi.aspire.generated.SessionModel -import me.rafaelldi.aspire.run.AspireHostProjectConfig +import me.rafaelldi.aspire.run.AspireHostConfig import java.util.* @Service(Service.Level.PROJECT) @@ -29,7 +30,8 @@ class SessionManager(private val project: Project, scope: CoroutineScope) { private val LOG = logger() } - private val sessions = mutableMapOf() + private val sessions = mutableMapOf() + private val resourceToSessionMap = mutableMapOf() private val commands = MutableSharedFlow( onBufferOverflow = BufferOverflow.DROP_OLDEST, @@ -43,7 +45,7 @@ class SessionManager(private val project: Project, scope: CoroutineScope) { } suspend fun addSessionHost( - aspireHostConfig: AspireHostProjectConfig, + aspireHostConfig: AspireHostConfig, sessionHostModel: AspireSessionHostModel, sessionEvents: MutableSharedFlow, sessionHostLifetime: Lifetime @@ -64,7 +66,7 @@ class SessionManager(private val project: Project, scope: CoroutineScope) { private suspend fun createSession( sessionModel: SessionModel, sessionEvents: MutableSharedFlow, - aspireHostConfig: AspireHostProjectConfig, + aspireHostConfig: AspireHostConfig, sessionHostLifetime: Lifetime ): SessionCreationResult { val sessionId = UUID.randomUUID().toString() @@ -88,6 +90,51 @@ class SessionManager(private val project: Project, scope: CoroutineScope) { return true } + fun isResourceRunning(resourceId: String): Boolean { + val sessionId = resourceToSessionMap[resourceId] ?: return false + val session = sessions[sessionId] ?: return false + return !session.lifetimes.isTerminated + } + + fun isResourceStopped(resourceId: String): Boolean { + val sessionId = resourceToSessionMap[resourceId] ?: return false + val session = sessions[sessionId] ?: return false + return session.lifetimes.isTerminated + } + + suspend fun startResource(resourceId: String) { + val sessionId = resourceToSessionMap[resourceId] ?: return + val session = sessions[sessionId] ?: return + if (!session.lifetimes.isTerminated) return + launchSession(session, false) + } + + suspend fun debugResource(resourceId: String) { + val sessionId = resourceToSessionMap[resourceId] ?: return + val session = sessions[sessionId] ?: return + if (!session.lifetimes.isTerminated) return + launchSession(session, true) + } + + private suspend fun launchSession(session: Session, debuggingMode: Boolean) { + val launcher = SessionLauncher.getInstance(project) + launcher.launchSession( + session.id, + session.model, + session.lifetimes.next(), + session.events, + debuggingMode, + session.openTelemetryProtocolServerPort + ) + } + + fun stopResource(resourceId: String) { + val sessionId = resourceToSessionMap[resourceId] ?: return + val session = sessions[sessionId] ?: return + if (session.lifetimes.isTerminated) return + session.lifetimes.terminateCurrent() + } + private suspend fun handleCommand(command: LaunchSessionCommand) { when (command) { is CreateSessionCommand -> handleCreateCommand(command) @@ -98,37 +145,65 @@ class SessionManager(private val project: Project, scope: CoroutineScope) { private suspend fun handleCreateCommand(command: CreateSessionCommand) { LOG.trace("Creating session ${command.sessionId}, ${command.sessionModel}") - val sessionLifetimeDef = command.sessionHostLifetime.createNested() - sessions[command.sessionId] = sessionLifetimeDef - - val launcher = SessionLauncher.getInstance(project) - launcher.launchSession( + val session = Session( command.sessionId, command.sessionModel, - sessionLifetimeDef.lifetime, + SequentialLifetimes(command.sessionHostLifetime), command.sessionEvents, - command.aspireHostConfig.debuggingMode, command.aspireHostConfig.openTelemetryProtocolServerPort ) + sessions[command.sessionId] = session + + saveConnectionToResource(command) + + val launcher = SessionLauncher.getInstance(project) + launcher.launchSession( + session.id, + session.model, + session.lifetimes.next(), + session.events, + command.aspireHostConfig.debuggingMode, + session.openTelemetryProtocolServerPort + ) + } + + private fun saveConnectionToResource(command: CreateSessionCommand) { + val resourceAttributes = + command.sessionModel.envs?.firstOrNull { it.key.equals("OTEL_RESOURCE_ATTRIBUTES", true) }?.value ?: return + val serviceInstanceId = + resourceAttributes.split(",").firstOrNull { it.startsWith("service.instance.id") } ?: return + val idValue = serviceInstanceId.removePrefix("service.instance.id=") + if (idValue.isEmpty()) return + + resourceToSessionMap[idValue] = command.sessionId } private fun handleDeleteCommand(command: DeleteSessionCommand) { LOG.trace("Deleting session ${command.sessionId}") - val lifetimes = sessions.remove(command.sessionId) ?: return + resourceToSessionMap.removeIf { it.value == command.sessionId } + val session = sessions.remove(command.sessionId) ?: return application.invokeLater { - lifetimes.terminate() + session.lifetimes.terminateCurrent() } } + data class Session( + val id: String, + val model: SessionModel, + val lifetimes: SequentialLifetimes, + val events: MutableSharedFlow, + val openTelemetryProtocolServerPort: Int + ) + interface LaunchSessionCommand data class CreateSessionCommand( val sessionId: String, val sessionModel: SessionModel, val sessionEvents: MutableSharedFlow, - val aspireHostConfig: AspireHostProjectConfig, + val aspireHostConfig: AspireHostConfig, val sessionHostLifetime: Lifetime ) : LaunchSessionCommand diff --git a/src/main/kotlin/me/rafaelldi/aspire/util/DataKeys.kt b/src/main/kotlin/me/rafaelldi/aspire/util/DataKeys.kt index 61ab49db..a0c9b014 100644 --- a/src/main/kotlin/me/rafaelldi/aspire/util/DataKeys.kt +++ b/src/main/kotlin/me/rafaelldi/aspire/util/DataKeys.kt @@ -1,5 +1,10 @@ package me.rafaelldi.aspire.util import com.intellij.openapi.actionSystem.DataKey +import me.rafaelldi.aspire.generated.ResourceState +import me.rafaelldi.aspire.generated.ResourceType -val ASPIRE_HOST_PATH: DataKey = DataKey.create("Aspire.Host.Path") \ No newline at end of file +val ASPIRE_HOST_PATH: DataKey = DataKey.create("Aspire.Host.Path") +val ASPIRE_RESOURCE_UID: DataKey = DataKey.create("Aspire.Resource.Uid") +val ASPIRE_RESOURCE_TYPE: DataKey = DataKey.create("Aspire.Resource.Type") +val ASPIRE_RESOURCE_STATE: DataKey = DataKey.create("Aspire.Resource.State") \ No newline at end of file diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index f26ec974..4663b770 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -73,6 +73,15 @@ icon="AllIcons.FileTypes.Json"> + + + diff --git a/src/main/resources/messages/AspireBundle.properties b/src/main/resources/messages/AspireBundle.properties index 81f7646a..7afcd424 100644 --- a/src/main/resources/messages/AspireBundle.properties +++ b/src/main/resources/messages/AspireBundle.properties @@ -1,5 +1,3 @@ -action.Aspire.Session.Stop.text=Stop Project -action.Aspire.Session.Stop.description=Stop the running project action.Aspire.Settings.text=Aspire Settings action.Aspire.Settings.description=Open plugin settings page action.Aspire.Help.text=Aspire Help @@ -12,6 +10,8 @@ action.Aspire.Diagram.text=Show Diagram action.Aspire.Diagram.description=Show distributed traces diagram action.Aspire.Solution.Manifest.text=Generate Aspire Manifest action.Aspire.Solution.Manifest.description=Generate .NET Aspire manifest +action.Aspire.Resource.Stop.text=Stop Resource +action.Aspire.Resource.Stop.description=Stop the running resource run.editor.project=Project: run.editor.launch.profile=Launch profile: @@ -24,16 +24,11 @@ configurable.Aspire.check.new.version=Check for new versions of the Aspire workl configurable.Aspire.connect.to.database=Automatically connect to a created database configurable.Aspire.collect.telemetry=Collect OpenTelemetry data (experimental) -progress.updating.aspire.workload=Updating Aspire workload progress.generating.aspire.manifest=Generating Aspire Manifest notification.new.version.is.available=A new version of Aspire workload is available -notifications.update.aspire.workload=Update Aspire workload notifications.go.to.documentation=Go to documentation notifications.do.not.check.for.updates=Do not check for updates -notifications.aspire.workload.updated=Aspire workload is successfully updated -notifications.aspire.workload.update.failed=Aspire workload update failed -notification.aspire.workload.update.elevated=Rider needs elevated permissions to update workload notification.manifest.unable.to.generate=Unable to generate Aspire manifest service.tab.dashboard=Dashboard @@ -53,6 +48,7 @@ service.tab.dashboard.properties.container.ports=Container Ports: service.tab.dashboard.properties.container.command=Container Command: service.tab.dashboard.properties.container.args=Container Args: service.tab.dashboard.environment=Environment +service.tab.dashboard.stop.resource=Stop Resource service.tab.console=Console service.tab.metrics=Metrics service.tab.metrics.table.scope=Scope