diff --git a/components/dashboard/src/user-settings/Preferences.tsx b/components/dashboard/src/user-settings/Preferences.tsx index 3d208466d2979a..081a49b4072141 100644 --- a/components/dashboard/src/user-settings/Preferences.tsx +++ b/components/dashboard/src/user-settings/Preferences.tsx @@ -4,13 +4,15 @@ * See License.AGPL.txt in the project root for license information. */ -import { useContext, useState } from "react"; +import { useCallback, useContext, useEffect, useState } from "react"; import { getGitpodService } from "../service/service"; import { UserContext } from "../user-context"; import { trackEvent } from "../Analytics"; import SelectIDE from "./SelectIDE"; import { PageWithSettingsSubMenu } from "./PageWithSettingsSubMenu"; import { ThemeSelector } from "../components/ThemeSelector"; +import Alert from "../components/Alert"; +import { Link } from "react-router-dom"; export default function Preferences() { const { user } = useContext(UserContext); @@ -29,6 +31,22 @@ export default function Preferences() { } }; + const [workspaceTimeout, setWorkspaceTimeout] = useState(user?.additionalData?.workspaceTimeout ?? ""); + const actuallySetWorkspaceTimeout = useCallback(async (value: string) => { + try { + await getGitpodService().server.updateWorkspaceTimeoutSetting({ workspaceTimeout: value }); + } catch (e) { + alert("Cannot set custom workspace timeout: " + e.message); + } + }, []); + + const [allowConfigureWorkspaceTimeout, setAllowConfigureWorkspaceTimeout] = useState(false); + useEffect(() => { + getGitpodService() + .server.maySetTimeout() + .then((r) => setAllowConfigureWorkspaceTimeout(r)); + }, []); + return (
@@ -72,6 +90,48 @@ export default function Preferences() {

+ +

Inactivity Timeout

+

+ Workspaces will stop after a period of inactivity without any user input. +

+
+

Default Workspace Timeout

+ + {!allowConfigureWorkspaceTimeout && ( + + Upgrade organization billing plan to use a custom inactivity + timeout. + + )} + + {allowConfigureWorkspaceTimeout && ( + <> + + setWorkspaceTimeout(e.target.value)} + /> + + +
+

+ Use minutes or hours, like 30m or 2h. +

+
+ + )} +
); diff --git a/components/gitpod-protocol/src/gitpod-service.ts b/components/gitpod-protocol/src/gitpod-service.ts index dab6c8c3cb59f8..7997c5037a3d49 100644 --- a/components/gitpod-protocol/src/gitpod-service.ts +++ b/components/gitpod-protocol/src/gitpod-service.ts @@ -28,6 +28,7 @@ import { SSHPublicKeyValue, IDESettings, EnvVarWithValue, + WorkspaceTimeoutSetting, } from "./protocol"; import { Team, @@ -314,6 +315,8 @@ export interface GitpodServer extends JsonRpcServer, AdminServer, getNotifications(): Promise; getSupportedWorkspaceClasses(): Promise; + maySetTimeout(): Promise; + updateWorkspaceTimeoutSetting(setting: Partial): Promise; } export interface AppNotification { diff --git a/components/gitpod-protocol/src/protocol.ts b/components/gitpod-protocol/src/protocol.ts index e1f6431f0ca441..2db57e3449d468 100644 --- a/components/gitpod-protocol/src/protocol.ts +++ b/components/gitpod-protocol/src/protocol.ts @@ -236,7 +236,14 @@ export namespace User { } } -export interface AdditionalUserData { +export interface WorkspaceTimeoutSetting { + // user globol workspace timeout + workspaceTimeout: string; + // control whether to enable the closed timeout of a workspace, i.e. close web ide, disconnect ssh connection + disabledClosedTimeout: boolean; +} + +export interface AdditionalUserData extends Partial { platforms?: UserPlatform[]; emailNotificationSettings?: EmailNotificationSettings; featurePreview?: boolean; diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index c10676f692c599..0390af2bef379b 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -411,7 +411,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl { await this.requireEELicense(Feature.FeatureSetTimeout); const user = this.checkUser("setWorkspaceTimeout"); - if (!(await this.maySetTimeout(user))) { + if (!(await this.entitlementService.maySetTimeout(user, new Date()))) { throw new ResponseError(ErrorCodes.PLAN_PROFESSIONAL_REQUIRED, "Plan upgrade is required"); } @@ -455,7 +455,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl { const user = this.checkUser("getWorkspaceTimeout"); - const canChange = await this.maySetTimeout(user); + const canChange = await this.entitlementService.maySetTimeout(user, new Date()); const workspace = await this.internalGetWorkspace(workspaceId, this.workspaceDb.trace(ctx)); const runningInstance = await this.workspaceDb.trace(ctx).findRunningInstance(workspaceId); @@ -494,13 +494,6 @@ export class GitpodServerEEImpl extends GitpodServerImpl { return PrebuiltWorkspace.isDone(pws); } - /** - * gitpod.io Extension point for implementing eligibility checks. Throws a ResponseError if not eligible. - */ - protected async maySetTimeout(user: User): Promise { - return this.entitlementService.maySetTimeout(user, new Date()); - } - public async controlAdmission(ctx: TraceContext, workspaceId: string, level: "owner" | "everyone"): Promise { traceAPIParams(ctx, { workspaceId, level }); traceWI(ctx, { workspaceId }); diff --git a/components/server/src/auth/rate-limiter.ts b/components/server/src/auth/rate-limiter.ts index 358b143e8f5e70..a395d13c5a7b9b 100644 --- a/components/server/src/auth/rate-limiter.ts +++ b/components/server/src/auth/rate-limiter.ts @@ -234,6 +234,8 @@ const defaultFunctions: FunctionsConfig = { setUsageLimit: { group: "default", points: 1 }, getNotifications: { group: "default", points: 1 }, getSupportedWorkspaceClasses: { group: "default", points: 1 }, + maySetTimeout: { group: "default", points: 1 }, + updateWorkspaceTimeoutSetting: { group: "default", points: 1 }, }; function getConfig(config: RateLimiterConfig): RateLimiterConfig { diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 4ad37bb1ebf4a6..4fbb91e3964391 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -163,7 +163,12 @@ import { IDEOptions } from "@gitpod/gitpod-protocol/lib/ide-protocol"; import { PartialProject } from "@gitpod/gitpod-protocol/lib/teams-projects-protocol"; import { ClientMetadata } from "../websocket/websocket-connection-manager"; import { ConfigurationService } from "../config/configuration-service"; -import { EnvVarWithValue, ProjectEnvVar } from "@gitpod/gitpod-protocol/lib/protocol"; +import { + AdditionalUserData, + EnvVarWithValue, + ProjectEnvVar, + WorkspaceTimeoutSetting, +} from "@gitpod/gitpod-protocol/lib/protocol"; import { InstallationAdminSettings, TelemetryData } from "@gitpod/gitpod-protocol"; import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred"; import { InstallationAdminTelemetryDataProvider } from "../installation-admin/telemetry-data-provider"; @@ -494,6 +499,33 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { return user; } + public async maySetTimeout(ctx: TraceContext): Promise { + const user = this.checkUser("maySetTimeout"); + await this.guardAccess({ kind: "user", subject: user }, "get"); + + return await this.entitlementService.maySetTimeout(user, new Date()); + } + + public async updateWorkspaceTimeoutSetting( + ctx: TraceContext, + setting: Partial, + ): Promise { + traceAPIParams(ctx, { setting }); + if (setting.workspaceTimeout) { + WorkspaceTimeoutDuration.validate(setting.workspaceTimeout); + } + + const user = this.checkAndBlockUser("updateWorkspaceTimeoutSetting"); + await this.guardAccess({ kind: "user", subject: user }, "update"); + + if (!(await this.entitlementService.maySetTimeout(user, new Date()))) { + throw new Error("configure workspace timeout only available for paid user."); + } + + AdditionalUserData.set(user, setting); + await this.userDB.updateUserPartial(user); + } + public async sendPhoneNumberVerificationToken(ctx: TraceContext, rawPhoneNumber: string): Promise { this.checkUser("sendPhoneNumberVerificationToken"); return this.verificationService.sendVerificationToken(formatPhoneNumber(rawPhoneNumber)); diff --git a/components/server/src/workspace/workspace-starter.ts b/components/server/src/workspace/workspace-starter.ts index e993746e78b42b..aa4d7c1ad5f5f4 100644 --- a/components/server/src/workspace/workspace-starter.ts +++ b/components/server/src/workspace/workspace-starter.ts @@ -57,6 +57,7 @@ import { Project, GitpodServer, IDESettings, + WorkspaceTimeoutDuration, } from "@gitpod/gitpod-protocol"; import { IAnalyticsWriter } from "@gitpod/gitpod-protocol/lib/analytics"; import { log } from "@gitpod/gitpod-protocol/lib/util/logging"; @@ -1437,6 +1438,7 @@ export class WorkspaceStarter { lastValidWorkspaceInstanceId, ); const userTimeoutPromise = this.entitlementService.getDefaultWorkspaceTimeout(user, new Date()); + const allowSetTimeoutPromise = this.entitlementService.maySetTimeout(user, new Date()); let featureFlags = instance.configuration!.featureFlags || []; @@ -1467,7 +1469,19 @@ export class WorkspaceStarter { spec.setClass(instance.workspaceClass!); if (workspace.type === "regular") { - spec.setTimeout(await userTimeoutPromise); + const [defaultTimeout, allowSetTimeout] = await Promise.all([userTimeoutPromise, allowSetTimeoutPromise]); + spec.setTimeout(defaultTimeout); + if (allowSetTimeout) { + if (user.additionalData?.workspaceTimeout) { + try { + let timeout = WorkspaceTimeoutDuration.validate(user.additionalData?.workspaceTimeout); + spec.setTimeout(timeout); + } catch (err) {} + } + if (user.additionalData?.disabledClosedTimeout === true) { + spec.setClosedTimeout("0"); + } + } } spec.setAdmission(admissionLevel); const sshKeys = await this.userDB.trace(traceCtx).getSSHPublicKeys(user.id);