Skip to content

Commit 52848de

Browse files
[server, dashboard, db] Org-wide "maintenance mode" (#20813)
* [dashboard] Initial infra rollout page, incl. list running workspaces * [server, db, dashboard] Allow org-owner to stop workspace on all workspaces in the organization Also, fix maintenanceMode update * [public-api, db, server, dashboard] Introduce MaintenanceNofitication banner that can be configured per org * review comments: use mutation instead of callback for state mutation * Fix workspace start prevention * Review comments around banners and rendering - permissions issues - add banner to /new page - a bunch of rendering issues and alignments - renaming some things for clarity (dropped "Scheduled" prefixes) - only allow up to 255 characters in notification messages * [dashboard] Only show Admin entry for dedicated * [server] Fix permissions for setMaintenanceMode to "maintenance" * [dashboard] Adjusted copy incl. default notification message * Review coments: re-use and fix styles, and naming Co-authored-by: Filip Troníček <[email protected]> * Minor copy improvements * [server] Fix bogus permission check in stopWorkspace --------- Co-authored-by: Filip Troníček <[email protected]>
1 parent 29ed2d7 commit 52848de

40 files changed

+7719
-150
lines changed

components/dashboard/src/AppNotifications.tsx

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ 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 "./org-admin/MaintenanceModeBanner";
22+
import { MaintenanceNotificationBanner } from "./org-admin/MaintenanceNotificationBanner";
2123

2224
const KEY_APP_DISMISSED_NOTIFICATIONS = "gitpod-app-notifications-dismissed";
2325
const PRIVACY_POLICY_LAST_UPDATED = "2024-12-03";
@@ -208,29 +210,29 @@ export function AppNotifications() {
208210
setTopNotification(undefined);
209211
}, [topNotification, setTopNotification]);
210212

211-
if (!topNotification) {
212-
return <></>;
213-
}
214-
215213
return (
216214
<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();
215+
<MaintenanceModeBanner />
216+
<MaintenanceNotificationBanner />
217+
{topNotification && (
218+
<Alert
219+
type={topNotification.type}
220+
closable={topNotification.id !== "gitpod-classic-sunset"} // Only show close button if it's not the sunset notification
221+
onClose={() => {
222+
if (!topNotification.preventDismiss) {
223+
dismissNotification();
224+
} else {
225+
if (topNotification.onClose) {
226+
topNotification.onClose();
227+
}
226228
}
227-
}
228-
}}
229-
showIcon={true}
230-
className="flex rounded mb-2 w-full"
231-
>
232-
<span>{topNotification.message}</span>
233-
</Alert>
229+
}}
230+
showIcon={true}
231+
className="flex rounded mb-2 w-full"
232+
>
233+
<span>{topNotification.message}</span>
234+
</Alert>
235+
)}
234236
</div>
235237
);
236238
}

components/dashboard/src/app/AppRoutes.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ const ConfigurationDetailPage = React.lazy(
7979
);
8080

8181
const PrebuildListPage = React.lazy(() => import(/* webpackPrefetch: true */ "../prebuilds/list/PrebuildListPage"));
82+
const AdminPage = React.lazy(() => import(/* webpackPrefetch: true */ "../org-admin/AdminPage"));
8283

