Skip to content

Commit ccdca90

Browse files
committed
[server, db, dashboard] Allow org-owner to stop workspace on all workspaces in the organization
Also, fix maintenanceMode update
1 parent 8ba9feb commit ccdca90

30 files changed

+3530
-268
lines changed

components/dashboard/src/AppNotifications.tsx

Lines changed: 20 additions & 20 deletions
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
}
Lines changed: 26 additions & 0 deletions
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+
};
Lines changed: 64 additions & 0 deletions
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

Lines changed: 7 additions & 3 deletions
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
);
Lines changed: 50 additions & 0 deletions
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

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@
44
* See License.AGPL.txt in the project root for license information.
55
*/
66

7-
import { FC, useEffect, useMemo } from "react";
7+
import { FC, useEffect, useMemo, useState } from "react";
88
import dayjs from "dayjs";
99
import { WorkspaceSession, WorkspacePhase_Phase } from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
10+
import { workspaceClient } from "../service/public-api";
1011
import { useWorkspaceSessions } from "../data/insights/list-workspace-sessions-query";
12+
import { Button } from "@podkit/buttons/Button";
13+
import ConfirmationModal from "../components/ConfirmationModal";
14+
import { useToast } from "../components/toasts/Toasts";
15+
import { useMaintenanceMode } from "../data/maintenance-mode-query";
1116
import { Item, ItemField, ItemsList } from "../components/ItemsList";
1217
import Alert from "../components/Alert";
1318
import Spinner from "../icons/Spinner.svg";
@@ -24,10 +29,15 @@ const isWorkspaceNotStopped = (session: WorkspaceSession): boolean => {
2429

2530
export const RunningWorkspacesCard: FC<RunningWorkspacesCardProps> = () => {
2631
const lookbackHours = 48;
32+
const [isStopAllModalOpen, setIsStopAllModalOpen] = useState(false);
33+
const [isStoppingAll, setIsStoppingAll] = useState(false);
34+
const toast = useToast();
35+
const { isMaintenanceMode } = useMaintenanceMode();
2736

28-
const { data, fetchNextPage, hasNextPage, isLoading, isError, error, isFetchingNextPage } = useWorkspaceSessions({
29-
from: Timestamp.fromDate(dayjs().subtract(lookbackHours, "hours").startOf("day").toDate()),
30-
});
37+
const { data, fetchNextPage, hasNextPage, isLoading, isError, error, isFetchingNextPage, refetch } =
38+
useWorkspaceSessions({
39+
from: Timestamp.fromDate(dayjs().subtract(lookbackHours, "hours").startOf("day").toDate()),
40+
});
3141

3242
useEffect(() => {
3343
if (hasNextPage && !isFetchingNextPage) {
@@ -43,7 +53,51 @@ export const RunningWorkspacesCard: FC<RunningWorkspacesCardProps> = () => {
4353
return allSessions.filter(isWorkspaceNotStopped);
4454
}, [data]);
4555

46-
if (isLoading) {
56+
const handleStopAllWorkspaces = async () => {
57+
if (runningWorkspaces.length === 0) {
58+
toast.toast({ type: "error", message: "No running workspaces to stop." });
59+
setIsStopAllModalOpen(false);
60+
return;
61+
}
62+
63+
setIsStoppingAll(true);
64+
let successCount = 0;
65+
let errorCount = 0;
66+
67+
const stopPromises = runningWorkspaces.map(async (session) => {
68+
if (session.workspace?.id) {
69+
try {
70+
await workspaceClient.stopWorkspace({ workspaceId: session.workspace.id });
71+
successCount++;
72+
} catch (e) {
73+
console.error(`Failed to stop workspace ${session.workspace.id}:`, e);
74+
errorCount++;
75+
}
76+
}
77+
});
78+
79+
await Promise.allSettled(stopPromises);
80+
81+
setIsStoppingAll(false);
82+
setIsStopAllModalOpen(false);
83+
84+
if (errorCount > 0) {
85+
toast.toast({
86+
type: "error",
87+
message: `Failed to stop all workspaces`,
88+
description: `Attempted to stop ${runningWorkspaces.length} workspaces. ${successCount} stopped, ${errorCount} failed.`,
89+
});
90+
} else {
91+
toast.toast({
92+
type: "success",
93+
message: `Stop command sent`,
94+
description: `Successfully sent stop command for ${successCount} workspaces.`,
95+
});
96+
}
97+
refetch();
98+
};
99+
100+
if (isLoading && !isStoppingAll) {
47101
return (
48102
<div className="flex items-center justify-center w-full space-x-2 text-gray-400 text-sm p-8">
49103
<img alt="Loading Spinner" className="h-4 w-4 animate-spin" src={Spinner} />
@@ -63,10 +117,19 @@ export const RunningWorkspacesCard: FC<RunningWorkspacesCardProps> = () => {
63117

64118
return (
65119
<div className="bg-white dark:bg-gray-800 shadow-md rounded-lg p-4 mt-6">
66-
<h3 className="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-3">
67-
Currently Running Workspaces ({runningWorkspaces.length})
68-
</h3>
69-
{runningWorkspaces.length === 0 ? (
120+
<div className="flex justify-between items-center mb-3">
121+
<h3 className="text-lg font-semibold text-gray-700 dark:text-gray-200">
122+
Currently Running Workspaces ({runningWorkspaces.length})
123+
</h3>
124+
<Button
125+
variant="destructive"
126+
onClick={() => setIsStopAllModalOpen(true)}
127+
disabled={!isMaintenanceMode || isStoppingAll || isLoading || runningWorkspaces.length === 0}
128+
>
129+
{!isMaintenanceMode ? "Enable Maintenance Mode to Stop All" : "Stop All Workspaces"}
130+
</Button>
131+
</div>
132+
{runningWorkspaces.length === 0 && !isLoading ? (
70133
<p className="text-gray-500 dark:text-gray-400">No workspaces are currently running.</p>
71134
) : (
72135
<ItemsList className="text-gray-400 dark:text-gray-500">
@@ -116,6 +179,21 @@ export const RunningWorkspacesCard: FC<RunningWorkspacesCardProps> = () => {
116179
})}
117180
</ItemsList>
118181
)}
182+
<ConfirmationModal
183+
title="Confirm Stop All Workspaces"
184+
visible={isStopAllModalOpen}
185+
onClose={() => setIsStopAllModalOpen(false)}
186+
onConfirm={handleStopAllWorkspaces}
187+
buttonText={isStoppingAll ? "Stopping..." : "Confirm Stop All"}
188+
buttonType="destructive"
189+
buttonDisabled={isStoppingAll}
190+
>
191+
<p className="text-sm text-gray-600 dark:text-gray-300">
192+
Are you sure you want to stop all {runningWorkspaces.length} currently running workspaces in this
193+
organization? Workspaces will be backed up before stopping. This action cannot be undone for the
194+
stop process itself.
195+
</p>
196+
</ConfirmationModal>
119197
</div>
120198
);
121199
};

0 commit comments

Comments
 (0)