Skip to content
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

Introduce max_parallel_running_workspaces for orgs #20448

Merged
merged 11 commits into from
Dec 16, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const OrgMemberPermissionRestrictionsOptions = ({
return (
<div className={cn("space-y-2", className)}>
{rolesAllowedToOpenArbitraryRepositories.map((entry) => (
<div className="flex gap-2 items-center">
<div className="flex gap-2 items-center" key={entry}>
<UserIcon size={20} />
<div>
<span className="font-medium text-pk-content-primary capitalize">{entry}</span>
Expand Down Expand Up @@ -116,6 +116,7 @@ export const OrganizationRoleRestrictionModal = ({
<OrganizationRoleRestrictionSwitch
role={role}
checked={!restrictedRoles.includes(role)}
key={role}
onCheckedChange={(checked) => {
console.log(role, { checked });
if (!checked) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const WorkspaceClassesOptions = (props: WorkspaceClassesOptionsProps) =>
return (
<div className={cn("space-y-2", props.className)}>
{props.classes.map((cls) => (
<div className="flex gap-2 items-center">
<div className="flex gap-2 items-center" key={cls.id}>
<CpuIcon size={20} />
<div>
<span className="font-medium text-pk-content-primary">{cls.displayName}</span>
Expand Down
35 changes: 33 additions & 2 deletions components/dashboard/src/components/forms/TextInputField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ type Props = TextInputProps & {
topMargin?: boolean;
containerClassName?: string;
};

export const TextInputField: FunctionComponent<Props> = memo(
({ label, id, hint, error, topMargin, containerClassName, ...props }) => {
const maybeId = useId();
Expand All @@ -44,7 +43,6 @@ interface TextInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement
onChange?: (newValue: string) => void;
onBlur?: () => void;
}

export const TextInput: FunctionComponent<TextInputProps> = memo(({ className, onChange, onBlur, ...props }) => {
const handleChange = useCallback(
(e) => {
Expand Down Expand Up @@ -72,3 +70,36 @@ export const TextInput: FunctionComponent<TextInputProps> = memo(({ className, o
/>
);
});

type NumberInputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange" | "type"> & {
onChange?: (newValue: number) => void;
onBlur?: () => void;
};
export const NumberInput: FunctionComponent<NumberInputProps> = memo(({ className, onChange, onBlur, ...props }) => {
const handleChange = useCallback(
(e) => {
onChange && onChange(e.target.valueAsNumber);
},
[onChange],
);

const handleBlur = useCallback(() => onBlur && onBlur(), [onBlur]);

return (
<input
// 7px top/bottom padding ensures height matches buttons (36px)
className={cn(
"py-[7px] w-full max-w-lg rounded-lg",
"text-pk-content-primary",
"bg-pk-surface-primary",
"border-pk-border-base",
"text-sm",
className,
)}
onChange={handleChange}
onBlur={handleBlur}
type="number"
{...props}
/>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type UpdateOrganizationSettingsArgs = Partial<
| "defaultRole"
| "timeoutSettings"
| "roleRestrictions"
| "maxParallelRunningWorkspaces"
>
>;

Expand All @@ -43,10 +44,11 @@ export const useUpdateOrgSettingsMutation = () => {
defaultRole,
timeoutSettings,
roleRestrictions,
maxParallelRunningWorkspaces,
}) => {
const settings = await organizationClient.updateOrganizationSettings({
organizationId: teamId,
workspaceSharingDisabled: workspaceSharingDisabled || false,
workspaceSharingDisabled: workspaceSharingDisabled ?? false,
defaultWorkspaceImage,
allowedWorkspaceClasses,
updatePinnedEditorVersions: !!pinnedEditorVersions,
Expand All @@ -57,6 +59,8 @@ export const useUpdateOrgSettingsMutation = () => {
timeoutSettings,
roleRestrictions,
updateRoleRestrictions: !!roleRestrictions,
maxParallelRunningWorkspaces,
updateMaxParallelRunningWorkspaces: maxParallelRunningWorkspaces !== undefined,
});
return settings.settings!;
},
Expand Down
3 changes: 3 additions & 0 deletions components/dashboard/src/start/StartWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,9 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
// scenarios with distributed workspace bridges (control loops): We might receive the update, but the backend might not have the token, yet.
// So we have to ask again, and wait until we're actually successful (it returns immediately on the happy path)
await this.ensureWorkspaceAuth(workspace.status!.instanceId, true);
if (this.state.error && this.state.error?.code !== ErrorCodes.NOT_FOUND) {
return;
}
this.redirectTo(workspace.status!.workspaceUrl);
})().catch(console.error);
return;
Expand Down
28 changes: 16 additions & 12 deletions components/dashboard/src/teams/TeamPolicies.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
OrgMemberPermissionRestrictionsOptions,
} from "../components/OrgMemberPermissionsOptions";
import { LightbulbIcon } from "lucide-react";
import { MaxParallelWorkspaces } from "./policies/MaxParallelWorkspaces";

export default function TeamPoliciesPage() {
useDocumentTitle("Organization Settings - Policies");
Expand Down Expand Up @@ -121,7 +122,7 @@ export default function TeamPoliciesPage() {
[workspaceTimeout, allowTimeoutChangeByMembers, handleUpdateTeamSettings],
);

const billingModeAllowsWorkspaceTimeouts =
const isPaidPlan =
filiptronicek marked this conversation as resolved.
Show resolved Hide resolved
billingMode.data?.mode === "none" || (billingMode.data?.mode === "usage-based" && billingMode.data?.paid);

return (
Expand Down Expand Up @@ -156,7 +157,7 @@ export default function TeamPoliciesPage() {

<ConfigurationSettingsField>
<Heading3>Workspace timeouts</Heading3>
{!billingModeAllowsWorkspaceTimeouts && (
{!isPaidPlan && (
<Alert type="info" className="my-3">
Setting Workspace timeouts is only available for organizations on a paid plan. Visit{" "}
<Link to={"/billing"} className="gp-link">
Expand All @@ -180,9 +181,7 @@ export default function TeamPoliciesPage() {
value={workspaceTimeout ?? ""}
placeholder="e.g. 30m"
onChange={setWorkspaceTimeout}
disabled={
updateTeamSettings.isLoading || !isOwner || !billingModeAllowsWorkspaceTimeouts
}
disabled={updateTeamSettings.isLoading || !isOwner || !isPaidPlan}
/>
</InputField>
<CheckboxInputField
Expand All @@ -191,19 +190,16 @@ export default function TeamPoliciesPage() {
checked={!!allowTimeoutChangeByMembers}
containerClassName="my-4"
onChange={setAllowTimeoutChangeByMembers}
disabled={
updateTeamSettings.isLoading || !isOwner || !billingModeAllowsWorkspaceTimeouts
}
disabled={updateTeamSettings.isLoading || !isOwner || !isPaidPlan}
/>
<LoadingButton
type="submit"
loading={updateTeamSettings.isLoading}
disabled={
!isOwner ||
!billingModeAllowsWorkspaceTimeouts ||
((workspaceTimeout ===
converter.toDurationString(settings?.timeoutSettings?.inactivity) ??
"") &&
!isPaidPlan ||
(workspaceTimeout ===
converter.toDurationString(settings?.timeoutSettings?.inactivity) &&
allowTimeoutChangeByMembers === !settings?.timeoutSettings?.denyUserTimeouts)
}
>
Expand All @@ -212,6 +208,14 @@ export default function TeamPoliciesPage() {
</form>
</ConfigurationSettingsField>

<MaxParallelWorkspaces
isOwner={isOwner}
isLoading={updateTeamSettings.isLoading}
settings={settings}
handleUpdateTeamSettings={handleUpdateTeamSettings}
isPaidPlan={isPaidPlan}
/>

<OrgWorkspaceClassesOptions
isOwner={isOwner}
settings={settings}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { OrganizationSettings } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";
import { FormEvent, useEffect, useState } from "react";
import { ConfigurationSettingsField } from "../../repositories/detail/ConfigurationSettingsField";
import { Heading3, Subheading } from "@podkit/typography/Headings";
import { InputField } from "../../components/forms/InputField";
import { NumberInput } from "../../components/forms/TextInputField";
import { LoadingButton } from "@podkit/buttons/LoadingButton";
import { MAX_PARALLEL_WORKSPACES_FREE, MAX_PARALLEL_WORKSPACES_PAID } from "@gitpod/gitpod-protocol";
import { PlainMessage } from "@bufbuild/protobuf";
import { useInstallationConfiguration } from "../../data/installation/default-workspace-image-query";

type Props = {
isOwner: boolean;
isLoading: boolean;
isPaidPlan: boolean;
settings?: OrganizationSettings;
handleUpdateTeamSettings: (
newSettings: Partial<PlainMessage<OrganizationSettings>>,
options?: {
throwMutateError?: boolean;
},
) => Promise<void>;
};

export const MaxParallelWorkspaces = ({
isOwner,
isLoading,
settings,
isPaidPlan,
handleUpdateTeamSettings,
}: Props) => {
const [error, setError] = useState<string | undefined>(undefined);
const [maxParallelWorkspaces, setMaxParallelWorkspaces] = useState<number>(
settings?.maxParallelRunningWorkspaces ?? 0,
);

const organizationDefault = isPaidPlan ? MAX_PARALLEL_WORKSPACES_PAID : MAX_PARALLEL_WORKSPACES_FREE;
const { data: installationConfig } = useInstallationConfiguration();
const isDedicatedInstallation = !!installationConfig?.isDedicatedInstallation;

const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (maxParallelWorkspaces < 0) {
setError("The maximum parallel running workspaces must be a positive number.");
return;
}
await handleUpdateTeamSettings({
maxParallelRunningWorkspaces: maxParallelWorkspaces,
});
};

useEffect(() => {
setMaxParallelWorkspaces(settings?.maxParallelRunningWorkspaces ?? 0);
}, [settings?.maxParallelRunningWorkspaces]);

return (
<ConfigurationSettingsField>
<Heading3>Maximum parallel running workspaces</Heading3>
<Subheading>
By default, every user in your organization can have <strong>{organizationDefault}</strong> workspaces
running at the same time. You can change this limit below or revert to this default by specifying{" "}
<strong>0</strong> as the limit.
</Subheading>
<form onSubmit={handleSubmit}>
<InputField label="Maximum parallel running workspaces" error={error} className="mb-4">
<NumberInput
value={maxParallelWorkspaces ?? ""}
onChange={(newValue) => {
setMaxParallelWorkspaces(newValue);
setError(undefined);
}}
disabled={isLoading || !isOwner}
min={0}
max={isDedicatedInstallation ? undefined : organizationDefault}
/>
</InputField>
<LoadingButton type="submit" loading={isLoading} disabled={!isOwner}>
Save
</LoadingButton>
</form>
</ConfigurationSettingsField>
);
};
10 changes: 8 additions & 2 deletions components/dashboard/src/workspaces/CreateWorkspacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { flattenPagedConfigurations } from "../data/git-providers/unified-reposi
import { Configuration } from "@gitpod/public-api/lib/gitpod/v1/configuration_pb";
import { useMemberRole } from "../data/organizations/members-query";
import { OrganizationPermission } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";
import { useInstallationConfiguration } from "../data/installation/default-workspace-image-query";

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

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

export function LimitReachedParallelWorkspacesModal() {
const { data: installationConfig } = useInstallationConfiguration();
const isDedicated = !!installationConfig?.isDedicatedInstallation;

return (
<LimitReachedModal>
<p className="mt-1 mb-2 text-base dark:text-gray-400">
You have reached the limit of parallel running workspaces for your account. Please, upgrade or stop one
of the running workspaces.
You have reached the limit of parallel running workspaces for your account.{" "}
{!isDedicated
? "Please, upgrade or stop one of your running workspaces."
: "Please, stop one of your running workspaces or contact your organization owner to change the limit."}
</p>
</LimitReachedModal>
);
Expand Down
3 changes: 3 additions & 0 deletions components/gitpod-db/src/typeorm/entity/db-team-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export class DBOrgSettings implements OrganizationSettings {
@Column("json", { nullable: true })
roleRestrictions?: RoleRestrictions | undefined;

@Column({ type: "int", default: 0 })
maxParallelRunningWorkspaces: number;

@Column()
deleted: boolean;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { MigrationInterface, QueryRunner } from "typeorm";
import { columnExists } from "./helper/helper";

const table = "d_b_org_settings";
const newColumn = "maxParallelRunningWorkspaces";

export class AddOrgSettingsMaxParallelWorkspaces1734079239772 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
if (!(await columnExists(queryRunner, table, newColumn))) {
await queryRunner.query(`ALTER TABLE ${table} ADD COLUMN ${newColumn} INTEGER NOT NULL DEFAULT 0`);
}
}

public async down(queryRunner: QueryRunner): Promise<void> {
if (await columnExists(queryRunner, table, newColumn)) {
await queryRunner.query(`ALTER TABLE ${table} DROP COLUMN ${newColumn}`);
}
}
}
1 change: 1 addition & 0 deletions components/gitpod-db/src/typeorm/team-db-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ export class TeamDBImpl extends TransactionalDBImpl<TeamDB> implements TeamDB {
"defaultRole",
"timeoutSettings",
"roleRestrictions",
"maxParallelRunningWorkspaces",
],
});
}
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 @@ -377,6 +377,9 @@ export const WORKSPACE_TIMEOUT_EXTENDED: WorkspaceTimeoutDuration = "180m";
export const WORKSPACE_LIFETIME_SHORT: WorkspaceTimeoutDuration = "8h";
export const WORKSPACE_LIFETIME_LONG: WorkspaceTimeoutDuration = "36h";

export const MAX_PARALLEL_WORKSPACES_FREE = 4;
geropl marked this conversation as resolved.
Show resolved Hide resolved
export const MAX_PARALLEL_WORKSPACES_PAID = 16;

export const createServiceMock = function <C extends GitpodClient, S extends GitpodServer>(
methods: Partial<JsonRpcProxy<S>>,
): GitpodServiceImpl<C, S> {
Expand Down
3 changes: 3 additions & 0 deletions components/gitpod-protocol/src/teams-projects-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,9 @@ export interface OrganizationSettings {
timeoutSettings?: TimeoutSettings;

roleRestrictions?: RoleRestrictions;

// max number of parallel running workspaces per user
maxParallelRunningWorkspaces?: number;
}

export type TimeoutSettings = {
Expand Down
Loading
Loading