8384
export const AppRoutes = () => {
8485
const hash = getURLHash();
@@ -205,6 +206,7 @@ export const AppRoutes = () => {
205206
{/* TODO: migrate other org settings pages underneath /settings prefix so we can utilize nested routes */}
206207
<Route exact path="/billing" component={TeamUsageBasedBilling} />
207208
<Route exact path="/sso" component={SSO} />
209+
<Route exact path="/org-admin" component={AdminPage} />
208210

209211
<Route exact path={`/prebuilds`} component={PrebuildListPage} />
210212
<Route path="/prebuilds/:prebuildId" component={PrebuildDetailPage} />
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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 { useMutation, useQueryClient } from "@tanstack/react-query";
8+
import { useCurrentOrg } from "../organizations/orgs-query";
9+
import { organizationClient } from "../../service/public-api";
10+
import { maintenanceModeQueryKey } from "./maintenance-mode-query";
11+
12+
export interface SetMaintenanceModeArgs {
13+
enabled: boolean;
14+
}
15+
16+
export const useSetMaintenanceModeMutation = () => {
17+
const { data: org } = useCurrentOrg();
18+
const queryClient = useQueryClient();
19+
const organizationId = org?.id ?? "";
20+
21+
return useMutation<boolean, Error, SetMaintenanceModeArgs>({
22+
mutationFn: async ({ enabled }) => {
23+
if (!organizationId) {
24+
throw new Error("No organization selected");
25+
}
26+
27+
try {
28+
const response = await organizationClient.setOrganizationMaintenanceMode({
29+
organizationId,
30+
enabled,
31+
});
32+
return response.enabled;
33+
} catch (error) {
34+
console.error("Failed to set maintenance mode", error);
35+
throw error;
36+
}
37+
},
38+
onSuccess: (result) => {
39+
// Update the cache
40+
queryClient.setQueryData(maintenanceModeQueryKey(organizationId), result);
41+
},
42+
});
43+
};
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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 } 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+
16+
const { data: isMaintenanceMode = false, isLoading } = useQuery(
17+
maintenanceModeQueryKey(org?.id || ""),
18+
async () => {
19+
if (!org?.id) return false;
20+
21+
try {
22+
const response = await organizationClient.getOrganizationMaintenanceMode({
23+
organizationId: org.id,
24+
});
25+
return response.enabled;
26+
} catch (error) {
27+
console.error("Failed to fetch maintenance mode status", error);
28+
return false;
29+
}
30+
},
31+
{
32+
enabled: !!org?.id,
33+
staleTime: 30 * 1000, // 30 seconds
34+
refetchInterval: 60 * 1000, // 1 minute
35+
},
36+
);
37+
38+
return {
39+
isMaintenanceMode,
40+
isLoading,
41+
};
42+
};
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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 { useMutation, useQueryClient } from "@tanstack/react-query";
8+
import { useCurrentOrg } from "../organizations/orgs-query";
9+
import { organizationClient } from "../../service/public-api";
10+
import { MaintenanceNotification } from "@gitpod/gitpod-protocol";
11+
import { maintenanceNotificationQueryKey } from "./maintenance-notification-query";
12+
13+
export interface SetMaintenanceNotificationArgs {
14+
isEnabled: boolean;
15+
customMessage?: string;
16+
}
17+
18+
export const useSetMaintenanceNotificationMutation = () => {
19+
const { data: org } = useCurrentOrg();
20+
const queryClient = useQueryClient();
21+
const organizationId = org?.id ?? "";
22+
23+
return useMutation<MaintenanceNotification, Error, SetMaintenanceNotificationArgs>({
24+
mutationFn: async ({ isEnabled, customMessage }) => {
25+
if (!organizationId) {
26+
throw new Error("No organization selected");
27+
}
28+
29+
try {
30+
const response = await organizationClient.setMaintenanceNotification({
31+
organizationId,
32+
isEnabled,
33+
customMessage,
34+
});
35+
36+
const result: MaintenanceNotification = {
37+
enabled: response.isEnabled,
38+
message: response.message,
39+
};
40+
41+
return result;
42+
} catch (error) {
43+
console.error("Failed to set maintenance notification", error);
44+
throw error;
45+
}
46+
},
47+
onSuccess: (result) => {
48+
// Update the cache
49+
queryClient.setQueryData(maintenanceNotificationQueryKey(organizationId), result);
50+
},
51+
});
52+
};
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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 } from "@tanstack/react-query";
8+
import { useCurrentOrg } from "../organizations/orgs-query";
9+
import { organizationClient } from "../../service/public-api";
10+
import { MaintenanceNotification } from "@gitpod/gitpod-protocol";
11+
12+
export const maintenanceNotificationQueryKey = (orgId: string) => ["maintenance-notification", orgId];
13+
14+
export const useMaintenanceNotification = () => {
15+
const { data: org } = useCurrentOrg();
16+
17+
const { data, isLoading } = useQuery<MaintenanceNotification>(
18+
maintenanceNotificationQueryKey(org?.id || ""),
19+
async () => {
20+
if (!org?.id) return { enabled: false };
21+
22+
try {
23+
const response = await organizationClient.getMaintenanceNotification({
24+
organizationId: org.id,
25+
});
26+
return {
27+
enabled: response.isEnabled,
28+
message: response.message,
29+
};
30+
} catch (error) {
31+
console.error("Failed to fetch maintenance notification settings", error);
32+
return { enabled: false };
33+
}
34+
},
35+
{
36+
enabled: !!org?.id,
37+
staleTime: 30 * 1000, // 30 seconds
38+
refetchInterval: 60 * 1000, // 1 minute
39+
},
40+
);
41+
42+
return {
43+
isNotificationEnabled: data?.enabled || false,
44+
notificationMessage: data?.message,
45+
isLoading,
46+
};
47+
};

