Skip to content

Commit b1e511e

Browse files
codecatalyst: Show warning messages when CC dev env is close to inactivity shutdown (#3648)
* userActivity: removed instance method This was overkill and added unnecessary complexity Signed-off-by: Nikolas Komonen <[email protected]> * devEnv: activity api calls + DevEnvActivity class - Adds the user activity api calls to the dev env client - Adds a dev env user activity class that allows the codebase to easily work with dev env user activity Signed-off-by: Nikolas Komonen <[email protected]> * userActivity: handle edge cases w/ vscode activity events - Certain vscode events are emitted that we do no consider as user activity. This commit ignores those specific events from emitting user activity - Add more tests for these new cases Signed-off-by: Nikolas Komonen <[email protected]> * refactor: Move call of DevEnvClient to when we are in dev env We added the DevEnvClient to the subscriptions before we verified we were in a dev env. This delays that code until we know we are in a dev env. * codecatalyst: Show warning messages when CC dev env is close to inactivity shutdown - A cancellable message will be shown a few minutes before shutdown, and will update the remaining time each minute until the final minute before shutdown. - On the final minute a different message will be show that indicates the dev env will shut down in 1 minute. For each message that pops up if they interact with the IDE or click one of the buttons in the message it will refresh the shutdown timer to whatever was initially configured. Signed-off-by: Nikolas Komonen <[email protected]> --------- Signed-off-by: Nikolas Komonen <[email protected]>
1 parent b69c658 commit b1e511e

File tree

7 files changed

+909
-48
lines changed

7 files changed

+909
-48
lines changed

src/codecatalyst/activation.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,17 @@ import { CodeCatalystCommands } from './commands'
1212
import { GitExtension } from '../shared/extensions/git'
1313
import { CodeCatalystAuthenticationProvider } from './auth'
1414
import { registerDevfileWatcher } from './devfile'
15-
import { DevEnvClient } from '../shared/clients/devenvClient'
15+
import { DevEnvClient, DevEnvActivity } from '../shared/clients/devenvClient'
1616
import { watchRestartingDevEnvs } from './reconnect'
1717
import { PromptSettings } from '../shared/settings'
1818
import { dontShow } from '../shared/localizedText'
1919
import { getIdeProperties, isCloud9 } from '../shared/extensionUtilities'
2020
import { Commands } from '../shared/vscode/commands2'
2121
import { getCodeCatalystConfig } from '../shared/clients/codecatalystClient'
22-
import { getThisDevEnv } from './model'
2322
import { isDevenvVscode } from './utils'
23+
import { getThisDevEnv } from './model'
2424
import { getLogger } from '../shared/logger/logger'
25+
import { InactivityMessage, shouldTrackUserActivity } from './devEnv'
2526

2627
const localize = nls.loadMessageBundle()
2728

@@ -38,6 +39,10 @@ export async function activate(ctx: ExtContext): Promise<void> {
3839
...Object.values(CodeCatalystCommands.declared).map(c => c.register(commands))
3940
)
4041

42+
Commands.register('aws.codecatalyst.removeConnection', () => {
43+
authProvider.removeSavedConnection()
44+
})
45+
4146
if (!isCloud9()) {
4247
GitExtension.instance.registerRemoteSourceProvider(remoteSourceProvider).then(disposable => {
4348
ctx.extensionContext.subscriptions.push(disposable)
@@ -56,11 +61,6 @@ export async function activate(ctx: ExtContext): Promise<void> {
5661
watchRestartingDevEnvs(ctx, authProvider)
5762
}
5863

59-
ctx.extensionContext.subscriptions.push(DevEnvClient.instance)
60-
if (DevEnvClient.instance.id) {
61-
ctx.extensionContext.subscriptions.push(registerDevfileWatcher(DevEnvClient.instance))
62-
}
63-
6464
const thisDevenv = (await getThisDevEnv(authProvider))?.unwrapOrElse(err => {
6565
getLogger().warn('codecatalyst: failed to get current Dev Enviroment: %s', err)
6666
return undefined
@@ -69,6 +69,11 @@ export async function activate(ctx: ExtContext): Promise<void> {
6969
if (!thisDevenv) {
7070
getLogger().verbose('codecatalyst: not a devenv, getThisDevEnv() returned empty')
7171
} else {
72+
ctx.extensionContext.subscriptions.push(DevEnvClient.instance)
73+
if (DevEnvClient.instance.id) {
74+
ctx.extensionContext.subscriptions.push(registerDevfileWatcher(DevEnvClient.instance))
75+
}
76+
7277
getLogger().info('codecatalyst: Dev Environment ides=%O', thisDevenv?.summary.ides)
7378
if (!isCloud9() && thisDevenv && !isDevenvVscode(thisDevenv.summary.ides)) {
7479
// Prevent Toolkit from reconnecting to a "non-vscode" devenv by actively closing it.
@@ -95,11 +100,17 @@ export async function activate(ctx: ExtContext): Promise<void> {
95100
}
96101
})
97102
}
98-
}
99103

100-
Commands.register('aws.codecatalyst.removeConnection', () => {
101-
authProvider.removeSavedConnection()
102-
})
104+
const maxInactivityMinutes = thisDevenv.summary.inactivityTimeoutMinutes
105+
const devEnvClient = thisDevenv.devenvClient
106+
const devEnvActivity = await DevEnvActivity.instanceIfActivityTrackingEnabled(devEnvClient)
107+
if (shouldTrackUserActivity(maxInactivityMinutes) && devEnvActivity) {
108+
const inactivityMessage = new InactivityMessage()
109+
inactivityMessage.setupMessage(maxInactivityMinutes, devEnvActivity)
110+
111+
ctx.extensionContext.subscriptions.push(inactivityMessage, devEnvActivity)
112+
}
113+
}
103114
}
104115

105116
async function showReadmeFileOnFirstLoad(workspaceState: vscode.ExtensionContext['workspaceState']): Promise<void> {

src/codecatalyst/devEnv.ts

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
/*!
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { DevEnvironment } from '../shared/clients/codecatalystClient'
7+
import { DevEnvActivity } from '../shared/clients/devenvClient'
8+
import globals from '../shared/extensionGlobals'
9+
import * as vscode from 'vscode'
10+
import { Timeout } from '../shared/utilities/timeoutUtils'
11+
import { showMessageWithCancel } from '../shared/utilities/messages'
12+
import { isCloud9 } from '../shared/extensionUtilities'
13+
14+
/** If we should be sending the dev env activity timestamps to track user activity */
15+
export function shouldTrackUserActivity(maxInactivityMinutes: DevEnvironment['inactivityTimeoutMinutes']): boolean {
16+
// This value is a static value initialized when the dev env is first created.
17+
//
18+
// If it is updated, the dev env is restarted and this extension will restarted and grab the latest value.
19+
// Due to this, we do not need to keep track of this value since the
20+
//
21+
// For more info see: https://docs.aws.amazon.com/codecatalyst/latest/APIReference/API_UpdateDevEnvironment.html#codecatalyst-UpdateDevEnvironment-request-inactivityTimeoutMinutes
22+
return maxInactivityMinutes > 0
23+
}
24+
25+
export class InactivityMessage implements vscode.Disposable {
26+
#message: Message | undefined
27+
#nextWholeMinute: NodeJS.Timeout | undefined
28+
#beforeMessageShown: NodeJS.Timeout | undefined
29+
30+
/** Indicates to show first message 5 minutes before shutdown */
31+
static readonly firstMessageBeforeShutdown = 5
32+
33+
/**
34+
* Sets up all messages that are displayed to the user when the dev env is inactive and starts to get close to shutdown.
35+
*
36+
* @param maxInactivityMinutes The # of minutes a CodeCatalyst Dev Env can be inactive till it shuts down.
37+
* @param devEnvActivity The DevEnvActivity client
38+
* @param relativeMinuteMillis How many milliseconds we want a "minute" to be. This is
39+
* useful for testing purposes.
40+
*/
41+
async setupMessage(
42+
maxInactivityMinutes: DevEnvironment['inactivityTimeoutMinutes'],
43+
devEnvActivity: DevEnvActivity,
44+
relativeMinuteMillis: number = 60_000
45+
) {
46+
devEnvActivity.onActivityUpdate(async latestActivityTimestamp => {
47+
this._setupMessage(maxInactivityMinutes, latestActivityTimestamp, devEnvActivity, relativeMinuteMillis)
48+
})
49+
50+
// Send an initial update to the dev env on startup
51+
devEnvActivity.sendActivityUpdate()
52+
}
53+
54+
private _setupMessage(
55+
maxInactivityMinutes: number,
56+
latestTimestamp: number,
57+
devEnvActivity: DevEnvActivity,
58+
relativeMinuteMillis: number
59+
) {
60+
this.clearOldMessage()
61+
62+
const { millisToWait, minutesSinceTimestamp } = this.millisUntilNextWholeMinute(
63+
latestTimestamp,
64+
relativeMinuteMillis
65+
)
66+
const minutesUntilShutdown = maxInactivityMinutes - minutesSinceTimestamp
67+
const minutesUntilFirstMessage = minutesUntilShutdown - InactivityMessage.firstMessageBeforeShutdown
68+
69+
/** Wait until we are {@link InactivityMessage.firstMessageBeforeShutdown} minutes before shutdown. */
70+
this.#beforeMessageShown = globals.clock.setTimeout(() => {
71+
const userIsActive = () => devEnvActivity.sendActivityUpdate()
72+
const willRefreshOnStaleTimestamp = async () => await devEnvActivity.isLocalActivityStale()
73+
74+
this.message().show(
75+
minutesSinceTimestamp + minutesUntilFirstMessage,
76+
minutesUntilShutdown - minutesUntilFirstMessage,
77+
userIsActive,
78+
willRefreshOnStaleTimestamp,
79+
relativeMinuteMillis
80+
)
81+
}, millisToWait + minutesUntilFirstMessage * relativeMinuteMillis)
82+
}
83+
84+
private clearOldMessage() {
85+
if (this.#nextWholeMinute) {
86+
clearTimeout(this.#nextWholeMinute)
87+
this.#nextWholeMinute = undefined
88+
}
89+
if (this.#beforeMessageShown) {
90+
clearTimeout(this.#beforeMessageShown)
91+
this.#beforeMessageShown = undefined
92+
}
93+
if (this.#message) {
94+
this.#message.dispose()
95+
this.#message = undefined
96+
}
97+
}
98+
99+
/**
100+
* The latest activity timestamp may not always be the current time, it may be from the past.
101+
* So the amount of time that has passed since that timestamp may not be a whole minute.
102+
*
103+
* This returns the amount of time we need to wait until the next whole minute, along with how many
104+
* minutes would have passed assuming the caller waitied until the next minute.
105+
*
106+
* Eg:
107+
* - 1 minute and 29 seconds have passed since the given timestamp.
108+
* - returns { millisToWait: 31_000, minutesSinceTimestamp: 2}
109+
*/
110+
private millisUntilNextWholeMinute(
111+
latestTimestamp: number,
112+
relativeMinuteMillis: number
113+
): { millisToWait: number; minutesSinceTimestamp: number } {
114+
const millisSinceLastTimestamp = new Date().getTime() - latestTimestamp
115+
const millisSinceLastWholeMinute = millisSinceLastTimestamp % relativeMinuteMillis
116+
117+
const millisToWait = millisSinceLastWholeMinute !== 0 ? relativeMinuteMillis - millisSinceLastWholeMinute : 0
118+
const minutesSinceTimestamp = (millisSinceLastTimestamp + millisToWait) / relativeMinuteMillis
119+
120+
return { millisToWait, minutesSinceTimestamp }
121+
}
122+
123+
private message() {
124+
this.clearOldMessage()
125+
return (this.#message = new Message())
126+
}
127+
128+
dispose() {
129+
this.clearOldMessage()
130+
}
131+
}
132+
133+
class Message implements vscode.Disposable {
134+
private currentWarningMessageTimeout: Timeout | undefined
135+
136+
/**
137+
* Show the warning message
138+
*
139+
* @param minutesUserWasInactive total minutes user was inactive.
140+
* @param minutesUntilShutdown remaining minutes until shutdown.
141+
* @param userIsActive function to call when we want to indicate the user is active
142+
* @param willRefreshOnStaleTimestamp sanity checks with the dev env api that the latest activity timestamp
143+
* is the same as what this client has locally. If stale, the warning message
144+
* will be refreshed asynchronously. Returns true if the message will be refreshed.
145+
* @param relativeMinuteMillis How many milliseconds we want a "minute" to be. This is
146+
* useful for testing purposes.
147+
*/
148+
async show(
149+
minutesUserWasInactive: number,
150+
minutesUntilShutdown: number,
151+
userIsActive: () => Promise<any> | any,
152+
willRefreshOnStaleTimestamp: () => Promise<boolean>,
153+
relativeMinuteMillis: number
154+
) {
155+
this.clearExistingMessage()
156+
// Show a new message every minute
157+
this.currentWarningMessageTimeout = new Timeout(1 * relativeMinuteMillis)
158+
159+
if (await willRefreshOnStaleTimestamp()) {
160+
return
161+
}
162+
163+
if (minutesUntilShutdown <= 1) {
164+
// Recursive base case, with only 1 minute left we do not want to show warning messages anymore,
165+
// since we will show a shutdown message instead.
166+
this.clearExistingMessage()
167+
168+
const imHere = `I'm here!`
169+
vscode.window
170+
.showWarningMessage(
171+
`Your CodeCatalyst Dev Environment has been inactive for ${minutesUserWasInactive} minutes, and will stop soon.`,
172+
imHere
173+
)
174+
.then(res => {
175+
if (res === imHere) {
176+
userIsActive()
177+
}
178+
})
179+
return
180+
}
181+
182+
this.currentWarningMessageTimeout.token.onCancellationRequested(c => {
183+
if (c.agent === 'user') {
184+
// User clicked the 'Cancel' button, indicate they are active.
185+
userIsActive()
186+
} else {
187+
// The message timed out, show the updated message.
188+
this.show(
189+
minutesUserWasInactive + 1,
190+
minutesUntilShutdown - 1,
191+
userIsActive,
192+
willRefreshOnStaleTimestamp,
193+
relativeMinuteMillis
194+
)
195+
}
196+
})
197+
198+
if (isCloud9()) {
199+
// C9 does not support message with progress, so just show a warning message.
200+
vscode.window
201+
.showWarningMessage(
202+
this.buildInactiveWarningMessage(minutesUserWasInactive, minutesUntilShutdown),
203+
'Cancel'
204+
)
205+
.then(() => {
206+
this.currentWarningMessageTimeout!.cancel()
207+
})
208+
} else {
209+
showMessageWithCancel(
210+
this.buildInactiveWarningMessage(minutesUserWasInactive, minutesUntilShutdown),
211+
this.currentWarningMessageTimeout
212+
)
213+
}
214+
}
215+
216+
clearExistingMessage() {
217+
if (this.currentWarningMessageTimeout) {
218+
this.currentWarningMessageTimeout.dispose()
219+
this.currentWarningMessageTimeout = undefined
220+
}
221+
}
222+
223+
dispose() {
224+
this.clearExistingMessage()
225+
}
226+
227+
private buildInactiveWarningMessage(inactiveMinutes: number, remainingMinutes: number) {
228+
return `Your CodeCatalyst Dev Environment has been inactive for ${inactiveMinutes} minutes, shutting it down in ${remainingMinutes} minutes.`
229+
}
230+
}

0 commit comments

Comments
 (0)