Skip to content

Commit 55b486e

Browse files
Introduce max_parallel_running_workspaces for orgs (#20448)
* Introduce `max_parallel_running_workspaces` for orgs * Add migration * Add UI and org settings server changes * Rough edges * Number inputs (:trollface:) * Move LazyOrganizationService to UBP entitlement service to get rid of circ. dep. errs * Fix tests * Update components/gitpod-db/src/typeorm/entity/db-team-settings.ts Co-authored-by: Gero Posmyk-Leinemann <[email protected]> * Rename `isPaidPlan` to `isPaidOrDedicated` and get rid of `update_max_parallel_running_workspaces` * Update components/server/src/workspace/workspace-service.ts Co-authored-by: Gero Posmyk-Leinemann <[email protected]> * Deduplicate instance counting logic --------- Co-authored-by: Gero Posmyk-Leinemann <[email protected]>
1 parent 74efee5 commit 55b486e

27 files changed

+1180
-630
lines changed

components/dashboard/src/components/OrgMemberPermissionsOptions.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export const OrgMemberPermissionRestrictionsOptions = ({
4747
return (
4848
<div className={cn("space-y-2", className)}>
4949
{rolesAllowedToOpenArbitraryRepositories.map((entry) => (
50-
<div className="flex gap-2 items-center">
50+
<div className="flex gap-2 items-center" key={entry}>
5151
<UserIcon size={20} />
5252
<div>
5353
<span className="font-medium text-pk-content-primary capitalize">{entry}</span>
@@ -116,6 +116,7 @@ export const OrganizationRoleRestrictionModal = ({
116116
<OrganizationRoleRestrictionSwitch
117117
role={role}
118118
checked={!restrictedRoles.includes(role)}
119+
key={role}
119120
onCheckedChange={(checked) => {
120121
console.log(role, { checked });
121122
if (!checked) {

components/dashboard/src/components/WorkspaceClassesOptions.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const WorkspaceClassesOptions = (props: WorkspaceClassesOptionsProps) =>
3535
return (
3636
<div className={cn("space-y-2", props.className)}>
3737
{props.classes.map((cls) => (
38-
<div className="flex gap-2 items-center">
38+
<div className="flex gap-2 items-center" key={cls.id}>
3939
<CpuIcon size={20} />
4040
<div>
4141
<span className="font-medium text-pk-content-primary">{cls.displayName}</span>

components/dashboard/src/components/forms/TextInputField.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ type Props = TextInputProps & {
1818
topMargin?: boolean;
1919
containerClassName?: string;
2020
};
21-
2221
export const TextInputField: FunctionComponent<Props> = memo(
2322
({ label, id, hint, error, topMargin, containerClassName, ...props }) => {
2423
const maybeId = useId();
@@ -44,7 +43,6 @@ interface TextInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement
4443
onChange?: (newValue: string) => void;
4544
onBlur?: () => void;
4645
}
47-
4846
export const TextInput: FunctionComponent<TextInputProps> = memo(({ className, onChange, onBlur, ...props }) => {
4947
const handleChange = useCallback(
5048
(e) => {
@@ -72,3 +70,36 @@ export const TextInput: FunctionComponent<TextInputProps> = memo(({ className, o
7270
/>
7371
);
7472
});
73+
74+
type NumberInputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange" | "type"> & {
75+
onChange?: (newValue: number) => void;
76+
onBlur?: () => void;
77+
};
78+
export const NumberInput: FunctionComponent<NumberInputProps> = memo(({ className, onChange, onBlur, ...props }) => {
79+
const handleChange = useCallback(
80+
(e) => {
81+
onChange && onChange(e.target.valueAsNumber);
82+
},
83+
[onChange],
84+
);
85+
86+
const handleBlur = useCallback(() => onBlur && onBlur(), [onBlur]);
87+
88+
return (
89+
<input
90+
// 7px top/bottom padding ensures height matches buttons (36px)
91+
className={cn(
92+
"py-[7px] w-full max-w-lg rounded-lg",
93+
"text-pk-content-primary",
94+
"bg-pk-surface-primary",
95+
"border-pk-border-base",
96+
"text-sm",
97+
className,
98+
)}
99+
onChange={handleChange}
100+
onBlur={handleBlur}
101+
type="number"
102+
{...props}
103+
/>
104+
);
105+
});

components/dashboard/src/data/organizations/update-org-settings-mutation.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type UpdateOrganizationSettingsArgs = Partial<
2424
| "defaultRole"
2525
| "timeoutSettings"
2626
| "roleRestrictions"
27+
| "maxParallelRunningWorkspaces"
2728
>
2829
>;
2930

@@ -43,10 +44,11 @@ export const useUpdateOrgSettingsMutation = () => {
4344
defaultRole,
4445
timeoutSettings,
4546
roleRestrictions,
47+
maxParallelRunningWorkspaces,
4648
}) => {
4749
const settings = await organizationClient.updateOrganizationSettings({
4850
organizationId: teamId,
49-
workspaceSharingDisabled: workspaceSharingDisabled || false,
51+
workspaceSharingDisabled: workspaceSharingDisabled ?? false,
5052
defaultWorkspaceImage,
5153
allowedWorkspaceClasses,
5254
updatePinnedEditorVersions: !!pinnedEditorVersions,
@@ -57,6 +59,7 @@ export const useUpdateOrgSettingsMutation = () => {
5759
timeoutSettings,
5860
roleRestrictions,
5961
updateRoleRestrictions: !!roleRestrictions,
62+
maxParallelRunningWorkspaces,
6063
});
6164
return settings.settings!;
6265
},

components/dashboard/src/start/StartWorkspace.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,9 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
366366
// scenarios with distributed workspace bridges (control loops): We might receive the update, but the backend might not have the token, yet.
367367
// So we have to ask again, and wait until we're actually successful (it returns immediately on the happy path)
368368
await this.ensureWorkspaceAuth(workspace.status!.instanceId, true);
369+
if (this.state.error && this.state.error?.code !== ErrorCodes.NOT_FOUND) {
370+
return;
371+
}
369372
this.redirectTo(workspace.status!.workspaceUrl);
370373
})().catch(console.error);
371374
return;

components/dashboard/src/teams/TeamPolicies.tsx

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
OrgMemberPermissionRestrictionsOptions,
4545
} from "../components/OrgMemberPermissionsOptions";
4646
import { LightbulbIcon } from "lucide-react";
47+
import { MaxParallelWorkspaces } from "./policies/MaxParallelWorkspaces";
4748

4849
export default function TeamPoliciesPage() {
4950
useDocumentTitle("Organization Settings - Policies");
@@ -121,7 +122,7 @@ export default function TeamPoliciesPage() {
121122
[workspaceTimeout, allowTimeoutChangeByMembers, handleUpdateTeamSettings],
122123
);
123124

124-
const billingModeAllowsWorkspaceTimeouts =
125+
const isPaidOrDedicated =
125126
billingMode.data?.mode === "none" || (billingMode.data?.mode === "usage-based" && billingMode.data?.paid);
126127

127128
return (
@@ -156,7 +157,7 @@ export default function TeamPoliciesPage() {
156157

157158
<ConfigurationSettingsField>
158159
<Heading3>Workspace timeouts</Heading3>
159-
{!billingModeAllowsWorkspaceTimeouts && (
160+
{!isPaidOrDedicated && (
160161
<Alert type="info" className="my-3">
161162
Setting Workspace timeouts is only available for organizations on a paid plan. Visit{" "}
162163
<Link to={"/billing"} className="gp-link">
@@ -180,9 +181,7 @@ export default function TeamPoliciesPage() {
180181
value={workspaceTimeout ?? ""}
181182
placeholder="e.g. 30m"
182183
onChange={setWorkspaceTimeout}
183-
disabled={
184-
updateTeamSettings.isLoading || !isOwner || !billingModeAllowsWorkspaceTimeouts
185-
}
184+
disabled={updateTeamSettings.isLoading || !isOwner || !isPaidOrDedicated}
186185
/>
187186
</InputField>
188187
<CheckboxInputField
@@ -191,19 +190,16 @@ export default function TeamPoliciesPage() {
191190
checked={!!allowTimeoutChangeByMembers}
192191
containerClassName="my-4"
193192
onChange={setAllowTimeoutChangeByMembers}
194-
disabled={
195-
updateTeamSettings.isLoading || !isOwner || !billingModeAllowsWorkspaceTimeouts
196-
}
193+
disabled={updateTeamSettings.isLoading || !isOwner || !isPaidOrDedicated}
197194
/>
198195
<LoadingButton
199196
type="submit"
200197
loading={updateTeamSettings.isLoading}
201198
disabled={
202199
!isOwner ||
203-
!billingModeAllowsWorkspaceTimeouts ||
204-
((workspaceTimeout ===
205-
converter.toDurationString(settings?.timeoutSettings?.inactivity) ??
206-
"") &&
200+
!isPaidOrDedicated ||
201+
(workspaceTimeout ===
202+
converter.toDurationString(settings?.timeoutSettings?.inactivity) &&
207203
allowTimeoutChangeByMembers === !settings?.timeoutSettings?.denyUserTimeouts)
208204
}
209205
>
@@ -212,6 +208,14 @@ export default function TeamPoliciesPage() {
212208
</form>
213209
</ConfigurationSettingsField>
214210

211+
<MaxParallelWorkspaces
212+
isOwner={isOwner}
213+
isLoading={updateTeamSettings.isLoading}
214+
settings={settings}
215+
handleUpdateTeamSettings={handleUpdateTeamSettings}
216+
isPaidOrDedicated={isPaidOrDedicated}
217+
/>
218+
215219
<OrgWorkspaceClassesOptions
216220
isOwner={isOwner}
217221
settings={settings}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { OrganizationSettings } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";
8+
import { FormEvent, useEffect, useState } from "react";
9+
import { ConfigurationSettingsField } from "../../repositories/detail/ConfigurationSettingsField";
10+
import { Heading3, Subheading } from "@podkit/typography/Headings";
11+
import { InputField } from "../../components/forms/InputField";
12+
import { NumberInput } from "../../components/forms/TextInputField";
13+
import { LoadingButton } from "@podkit/buttons/LoadingButton";
14+
import { MAX_PARALLEL_WORKSPACES_FREE, MAX_PARALLEL_WORKSPACES_PAID } from "@gitpod/gitpod-protocol";
15+
import { PlainMessage } from "@bufbuild/protobuf";
16+
import { useInstallationConfiguration } from "../../data/installation/default-workspace-image-query";
17+
18+
type Props = {
19+
isOwner: boolean;
20+
isLoading: boolean;
21+
isPaidOrDedicated: boolean;
22+
settings?: OrganizationSettings;
23+
handleUpdateTeamSettings: (
24+
newSettings: Partial<PlainMessage<OrganizationSettings>>,
25+
options?: {
26+
throwMutateError?: boolean;
27+
},
28+
) => Promise<void>;
29+
};
30+
31+
export const MaxParallelWorkspaces = ({
32+
isOwner,
33+
isLoading,
34+
settings,
35+
isPaidOrDedicated,
36+
handleUpdateTeamSettings,
37+
}: Props) => {
38+
const [error, setError] = useState<string | undefined>(undefined);
39+
const [maxParallelWorkspaces, setMaxParallelWorkspaces] = useState<number>(
40+
settings?.maxParallelRunningWorkspaces ?? 0,
41+
);
42+
43+
const organizationDefault = isPaidOrDedicated ? MAX_PARALLEL_WORKSPACES_PAID : MAX_PARALLEL_WORKSPACES_FREE;
44+
const { data: installationConfig } = useInstallationConfiguration();
45+
const isDedicatedInstallation = !!installationConfig?.isDedicatedInstallation;
46+
47+
const handleSubmit = async (e: FormEvent) => {
48+
e.preventDefault();
49+
if (maxParallelWorkspaces < 0) {
50+
setError("The maximum parallel running workspaces must be a positive number.");
51+
return;
52+
}
53+
await handleUpdateTeamSettings({
54+
maxParallelRunningWorkspaces: maxParallelWorkspaces,
55+
});
56+
};
57+
58+
useEffect(() => {
59+
setMaxParallelWorkspaces(settings?.maxParallelRunningWorkspaces ?? 0);
60+
}, [settings?.maxParallelRunningWorkspaces]);
61+
62+
return (
63+
<ConfigurationSettingsField>
64+
<Heading3>Maximum parallel running workspaces</Heading3>
65+
<Subheading>
66+
By default, every user in your organization can have <strong>{organizationDefault}</strong> workspaces
67+
running at the same time. You can change this limit below or revert to this default by specifying{" "}
68+
<strong>0</strong> as the limit.
69+
</Subheading>
70+
<form onSubmit={handleSubmit}>
71+
<InputField label="Maximum parallel running workspaces" error={error} className="mb-4">
72+
<NumberInput
73+
value={maxParallelWorkspaces ?? ""}
74+
onChange={(newValue) => {
75+
setMaxParallelWorkspaces(newValue);
76+
setError(undefined);
77+
}}
78+
disabled={isLoading || !isOwner}
79+
min={0}
80+
max={isDedicatedInstallation ? undefined : organizationDefault}
81+
/>
82+
</InputField>
83+
<LoadingButton type="submit" loading={isLoading} disabled={!isOwner}>
84+
Save
85+
</LoadingButton>
86+
</form>
87+
</ConfigurationSettingsField>
88+
);
89+
};

components/dashboard/src/workspaces/CreateWorkspacePage.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import { flattenPagedConfigurations } from "../data/git-providers/unified-reposi
5959
import { Configuration } from "@gitpod/public-api/lib/gitpod/v1/configuration_pb";
6060
import { useMemberRole } from "../data/organizations/members-query";
6161
import { OrganizationPermission } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";
62+
import { useInstallationConfiguration } from "../data/installation/default-workspace-image-query";
6263

6364
type NextLoadOption = "searchParams" | "autoStart" | "allDone";
6465

@@ -847,11 +848,16 @@ export const RepositoryNotFound: FC<{ error: StartWorkspaceError }> = ({ error }
847848
};
848849

849850
export function LimitReachedParallelWorkspacesModal() {
851+
const { data: installationConfig } = useInstallationConfiguration();
852+
const isDedicated = !!installationConfig?.isDedicatedInstallation;
853+
850854
return (
851855
<LimitReachedModal>
852856
<p className="mt-1 mb-2 text-base dark:text-gray-400">
853-
You have reached the limit of parallel running workspaces for your account. Please, upgrade or stop one
854-
of the running workspaces.
857+
You have reached the limit of parallel running workspaces for your account.{" "}
858+
{!isDedicated
859+
? "Please, upgrade or stop one of your running workspaces."
860+
: "Please, stop one of your running workspaces or contact your organization owner to change the limit."}
855861
</p>
856862
</LimitReachedModal>
857863
);

components/gitpod-db/src/typeorm/entity/db-team-settings.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ export class DBOrgSettings implements OrganizationSettings {
3939
@Column("json", { nullable: true })
4040
roleRestrictions?: RoleRestrictions | undefined;
4141

42+
@Column({ type: "int", default: 0 })
43+
maxParallelRunningWorkspaces: number;
44+
4245
@Column()
4346
deleted: boolean;
4447
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { MigrationInterface, QueryRunner } from "typeorm";
8+
import { columnExists } from "./helper/helper";
9+
10+
const table = "d_b_org_settings";
11+
const newColumn = "maxParallelRunningWorkspaces";
12+
13+
export class AddOrgSettingsMaxParallelWorkspaces1734079239772 implements MigrationInterface {
14+
public async up(queryRunner: QueryRunner): Promise<void> {
15+
if (!(await columnExists(queryRunner, table, newColumn))) {
16+
await queryRunner.query(`ALTER TABLE ${table} ADD COLUMN ${newColumn} INTEGER NOT NULL DEFAULT 0`);
17+
}
18+
}
19+
20+
public async down(queryRunner: QueryRunner): Promise<void> {
21+
if (await columnExists(queryRunner, table, newColumn)) {
22+
await queryRunner.query(`ALTER TABLE ${table} DROP COLUMN ${newColumn}`);
23+
}
24+
}
25+
}

0 commit comments

Comments
 (0)