Skip to content

Commit 1074089

Browse files
committed
WIP
1 parent 544bffe commit 1074089

30 files changed

+3446
-260
lines changed

components/dashboard/src/AppNotifications.tsx

+20-20
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution";
1818
import { getGitpodService } from "./service/service";
1919
import { useOrgBillingMode } from "./data/billing-mode/org-billing-mode-query";
2020
import { Organization } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";
21+
import { MaintenanceModeBanner } from "./components/MaintenanceModeBanner";
2122

2223
const KEY_APP_DISMISSED_NOTIFICATIONS = "gitpod-app-notifications-dismissed";
2324
const PRIVACY_POLICY_LAST_UPDATED = "2024-12-03";
@@ -208,29 +209,28 @@ export function AppNotifications() {
208209
setTopNotification(undefined);
209210
}, [topNotification, setTopNotification]);
210211

211-
if (!topNotification) {
212-
return <></>;
213-
}
214-
215212
return (
216213
<div className="app-container pt-2">
217-
<Alert
218-
type={topNotification.type}
219-
closable={topNotification.id !== "gitpod-classic-sunset"} // Only show close button if it's not the sunset notification
220-
onClose={() => {
221-
if (!topNotification.preventDismiss) {
222-
dismissNotification();
223-
} else {
224-
if (topNotification.onClose) {
225-
topNotification.onClose();
214+
<MaintenanceModeBanner />
215+
{topNotification && (
216+
<Alert
217+
type={topNotification.type}
218+
closable={topNotification.id !== "gitpod-classic-sunset"} // Only show close button if it's not the sunset notification
219+
onClose={() => {
220+
if (!topNotification.preventDismiss) {
221+
dismissNotification();
222+
} else {
223+
if (topNotification.onClose) {
224+
topNotification.onClose();
225+
}
226226
}
227-
}
228-
}}
229-
showIcon={true}
230-
className="flex rounded mb-2 w-full"
231-
>
232-
<span>{topNotification.message}</span>
233-
</Alert>
227+
}}
228+
showIcon={true}
229+
className="flex rounded mb-2 w-full"
230+
>
231+
<span>{topNotification.message}</span>
232+
</Alert>
233+
)}
234234
</div>
235235
);
236236
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Copyright (c) 2025 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 { FC } from "react";
8+
import Alert from "./Alert";
9+
import { useMaintenanceMode } from "../data/maintenance-mode-query";
10+
11+
export const MaintenanceModeBanner: FC = () => {
12+
const { isMaintenanceMode } = useMaintenanceMode();
13+
14+
if (!isMaintenanceMode) {
15+
return null;
16+
}
17+
18+
return (
19+
<Alert type="warning" className="mb-2">
20+
<div className="flex items-center">
21+
<span className="font-semibold">System is in maintenance mode.</span>
22+
<span className="ml-2">Starting new workspaces is currently disabled.</span>
23+
</div>
24+
</Alert>
25+
);
26+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Copyright (c) 2025 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 { useQuery, useQueryClient } from "@tanstack/react-query";
8+
import { useCurrentOrg } from "./organizations/orgs-query";
9+
import { organizationClient } from "../service/public-api";
10+
11+
export const maintenanceModeQueryKey = (orgId: string) => ["maintenance-mode", orgId];
12+
13+
export const useMaintenanceMode = () => {
14+
const { data: org } = useCurrentOrg();
15+
const queryClient = useQueryClient();
16+
17+
const { data: isMaintenanceMode = false, isLoading } = useQuery(
18+
maintenanceModeQueryKey(org?.id || ""),
19+
async () => {
20+
if (!org?.id) return false;
21+
22+
try {
23+
const response = await organizationClient.getOrganizationMaintenanceMode({
24+
organizationId: org.id,
25+
});
26+
return response.enabled;
27+
} catch (error) {
28+
console.error("Failed to fetch maintenance mode status", error);
29+
return false;
30+
}
31+
},
32+
{
33+
enabled: !!org?.id,
34+
staleTime: 30 * 1000, // 30 seconds
35+
refetchInterval: 60 * 1000, // 1 minute
36+
},
37+
);
38+
39+
const setMaintenanceMode = async (enabled: boolean) => {
40+
if (!org?.id) return false;
41+
42+
try {
43+
const response = await organizationClient.setOrganizationMaintenanceMode({
44+
organizationId: org.id,
45+
enabled,
46+
});
47+
const result = response.enabled;
48+
49+
// Update the cache
50+
queryClient.setQueryData(maintenanceModeQueryKey(org.id), result);
51+
52+
return result;
53+
} catch (error) {
54+
console.error("Failed to set maintenance mode", error);
55+
return false;
56+
}
57+
};
58+
59+
return {
60+
isMaintenanceMode,
61+
isLoading,
62+
setMaintenanceMode,
63+
};
64+
};

components/dashboard/src/org-admin/AdminPage.tsx

+7-3
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useIsOwner } from "../data/organizations/members-query";
1212
import Header from "../components/Header";
1313
import { SpinnerLoader } from "../components/Loader";
1414
import { RunningWorkspacesCard } from "./RunningWorkspacesCard";
15+
import { MaintenanceModeCard } from "./MaintenanceModeCard";
1516

1617
const AdminPage: React.FC = () => {
1718
const history = useHistory();
@@ -51,9 +52,12 @@ const AdminPage: React.FC = () => {
5152
</div>
5253
)}
5354

54-
{currentOrg && <RunningWorkspacesCard />}
55-
56-
{/* Other admin cards/sections will go here in the future */}
55+
{currentOrg && (
56+
<>
57+
<MaintenanceModeCard />
58+
<RunningWorkspacesCard />
59+
</>
60+
)}
5761
</div>
5862
</div>
5963
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Copyright (c) 2025 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 { FC } from "react";
8+
import { useToast } from "../components/toasts/Toasts";
9+
import { Button } from "@podkit/buttons/Button";
10+
import { useMaintenanceMode } from "../data/maintenance-mode-query";
11+
12+
export const MaintenanceModeCard: FC = () => {
13+
const { isMaintenanceMode, isLoading, setMaintenanceMode } = useMaintenanceMode();
14+
const toast = useToast();
15+
16+
const toggleMaintenanceMode = async () => {
17+
try {
18+
const newState = !isMaintenanceMode;
19+
const result = await setMaintenanceMode(newState);
20+
21+
toast.toast({
22+
message: `Maintenance mode ${result ? "enabled" : "disabled"}`,
23+
type: "success",
24+
});
25+
} catch (error) {
26+
console.error("Failed to toggle maintenance mode", error);
27+
toast.toast({ message: "Failed to toggle maintenance mode", type: "error" });
28+
}
29+
};
30+
31+
return (
32+
<div className="bg-white dark:bg-gray-800 shadow-md rounded-lg p-4 mb-4">
33+
<div className="flex justify-between items-center">
34+
<div>
35+
<h3 className="text-lg font-semibold text-gray-700 dark:text-gray-200">Maintenance Mode</h3>
36+
<p className="text-gray-500 dark:text-gray-400">
37+
When enabled, users cannot start new workspaces and a notification is displayed.
38+
</p>
39+
</div>
40+
<Button
41+
variant={isMaintenanceMode ? "secondary" : "default"}
42+
onClick={toggleMaintenanceMode}
43+
disabled={isLoading}
44+
>
45+
{isLoading ? "Loading..." : isMaintenanceMode ? "Disable" : "Enable"}
46+
</Button>
47+
</div>
48+
</div>
49+
);
50+
};

components/dashboard/src/org-admin/RunningWorkspacesCard.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useWorkspaceSessions } from "../data/insights/list-workspace-sessions-q
1212
import { Button } from "@podkit/buttons/Button";
1313
import ConfirmationModal from "../components/ConfirmationModal";
1414
import { useToast } from "../components/toasts/Toasts";
15+
import { useMaintenanceMode } from "../data/maintenance-mode-query";
1516
import { Item, ItemField, ItemsList } from "../components/ItemsList";
1617
import Alert from "../components/Alert";
1718
import Spinner from "../icons/Spinner.svg";
@@ -31,6 +32,7 @@ export const RunningWorkspacesCard: FC<RunningWorkspacesCardProps> = () => {
3132
const [isStopAllModalOpen, setIsStopAllModalOpen] = useState(false);
3233
const [isStoppingAll, setIsStoppingAll] = useState(false);
3334
const toast = useToast();
35+
const { isMaintenanceMode } = useMaintenanceMode();
3436

3537
const { data, fetchNextPage, hasNextPage, isLoading, isError, error, isFetchingNextPage, refetch } =
3638
useWorkspaceSessions({
@@ -122,9 +124,9 @@ export const RunningWorkspacesCard: FC<RunningWorkspacesCardProps> = () => {
122124
<Button
123125
variant="destructive"
124126
onClick={() => setIsStopAllModalOpen(true)}
125-
disabled={isStoppingAll || isLoading || runningWorkspaces.length === 0}
127+
disabled={!isMaintenanceMode || isStoppingAll || isLoading || runningWorkspaces.length === 0}
126128
>
127-
Stop All Workspaces
129+
{!isMaintenanceMode ? "Enable Maintenance Mode to Stop All" : "Stop All Workspaces"}
128130
</Button>
129131
</div>
130132
{runningWorkspaces.length === 0 && !isLoading ? (

components/dashboard/src/service/json-rpc-organization-client.ts

+32
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
DeleteOrganizationResponse,
1717
GetOrganizationInvitationRequest,
1818
GetOrganizationInvitationResponse,
19+
GetOrganizationMaintenanceModeRequest,
20+
GetOrganizationMaintenanceModeResponse,
1921
GetOrganizationRequest,
2022
GetOrganizationResponse,
2123
GetOrganizationSettingsRequest,
@@ -31,6 +33,8 @@ import {
3133
OrganizationSettings,
3234
ResetOrganizationInvitationRequest,
3335
ResetOrganizationInvitationResponse,
36+
SetOrganizationMaintenanceModeRequest,
37+
SetOrganizationMaintenanceModeResponse,
3438
UpdateOrganizationMemberRequest,
3539
UpdateOrganizationMemberResponse,
3640
UpdateOrganizationRequest,
@@ -285,4 +289,32 @@ export class JsonRpcOrganizationClient implements PromiseClient<typeof Organizat
285289
await getGitpodService().server.updateOrgSettings(request.organizationId, update);
286290
return new UpdateOrganizationSettingsResponse();
287291
}
292+
293+
async getOrganizationMaintenanceMode(
294+
request: PartialMessage<GetOrganizationMaintenanceModeRequest>,
295+
options?: CallOptions | undefined,
296+
): Promise<GetOrganizationMaintenanceModeResponse> {
297+
if (!request.organizationId) {
298+
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "organizationId is required");
299+
}
300+
const result = await getGitpodService().server.getTeam(request.organizationId);
301+
return new GetOrganizationMaintenanceModeResponse({
302+
enabled: result.maintenanceMode,
303+
});
304+
}
305+
306+
async setOrganizationMaintenanceMode(
307+
request: PartialMessage<SetOrganizationMaintenanceModeRequest>,
308+
options?: CallOptions | undefined,
309+
): Promise<SetOrganizationMaintenanceModeResponse> {
310+
if (!request.organizationId) {
311+
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "organizationId is required");
312+
}
313+
const result = await getGitpodService().server.updateTeam(request.organizationId, {
314+
maintenanceMode: request.enabled,
315+
});
316+
return new SetOrganizationMaintenanceModeResponse({
317+
enabled: result.maintenanceMode,
318+
});
319+
}
288320
}

components/gitpod-db/src/team-db.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export interface TeamDB extends TransactionalDB<TeamDB> {
3232
findTeamsByUser(userId: string): Promise<Team[]>;
3333
findTeamsByUserAsSoleOwner(userId: string): Promise<Team[]>;
3434
createTeam(userId: string, name: string): Promise<Team>;
35-
updateTeam(teamId: string, team: Pick<Team, "name">): Promise<Team>;
35+
updateTeam(teamId: string, team: Partial<Pick<Team, "name" | "maintenanceMode">>): Promise<Team>;
3636
addMemberToTeam(userId: string, teamId: string): Promise<"added" | "already_member">;
3737
setTeamMemberRole(userId: string, teamId: string, role: TeamMemberRole): Promise<void>;
3838
removeMemberFromTeam(userId: string, teamId: string): Promise<void>;

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

+3
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,7 @@ export class DBTeam implements Team {
2525

2626
@Column()
2727
markedDeleted?: boolean;
28+
29+
@Column({ default: false })
30+
maintenanceMode?: boolean;
2831
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* Copyright (c) 2025 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+
9+
export class AddMaintenanceModeToTeam1715259000000 implements MigrationInterface {
10+
public async up(queryRunner: QueryRunner): Promise<void> {
11+
await queryRunner.query("ALTER TABLE `d_b_team` ADD COLUMN `maintenanceMode` tinyint(1) NOT NULL DEFAULT 0");
12+
}
13+
14+
public async down(queryRunner: QueryRunner): Promise<void> {
15+
await queryRunner.query("ALTER TABLE `d_b_team` DROP COLUMN `maintenanceMode`");
16+
}
17+
}

components/gitpod-db/src/typeorm/team-db-impl.ts

+16-12
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,10 @@ export class TeamDBImpl extends TransactionalDBImpl<TeamDB> implements TeamDB {
154154
return soleOwnedTeams;
155155
}
156156

157-
public async updateTeam(teamId: string, team: Pick<Team, "name">): Promise<Team> {
157+
public async updateTeam(teamId: string, team: Pick<Team, "name" | "maintenanceMode">): Promise<Team> {
158158
const name = team.name && team.name.trim();
159-
if (!name) {
159+
const maintenanceMode = team.maintenanceMode;
160+
if (!name && !maintenanceMode) {
160161
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "No update provided");
161162
}
162163

@@ -169,19 +170,22 @@ export class TeamDBImpl extends TransactionalDBImpl<TeamDB> implements TeamDB {
169170
throw new ApplicationError(ErrorCodes.NOT_FOUND, "Organization not found");
170171
}
171172

172-
// no changes
173-
if (existingTeam.name === name) {
174-
return existingTeam;
173+
// Update name if provided
174+
if (name) {
175+
if (name.length > 32) {
176+
throw new ApplicationError(
177+
ErrorCodes.INVALID_VALUE,
178+
"The name must be between 1 and 32 characters long",
179+
);
180+
}
181+
existingTeam.name = name;
182+
existingTeam.slug = await this.createUniqueSlug(teamRepo, name);
175183
}
176184

177-
if (name.length > 32) {
178-
throw new ApplicationError(
179-
ErrorCodes.INVALID_VALUE,
180-
"The name must be between 1 and 32 characters long",
181-
);
185+
// Update maintenance mode if provided
186+
if (maintenanceMode) {
187+
existingTeam.maintenanceMode = team.maintenanceMode;
182188
}
183-
existingTeam.name = name;
184-
existingTeam.slug = await this.createUniqueSlug(teamRepo, name);
185189

186190
return teamRepo.save(existingTeam);
187191
});

components/gitpod-protocol/src/gitpod-service.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
166166

167167
// Teams
168168
getTeam(teamId: string): Promise<Team>;
169-
updateTeam(teamId: string, team: Pick<Team, "name">): Promise<Team>;
169+
updateTeam(teamId: string, team: Partial<Pick<Team, "name" | "maintenanceMode">>): Promise<Team>;
170170
getTeams(): Promise<Team[]>;
171171
getTeamMembers(teamId: string): Promise<TeamMemberInfo[]>;
172172
createTeam(name: string): Promise<Team>;

0 commit comments

Comments
 (0)