Skip to content

[dashboard, server] Add global custom timeout preference #16503

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Mar 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 61 additions & 1 deletion components/dashboard/src/user-settings/Preferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -29,6 +31,22 @@ export default function Preferences() {
}
};

const [workspaceTimeout, setWorkspaceTimeout] = useState<string>(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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a better way to communicate this rather than showing the alert box? It's rather un-friendly and has the potential of spamming the user if the error happens to trigger on a loop. @gtsiolis what's a good pattern to communicate an API error here?

Copy link
Contributor

@gtsiolis gtsiolis Mar 1, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@easyCZ Yes:

  1. We could use an in-page alert as we do for profile updates or paused prebuilds, see screenshots below.
  2. Alternatively, using a form validation error like we do with the onboarding could be nice. Cc @selfcontained because New User Onboarding Flow UI #16501.
  3. Long-term, I'd like to see this moving away from this custom value (2h, 20m, etc.) and rely on just a number, making this a number type of field. Also, decreasing the max to something more sensible and aligned with our mission to deliver ephemeral development environments. It's quite confusing and hard to understand what's going on here, whereas if we just used minutes as we do with prebuilds it could simplify things, including what copy we use for labels and input help text. Cc @loujaybee

However, I'd not block merging this as is for now, even if it's just a browser alert.

Thanks for the ping, @easyCZ! 🏓

Profile update Paused prebuilds
Screenshot 2023-02-28 at 18 02 07 Screenshot 2023-03-01 at 19 15 58

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This alert is suitable for display in a modal, where it feels like there is no suitable place for it

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also don't think we should really ever be using a native browser alert(), for reasons @easyCZ described, and it also communicates a very unpolished feel. Showing an inline <Alert> message in the UI next to the place the button or form lives seems like a decent approach. If we had toast notifications, that would be nice as well, but we don't atm.

}
}, []);

const [allowConfigureWorkspaceTimeout, setAllowConfigureWorkspaceTimeout] = useState<boolean>(false);
useEffect(() => {
getGitpodService()
.server.maySetTimeout()
.then((r) => setAllowConfigureWorkspaceTimeout(r));
}, []);

return (
<div>
<PageWithSettingsSubMenu>
Expand Down Expand Up @@ -72,6 +90,48 @@ export default function Preferences() {
</p>
</div>
</div>

<h3 className="mt-12">Inactivity Timeout </h3>
<p className="text-base text-gray-500 dark:text-gray-400">
Workspaces will stop after a period of inactivity without any user input.
</p>
<div className="mt-4 max-w-xl">
<h4>Default Workspace Timeout</h4>

{!allowConfigureWorkspaceTimeout && (
<Alert type="message">
Upgrade organization <Link to="/billing">billing</Link> plan to use a custom inactivity
timeout.
</Alert>
)}

{allowConfigureWorkspaceTimeout && (
<>
<span className="flex mt-2">
<input
type="text"
className="w-96 h-9"
value={workspaceTimeout}
disabled={!allowConfigureWorkspaceTimeout}
placeholder="e.g. 30m"
onChange={(e) => setWorkspaceTimeout(e.target.value)}
/>
<button
className="secondary ml-2"
disabled={!allowConfigureWorkspaceTimeout}
onClick={() => actuallySetWorkspaceTimeout(workspaceTimeout)}
>
Save Changes
</button>
</span>
<div className="mt-1">
<p className="text-gray-500 dark:text-gray-400">
Use minutes or hours, like <strong>30m</strong> or <strong>2h</strong>.
</p>
</div>
</>
)}
</div>
</PageWithSettingsSubMenu>
</div>
);
Expand Down
3 changes: 3 additions & 0 deletions components/gitpod-protocol/src/gitpod-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
SSHPublicKeyValue,
IDESettings,
EnvVarWithValue,
WorkspaceTimeoutSetting,
} from "./protocol";
import {
Team,
Expand Down Expand Up @@ -312,6 +313,8 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
getNotifications(): Promise<AppNotification[]>;

getSupportedWorkspaceClasses(): Promise<SupportedWorkspaceClass[]>;
maySetTimeout(): Promise<boolean>;
updateWorkspaceTimeoutSetting(setting: Partial<WorkspaceTimeoutSetting>): Promise<void>;
}

export interface AppNotification {
Expand Down
9 changes: 8 additions & 1 deletion components/gitpod-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,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<WorkspaceTimeoutSetting> {
platforms?: UserPlatform[];
emailNotificationSettings?: EmailNotificationSettings;
featurePreview?: boolean;
Expand Down
11 changes: 2 additions & 9 deletions components/server/ee/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<boolean> {
return this.entitlementService.maySetTimeout(user, new Date());
}

public async controlAdmission(ctx: TraceContext, workspaceId: string, level: "owner" | "everyone"): Promise<void> {
traceAPIParams(ctx, { workspaceId, level });
traceWI(ctx, { workspaceId });
Expand Down
2 changes: 2 additions & 0 deletions components/server/src/auth/rate-limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,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 {
Expand Down
34 changes: 33 additions & 1 deletion components/server/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -494,6 +499,33 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
return user;
}

public async maySetTimeout(ctx: TraceContext): Promise<boolean> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧡 Thanks @iQQBot !

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<WorkspaceTimeoutSetting>,
): Promise<void> {
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<void> {
this.checkUser("sendPhoneNumberVerificationToken");
return this.verificationService.sendVerificationToken(formatPhoneNumber(rawPhoneNumber));
Expand Down
16 changes: 15 additions & 1 deletion components/server/src/workspace/workspace-starter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 || [];

Expand Down Expand Up @@ -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) {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really want to swallow any exception here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think yes, We have already verified once when the user submits the settings. and this only for some edge case e.g. we change rule, at this point in time we output this error and it doesn't change anything, i.e. not system error, we cannot improve it, it's just an annoying thing

Copy link
Member

@geropl geropl Mar 2, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd 🧡 to see this kind of reasoning backed into comments. 🙂

}
if (user.additionalData?.disabledClosedTimeout === true) {
spec.setClosedTimeout("0");
}
}
}
spec.setAdmission(admissionLevel);
const sshKeys = await this.userDB.trace(traceCtx).getSSHPublicKeys(user.id);
Expand Down