Skip to content

Commit 56797f0

Browse files
authored
Prompt users to continue their Dev Environment session (#3832)
* added heartbeat * feedback changes 1 * removed changes * added more tests * feedback changes 2 * addressed feedback * new changes * corrected millisecond diff * detekt * feedback changes * feedbackc hanges * added tests * addressed feedback * changed test * detekt * removed unused comma * addressed feedback * fixed detekt * Added test * detekt * detekt * detekt * prop change
1 parent f68cfb5 commit 56797f0

File tree

9 files changed

+267
-1
lines changed

9 files changed

+267
-1
lines changed

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ toolkitVersion=1.83-SNAPSHOT
88
publishToken=
99
publishChannel=
1010

11-
ideProfileName=2022.3
11+
ideProfileName=2023.2
1212

1313
remoteRobotPort=8080
1414

jetbrains-core/src/software/aws/toolkits/jetbrains/services/caws/envclient/CawsEnvironmentClient.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.intellij.openapi.components.service
1111
import org.apache.http.client.methods.CloseableHttpResponse
1212
import org.apache.http.client.methods.HttpGet
1313
import org.apache.http.client.methods.HttpPost
14+
import org.apache.http.client.methods.HttpPut
1415
import org.apache.http.client.methods.HttpUriRequest
1516
import org.apache.http.entity.ContentType
1617
import org.apache.http.entity.StringEntity
@@ -21,8 +22,10 @@ import software.aws.toolkits.core.utils.getLogger
2122
import software.aws.toolkits.jetbrains.services.caws.CawsConstants
2223
import software.aws.toolkits.jetbrains.services.caws.envclient.models.CreateDevfileRequest
2324
import software.aws.toolkits.jetbrains.services.caws.envclient.models.CreateDevfileResponse
25+
import software.aws.toolkits.jetbrains.services.caws.envclient.models.GetActivityResponse
2426
import software.aws.toolkits.jetbrains.services.caws.envclient.models.GetStatusResponse
2527
import software.aws.toolkits.jetbrains.services.caws.envclient.models.StartDevfileRequest
28+
import software.aws.toolkits.jetbrains.services.caws.envclient.models.UpdateActivityRequest
2629
import software.aws.toolkits.jetbrains.utils.notifyError
2730
import software.aws.toolkits.resources.message
2831

@@ -88,6 +91,32 @@ class CawsEnvironmentClient(
8891
return objectMapper.readValue(response.entity.content)
8992
}
9093

94+
fun getActivity(): GetActivityResponse? = try {
95+
val request = HttpGet("$endpoint/activity")
96+
val response = execute(request)
97+
if (response.statusLine.statusCode == 400) {
98+
LOG.error { "Inactivity tracking may not enabled" }
99+
null
100+
} else {
101+
objectMapper.readValue<GetActivityResponse>(response.entity.content)
102+
}
103+
} catch (e: Exception) {
104+
LOG.error(e) { "Couldn't parse response from /activity API" }
105+
null
106+
}
107+
108+
fun putActivityTimestamp(request: UpdateActivityRequest) {
109+
try {
110+
val body = objectMapper.writeValueAsString(request)
111+
val httpRequest = HttpPut("$endpoint/activity").also {
112+
it.entity = StringEntity(body, ContentType.APPLICATION_JSON)
113+
}
114+
val response = execute(httpRequest).use {}
115+
} catch (e: Exception) {
116+
LOG.error(e) { "Couldn't execute /activity API" }
117+
}
118+
}
119+
91120
private fun execute(request: HttpUriRequest): CloseableHttpResponse {
92121
request.addHeader("Authorization", authToken)
93122
return httpClient.execute(request)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.services.caws.envclient.models
5+
6+
data class GetActivityResponse(
7+
val timestamp: String
8+
)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.services.caws.envclient.models
5+
6+
data class UpdateActivityRequest(
7+
val timestamp: String? = null
8+
)

jetbrains-core/tst/software/aws/toolkits/jetbrains/services/caws/envclient/CawsEnvironmentClientTest.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,23 @@ class CawsEnvironmentClientTest {
6969

7070
assertThat(sut.getStatus().status).isEqualTo(GetStatusResponse.Status.IMAGES_UPDATE_AVAILABLE)
7171
}
72+
73+
@Test
74+
fun `getActivity returns timestamp`() {
75+
wireMockRule.stubFor(
76+
WireMock.any(WireMock.urlPathEqualTo("/activity"))
77+
.willReturn(
78+
WireMock.aResponse().withBody(
79+
// language=JSON
80+
"""
81+
{
82+
"timestamp": "112222444455555"
83+
}
84+
""".trimIndent()
85+
)
86+
)
87+
)
88+
89+
assertThat(sut.getActivity()?.timestamp).isEqualTo("112222444455555")
90+
}
7291
}

jetbrains-ultimate/resources/META-INF/ext-codewithme.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
<postStartupActivity implementation="software.aws.toolkits.jetbrains.services.caws.DevfileWatcher"/>
1212
<applicationService serviceImplementation="software.aws.toolkits.jetbrains.services.caws.DevfileWatcher"/>
13+
<postStartupActivity implementation="software.aws.toolkits.jetbrains.services.caws.DevEnvStatusWatcher"/>
14+
1315
<dynamicActionConfigurationCustomizer implementation="software.aws.toolkits.jetbrains.services.caws.RebuildActionConfigurationCustomizer"/>
1416
<gateway.customization.tab implementation="software.aws.toolkits.jetbrains.services.caws.UpdateWorkspaceSettingsTab"/>
1517
</extensions>
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.services.caws
5+
6+
import com.intellij.openapi.project.Project
7+
import com.intellij.openapi.startup.StartupActivity
8+
import com.intellij.openapi.ui.MessageDialogBuilder
9+
import com.jetbrains.rdserver.unattendedHost.UnattendedStatusUtil
10+
import kotlinx.coroutines.delay
11+
import kotlinx.coroutines.launch
12+
import kotlinx.coroutines.runBlocking
13+
import kotlinx.coroutines.withContext
14+
import software.amazon.awssdk.services.codecatalyst.CodeCatalystClient
15+
import software.aws.toolkits.core.utils.error
16+
import software.aws.toolkits.core.utils.getLogger
17+
import software.aws.toolkits.core.utils.info
18+
import software.aws.toolkits.jetbrains.core.awsClient
19+
import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineBgContext
20+
import software.aws.toolkits.jetbrains.core.coroutines.getCoroutineUiContext
21+
import software.aws.toolkits.jetbrains.core.coroutines.projectCoroutineScope
22+
import software.aws.toolkits.jetbrains.core.credentials.sono.SonoCredentialManager
23+
import software.aws.toolkits.jetbrains.services.caws.envclient.CawsEnvironmentClient
24+
import software.aws.toolkits.jetbrains.services.caws.envclient.models.UpdateActivityRequest
25+
import software.aws.toolkits.jetbrains.utils.notifyError
26+
import software.aws.toolkits.resources.message
27+
import java.time.Instant
28+
import java.time.temporal.ChronoUnit
29+
30+
class DevEnvStatusWatcher : StartupActivity {
31+
32+
companion object {
33+
private val LOG = getLogger<DevEnvStatusWatcher>()
34+
}
35+
36+
override fun runActivity(project: Project) {
37+
if (System.getenv(CawsConstants.CAWS_ENV_ID_VAR) == null) {
38+
return
39+
}
40+
val connection = SonoCredentialManager.getInstance(project).getConnectionSettings()
41+
?: error("Failed to fetch connection settings from Dev Environment")
42+
val envId = System.getenv(CawsConstants.CAWS_ENV_ID_VAR) ?: error("envId env var null")
43+
val org = System.getenv(CawsConstants.CAWS_ENV_ORG_NAME_VAR) ?: error("space env var null")
44+
val projectName = System.getenv(CawsConstants.CAWS_ENV_PROJECT_NAME_VAR) ?: error("project env var null")
45+
val client = connection.awsClient<CodeCatalystClient>()
46+
val coroutineScope = projectCoroutineScope(project)
47+
coroutineScope.launch(getCoroutineBgContext()) {
48+
val initialEnv = client.getDevEnvironment {
49+
it.id(envId)
50+
it.spaceName(org)
51+
it.projectName(projectName)
52+
}
53+
val inactivityTimeout = initialEnv.inactivityTimeoutMinutes()
54+
if (inactivityTimeout == 0) {
55+
LOG.info { "Dev environment inactivity timeout is 0, not monitoring" }
56+
return@launch
57+
}
58+
val inactivityTimeoutInSeconds = inactivityTimeout * 60
59+
60+
// ensure the JetBrains inactivity tracker and the activity api are in sync
61+
val jbActivityStatusJson = UnattendedStatusUtil.getStatus()
62+
val jbActivityStatus = jbActivityStatusJson.projects?.first()?.secondsSinceLastControllerActivity ?: 0
63+
notifyBackendOfActivity((getActivityTime(jbActivityStatus).toString()))
64+
var secondsSinceLastControllerActivity = jbActivityStatus
65+
66+
while (true) {
67+
val response = checkHeartbeat(secondsSinceLastControllerActivity, inactivityTimeoutInSeconds, project)
68+
if (response.first) return@launch
69+
delay(30000)
70+
secondsSinceLastControllerActivity = response.second
71+
}
72+
}
73+
}
74+
75+
// This function returns a Pair The first value is a boolean indicating if the API returned the last recorded activity.
76+
// If inactivity tracking is disabled or if the value returned by the API is unparseable, the heartbeat is not sent
77+
// The second value indicates the seconds since last activity as recorded by JB in the most recent run
78+
fun checkHeartbeat(
79+
secondsSinceLastControllerActivity: Long,
80+
inactivityTimeoutInSeconds: Int,
81+
project: Project
82+
): Pair<Boolean, Long> {
83+
val lastActivityTime = getJbRecordedActivity()
84+
85+
if (lastActivityTime < secondsSinceLastControllerActivity) {
86+
// update the API in case of any activity
87+
notifyBackendOfActivity((getActivityTime(lastActivityTime).toString()))
88+
}
89+
90+
val lastRecordedActivityTime = getLastRecordedApiActivity()
91+
if (lastRecordedActivityTime == null) {
92+
LOG.error { "Couldn't retrieve last recorded activity from API" }
93+
return Pair(true, lastActivityTime)
94+
}
95+
val durationRecordedSinceLastActivity = Instant.now().toEpochMilli().minus(lastRecordedActivityTime.toLong())
96+
val secondsRecordedSinceLastActivity = durationRecordedSinceLastActivity / 1000
97+
98+
if (secondsRecordedSinceLastActivity >= (inactivityTimeoutInSeconds - 300)) {
99+
try {
100+
val inactivityDurationInMinutes = secondsRecordedSinceLastActivity / 60
101+
val ans = runBlocking {
102+
val continueWorking = withContext(getCoroutineUiContext()) {
103+
return@withContext MessageDialogBuilder.okCancel(
104+
message("caws.devenv.continue.working.after.timeout.title"),
105+
message("caws.devenv.continue.working.after.timeout", inactivityDurationInMinutes)
106+
).ask(project)
107+
}
108+
return@runBlocking continueWorking
109+
}
110+
111+
if (ans) {
112+
notifyBackendOfActivity(getActivityTime().toString())
113+
}
114+
} catch (e: Exception) {
115+
val preMessage = "Error while checking if Dev Environment should continue working"
116+
LOG.error(e) { preMessage }
117+
notifyError(preMessage, e.message.toString())
118+
}
119+
}
120+
return Pair(false, lastActivityTime)
121+
}
122+
123+
fun getLastRecordedApiActivity(): String? = CawsEnvironmentClient.getInstance().getActivity()?.timestamp
124+
125+
fun getJbRecordedActivity(): Long {
126+
val statusJson = UnattendedStatusUtil.getStatus()
127+
val lastActivityTime = statusJson.projects?.first()?.secondsSinceLastControllerActivity ?: 0
128+
return lastActivityTime
129+
}
130+
131+
fun notifyBackendOfActivity(timestamp: String = Instant.now().toEpochMilli().toString()) {
132+
val request = UpdateActivityRequest(
133+
timestamp = timestamp
134+
)
135+
CawsEnvironmentClient.getInstance().putActivityTimestamp(request)
136+
}
137+
138+
private fun getActivityTime(secondsSinceLastActivity: Long = 0): Long = Instant.now().minus(secondsSinceLastActivity, ChronoUnit.SECONDS).toEpochMilli()
139+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.services
5+
6+
import com.intellij.openapi.ui.TestDialog
7+
import com.intellij.openapi.ui.TestDialogManager
8+
import com.intellij.testFramework.ProjectRule
9+
import org.assertj.core.api.Assertions.assertThat
10+
import org.junit.Rule
11+
import org.junit.Test
12+
import org.mockito.kotlin.any
13+
import org.mockito.kotlin.doReturn
14+
import org.mockito.kotlin.spy
15+
import org.mockito.kotlin.times
16+
import org.mockito.kotlin.verify
17+
import org.mockito.kotlin.whenever
18+
import software.aws.toolkits.jetbrains.services.caws.DevEnvStatusWatcher
19+
20+
class DevEnvStatusWatcherTest {
21+
@JvmField
22+
@Rule
23+
val projectRule = ProjectRule()
24+
25+
@Test
26+
fun `Heartbeat check stops if no response is returned by the API`() {
27+
val sut = DevEnvStatusWatcher()
28+
val devEnvStatusWatcher = spy<DevEnvStatusWatcher>(sut) {
29+
doReturn(600.toLong()).whenever(it).getJbRecordedActivity()
30+
doReturn(null).whenever(it).getLastRecordedApiActivity()
31+
}
32+
val response = devEnvStatusWatcher.checkHeartbeat(0, 0, projectRule.project)
33+
assertThat(response.first).isTrue()
34+
}
35+
36+
@Test
37+
fun `API is called if user extends the timeout 5 minutes before inactivity timeout`() {
38+
val sut = DevEnvStatusWatcher()
39+
val devEnvStatusWatcher = spy<DevEnvStatusWatcher>(sut) {
40+
doReturn(600.toLong()).whenever(it).getJbRecordedActivity()
41+
doReturn("1672531261000").whenever(it).getLastRecordedApiActivity()
42+
}
43+
TestDialogManager.setTestDialog(TestDialog.OK)
44+
devEnvStatusWatcher.checkHeartbeat(0, 900, projectRule.project)
45+
verify(devEnvStatusWatcher).notifyBackendOfActivity(any())
46+
}
47+
48+
@Test
49+
fun `API is not called if user doesn't extend the timeout 5 minutes before inactivity timeout`() {
50+
val sut = DevEnvStatusWatcher()
51+
val devEnvStatusWatcher = spy<DevEnvStatusWatcher>(sut) {
52+
doReturn(600.toLong()).whenever(it).getJbRecordedActivity()
53+
doReturn("1672531261000").whenever(it).getLastRecordedApiActivity()
54+
}
55+
TestDialogManager.setTestDialog(TestDialog.NO)
56+
devEnvStatusWatcher.checkHeartbeat(0, 900, projectRule.project)
57+
verify(devEnvStatusWatcher, times(0)).notifyBackendOfActivity(any())
58+
}
59+
}

resources/resources/software/aws/toolkits/resources/MessagesBundle.properties

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ caws.delete_failed=Delete Dev Environment Failed
204204
caws.delete_workspace=Delete
205205
caws.delete_workspace_warning=Are you sure you wish to delete the Dev Environment? All data will be deleted
206206
caws.delete_workspace_warning_title=Confirm Deletion
207+
caws.devenv.continue.working.after.timeout=Your dev environment has had no activity in the past {0} minutes and will be terminated within 5 minutes. Press OK to continue working
208+
caws.devenv.continue.working.after.timeout.title=Do you want to continue working?
207209
caws.devfile.schema=Devfile Schema
208210
caws.devtoolPanel.fetch.git.url=Fetching Git clone URL for {0}
209211
caws.devtoolPanel.git_url_copied=Clone URL copied to clipboard

0 commit comments

Comments
 (0)