Skip to content

Commit d73be97

Browse files
committed
[public-api, db, server, dashboard] Introduce MaintenanceNofitication banner that can be configured per org
1 parent ccdca90 commit d73be97

25 files changed

+4273
-242
lines changed

components/dashboard/src/AppNotifications.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +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 "./components/MaintenanceModeBanner";
21+
import { MaintenanceModeBanner } from "./org-admin/MaintenanceModeBanner";
22+
import { ScheduledMaintenanceBanner } from "./org-admin/ScheduledMaintenanceBanner";
2223

2324
const KEY_APP_DISMISSED_NOTIFICATIONS = "gitpod-app-notifications-dismissed";
2425
const PRIVACY_POLICY_LAST_UPDATED = "2024-12-03";
@@ -212,6 +213,7 @@ export function AppNotifications() {
212213
return (
213214
<div className="app-container pt-2">
214215
<MaintenanceModeBanner />
216+
<ScheduledMaintenanceBanner />
215217
{topNotification && (
216218
<Alert
217219
type={topNotification.type}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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+
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+
const queryClient = useQueryClient();
17+
18+
const { data, isLoading } = useQuery<MaintenanceNotification>(
19+
maintenanceNotificationQueryKey(org?.id || ""),
20+
async () => {
21+
if (!org?.id) return { enabled: false };
22+
23+
try {
24+
const response = await organizationClient.getMaintenanceNotification({
25+
organizationId: org.id,
26+
});
27+
return {
28+
enabled: response.isEnabled,
29+
message: response.message,
30+
};
31+
} catch (error) {
32+
console.error("Failed to fetch maintenance notification settings", error);
33+
return { enabled: false };
34+
}
35+
},
36+
{
37+
enabled: !!org?.id,
38+
staleTime: 30 * 1000, // 30 seconds
39+
refetchInterval: 60 * 1000, // 1 minute
40+
},
41+
);
42+
43+
const setMaintenanceNotification = async (
44+
isEnabled: boolean,
45+
customMessage?: string,
46+
): Promise<MaintenanceNotification> => {
47+
if (!org?.id) return { enabled: false, message: "" };
48+
49+
try {
50+
const response = await organizationClient.setMaintenanceNotification({
51+
organizationId: org.id,
52+
isEnabled,
53+
customMessage,
54+
});
55+
56+
const result: MaintenanceNotification = {
57+
enabled: response.isEnabled,
58+
message: response.message,
59+
};
60+
61+
// Update the cache
62+
queryClient.setQueryData(maintenanceNotificationQueryKey(org.id), result);
63+
64+
return result;
65+
} catch (error) {
66+
console.error("Failed to set maintenance notification", error);
67+
return { enabled: false };
68+
}
69+
};
70+
71+
return {
72+
isNotificationEnabled: data?.enabled || false,
73+
notificationMessage: data?.message,
74+
isLoading,
75+
setMaintenanceNotification,
76+
};
77+
};

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import Header from "../components/Header";
1313
import { SpinnerLoader } from "../components/Loader";
1414
import { RunningWorkspacesCard } from "./RunningWorkspacesCard";
1515
import { MaintenanceModeCard } from "./MaintenanceModeCard";
16+
import { MaintenanceNotificationCard } from "./MaintenanceNotificationCard";
1617

1718
const AdminPage: React.FC = () => {
1819
const history = useHistory();
@@ -54,6 +55,7 @@ const AdminPage: React.FC = () => {
5455

5556
{currentOrg && (
5657
<>
58+
<MaintenanceNotificationCard />
5759
<MaintenanceModeCard />
5860
<RunningWorkspacesCard />
5961
</>

components/dashboard/src/components/MaintenanceModeBanner.tsx renamed to components/dashboard/src/org-admin/MaintenanceModeBanner.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import { FC } from "react";
8-
import Alert from "./Alert";
8+
import Alert from "../components/Alert";
99
import { useMaintenanceMode } from "../data/maintenance-mode-query";
1010

1111
export const MaintenanceModeBanner: FC = () => {
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
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, useState, useEffect } from "react";
8+
import { useToast } from "../components/toasts/Toasts";
9+
import { Button } from "@podkit/buttons/Button";
10+
import { useMaintenanceNotification } from "../data/maintenance-notification-query";
11+
import Alert from "../components/Alert";
12+
13+
export const MaintenanceNotificationCard: FC = () => {
14+
const { isNotificationEnabled, notificationMessage, isLoading, setMaintenanceNotification } =
15+
useMaintenanceNotification();
16+
const [message, setMessage] = useState(notificationMessage);
17+
const [isEditing, setIsEditing] = useState(false);
18+
const toast = useToast();
19+
20+
// Update local state when the data from the API changes
21+
useEffect(() => {
22+
setMessage(notificationMessage);
23+
}, [notificationMessage]);
24+
25+
const toggleNotification = async () => {
26+
try {
27+
const newState = !isNotificationEnabled;
28+
const result = await setMaintenanceNotification(newState, message);
29+
30+
toast.toast({
31+
message: `Maintenance notification ${result.enabled ? "enabled" : "disabled"}`,
32+
type: "success",
33+
});
34+
35+
setIsEditing(false);
36+
} catch (error) {
37+
console.error("Failed to toggle maintenance notification", error);
38+
toast.toast({ message: "Failed to toggle maintenance notification", type: "error" });
39+
}
40+
};
41+
42+
const saveMessage = async () => {
43+
try {
44+
await setMaintenanceNotification(isNotificationEnabled, message);
45+
46+
toast.toast({
47+
message: "Maintenance notification message updated",
48+
type: "success",
49+
});
50+
51+
setIsEditing(false);
52+
} catch (error) {
53+
console.error("Failed to update maintenance notification message", error);
54+
toast.toast({ message: "Failed to update maintenance notification message", type: "error" });
55+
}
56+
};
57+
58+
return (
59+
<div className="bg-white dark:bg-gray-800 shadow-md rounded-lg p-4 mb-4">
60+
<div className="flex justify-between items-center mb-4">
61+
<div>
62+
<h3 className="text-lg font-semibold text-gray-700 dark:text-gray-200">
63+
Scheduled Maintenance Notification
64+
</h3>
65+
<p className="text-gray-500 dark:text-gray-400">
66+
Display a notification banner to inform users about upcoming maintenance.
67+
</p>
68+
</div>
69+
<Button
70+
variant={isNotificationEnabled ? "secondary" : "default"}
71+
onClick={toggleNotification}
72+
disabled={isLoading}
73+
>
74+
{isLoading ? "Loading..." : isNotificationEnabled ? "Disable" : "Enable"}
75+
</Button>
76+
</div>
77+
78+
{/* Message input section */}
79+
<div className="mt-4">
80+
<label
81+
htmlFor="maintenance-message"
82+
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
83+
>
84+
Notification Message
85+
</label>
86+
{isEditing ? (
87+
<div>
88+
<textarea
89+
id="maintenance-message"
90+
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-white"
91+
rows={3}
92+
value={message}
93+
onChange={(e) => setMessage(e.target.value)}
94+
placeholder="Enter a message to display in the notification banner"
95+
/>
96+
<div className="mt-2 flex justify-end space-x-2">
97+
<Button
98+
variant="secondary"
99+
onClick={() => {
100+
setMessage(notificationMessage);
101+
setIsEditing(false);
102+
}}
103+
>
104+
Cancel
105+
</Button>
106+
<Button onClick={saveMessage}>Save</Button>
107+
</div>
108+
</div>
109+
) : (
110+
<div>
111+
<div className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-gray-50 dark:bg-gray-700 text-gray-700 dark:text-gray-300 min-h-[4rem]">
112+
{message || (
113+
<span className="text-gray-400 dark:text-gray-500 italic">
114+
Default message will be used
115+
</span>
116+
)}
117+
</div>
118+
<div className="mt-2 flex justify-end">
119+
<Button variant="secondary" onClick={() => setIsEditing(true)}>
120+
Edit Message
121+
</Button>
122+
</div>
123+
</div>
124+
)}
125+
</div>
126+
127+
{/* Preview section */}
128+
{isNotificationEnabled && (
129+
<div className="mt-4">
130+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Preview</label>
131+
<Alert type="warning" className="mb-0">
132+
<div className="flex items-center">
133+
<span className="font-semibold">Scheduled Maintenance:</span>
134+
<span className="ml-2">
135+
{message || "Maintenance is scheduled for this system. Please save your work."}
136+
</span>
137+
</div>
138+
</Alert>
139+
</div>
140+
)}
141+
</div>
142+
);
143+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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 { useMaintenanceNotification } from "../data/maintenance-notification-query";
10+
import { useMaintenanceMode } from "../data/maintenance-mode-query";
11+
12+
export const ScheduledMaintenanceBanner: FC = () => {
13+
const { isNotificationEnabled, notificationMessage } = useMaintenanceNotification();
14+
const { isMaintenanceMode } = useMaintenanceMode();
15+
16+
// As per requirement R4.5, if both maintenance mode and scheduled notification
17+
// are enabled, only show the maintenance mode notification
18+
if (isMaintenanceMode || !isNotificationEnabled) {
19+
return null;
20+
}
21+
22+
const defaultMessage = "Maintenance is scheduled for this system. Please save your work.";
23+
const displayMessage = notificationMessage || defaultMessage;
24+
25+
return (
26+
<Alert type="warning" className="mb-2">
27+
<div className="flex items-center">
28+
<span className="font-semibold">Scheduled Maintenance:</span>
29+
<span className="ml-2">{displayMessage}</span>
30+
</div>
31+
</Alert>
32+
);
33+
};

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

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
GetOrganizationResponse,
2323
GetOrganizationSettingsRequest,
2424
GetOrganizationSettingsResponse,
25+
GetMaintenanceNotificationRequest,
26+
GetMaintenanceNotificationResponse,
2527
JoinOrganizationRequest,
2628
JoinOrganizationResponse,
2729
ListOrganizationMembersRequest,
@@ -35,6 +37,8 @@ import {
3537
ResetOrganizationInvitationResponse,
3638
SetOrganizationMaintenanceModeRequest,
3739
SetOrganizationMaintenanceModeResponse,
40+
SetMaintenanceNotificationRequest,
41+
SetMaintenanceNotificationResponse,
3842
UpdateOrganizationMemberRequest,
3943
UpdateOrganizationMemberResponse,
4044
UpdateOrganizationRequest,
@@ -299,7 +303,7 @@ export class JsonRpcOrganizationClient implements PromiseClient<typeof Organizat
299303
}
300304
const result = await getGitpodService().server.getTeam(request.organizationId);
301305
return new GetOrganizationMaintenanceModeResponse({
302-
enabled: result.maintenanceMode,
306+
enabled: !!result.maintenanceMode,
303307
});
304308
}
305309

@@ -314,7 +318,40 @@ export class JsonRpcOrganizationClient implements PromiseClient<typeof Organizat
314318
maintenanceMode: request.enabled,
315319
});
316320
return new SetOrganizationMaintenanceModeResponse({
317-
enabled: result.maintenanceMode,
321+
enabled: !!result.maintenanceMode,
322+
});
323+
}
324+
325+
async getMaintenanceNotification(
326+
request: PartialMessage<GetMaintenanceNotificationRequest>,
327+
options?: CallOptions | undefined,
328+
): Promise<GetMaintenanceNotificationResponse> {
329+
if (!request.organizationId) {
330+
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "organizationId is required");
331+
}
332+
const result = await getGitpodService().server.getTeam(request.organizationId);
333+
return new GetMaintenanceNotificationResponse({
334+
isEnabled: result.maintenanceNotification?.enabled || false,
335+
message: result.maintenanceNotification?.message || "",
336+
});
337+
}
338+
339+
async setMaintenanceNotification(
340+
request: PartialMessage<SetMaintenanceNotificationRequest>,
341+
options?: CallOptions | undefined,
342+
): Promise<SetMaintenanceNotificationResponse> {
343+
if (!request.organizationId) {
344+
throw new ApplicationError(ErrorCodes.BAD_REQUEST, "organizationId is required");
345+
}
346+
const result = await getGitpodService().server.updateTeam(request.organizationId, {
347+
maintenanceNotification: {
348+
enabled: !!request.isEnabled,
349+
message: request.customMessage,
350+
},
351+
});
352+
return new SetMaintenanceNotificationResponse({
353+
isEnabled: result.maintenanceNotification?.enabled || false,
354+
message: result.maintenanceNotification?.message || "",
318355
});
319356
}
320357
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@ 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: Partial<Pick<Team, "name" | "maintenanceMode">>): Promise<Team>;
35+
updateTeam(
36+
teamId: string,
37+
team: Partial<Pick<Team, "name" | "maintenanceMode" | "maintenanceNotification">>,
38+
): Promise<Team>;
3639
addMemberToTeam(userId: string, teamId: string): Promise<"added" | "already_member">;
3740
setTeamMemberRole(userId: string, teamId: string, role: TeamMemberRole): Promise<void>;
3841
removeMemberFromTeam(userId: string, teamId: string): Promise<void>;

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

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

7-
import { Team } from "@gitpod/gitpod-protocol";
7+
import { MaintenanceNotification, Team } from "@gitpod/gitpod-protocol";
88
import { Entity, Column, PrimaryColumn } from "typeorm";
99
import { TypeORM } from "../typeorm";
1010

@@ -28,4 +28,7 @@ export class DBTeam implements Team {
2828

2929
@Column({ default: false })
3030
maintenanceMode?: boolean;
31+
32+
@Column("json", { nullable: true })
33+
maintenanceNotification?: MaintenanceNotification;
3134
}

0 commit comments

Comments
 (0)