components/dashboard/src/menu/OrganizationSelector.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,17 @@ export default function OrganizationSelector() {
127127
separator: false,
128128
link: "/settings",
129129
});
130+
131+
if (isOwner && isDedicated) {
132+
// Add Admin link for owners
133+
linkEntries.push({
134+
title: "Organization Administration",
135+
customContent: <LinkEntry>Organization Administration</LinkEntry>,
136+
active: false,
137+
separator: false,
138+
link: "/org-admin",
139+
});
140+
}
130141
}
131142
}
132143

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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 React, { useEffect } from "react";
8+
import { useHistory } from "react-router-dom";
9+
import { useUserLoader } from "../hooks/use-user-loader";
10+
import { useCurrentOrg } from "../data/organizations/orgs-query";
11+
import { useIsOwner } from "../data/organizations/members-query";
12+
import Header from "../components/Header";
13+
import { SpinnerLoader } from "../components/Loader";
14+
import { RunningWorkspacesCard } from "./RunningWorkspacesCard";
15+
import { MaintenanceModeCard } from "./MaintenanceModeCard";
16+
import { MaintenanceNotificationCard } from "./MaintenanceNotificationCard";
17+
import { Heading2 } from "@podkit/typography/Headings";
18+
19+
const AdminPage: React.FC = () => {
20+
const history = useHistory();
21+
const { loading: userLoading } = useUserLoader();
22+
const { data: currentOrg, isLoading: orgLoading } = useCurrentOrg();
23+
const isOwner = useIsOwner();
24+
25+
useEffect(() => {
26+
if (userLoading || orgLoading) {
27+
return;
28+
}
29+
if (!isOwner) {
30+
history.replace("/workspaces");
31+
}
32+
}, [isOwner, userLoading, orgLoading, history, currentOrg?.id]);
33+
34+
return (
35+
<div className="flex flex-col w-full">
36+
<Header title="Organization Administration" subtitle="Manage Infrastructure Rollouts" />
37+
<div className="app-container py-6 flex flex-col gap-4">
38+
<Heading2>Infrastructure Rollout</Heading2>
39+
40+
{userLoading ||
41+
orgLoading ||
42+
(!isOwner && (
43+
<div className="flex items-center justify-center w-full p-8">
44+
<SpinnerLoader />
45+
</div>
46+
))}
47+
48+
{!orgLoading && !currentOrg && (
49+
<div className="text-red-500 p-4 bg-red-100 dark:bg-red-900 border border-red-500 rounded-md">
50+
Could not load organization details. Please ensure you are part of an organization.
51+
</div>
52+
)}
53+
54+
{currentOrg && (
55+
<>
56+
<MaintenanceNotificationCard />
57+
<MaintenanceModeCard />
58+
<RunningWorkspacesCard />
59+
</>
60+
)}
61+
</div>
62+
</div>
63+
);
64+
};
65+
66+
export default AdminPage;
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 "../components/Alert";
9+
import { useMaintenanceMode } from "../data/maintenance-mode/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 flex-wrap gap-2">
21+
<span className="font-semibold">System is in maintenance mode.</span>
22+
<span>Starting new workspaces is currently disabled by your organization owner.</span>
23+
</div>
24+
</Alert>
25+
);
26+
};

0 commit comments

Comments
 (0)