diff --git a/components/dashboard/src/AppNotifications.tsx b/components/dashboard/src/AppNotifications.tsx index f0a09d6a8d1be2..33e9a34f284667 100644 --- a/components/dashboard/src/AppNotifications.tsx +++ b/components/dashboard/src/AppNotifications.tsx @@ -18,6 +18,8 @@ import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution"; import { getGitpodService } from "./service/service"; import { useOrgBillingMode } from "./data/billing-mode/org-billing-mode-query"; import { Organization } from "@gitpod/public-api/lib/gitpod/v1/organization_pb"; +import { MaintenanceModeBanner } from "./org-admin/MaintenanceModeBanner"; +import { MaintenanceNotificationBanner } from "./org-admin/MaintenanceNotificationBanner"; const KEY_APP_DISMISSED_NOTIFICATIONS = "gitpod-app-notifications-dismissed"; const PRIVACY_POLICY_LAST_UPDATED = "2024-12-03"; @@ -208,29 +210,29 @@ export function AppNotifications() { setTopNotification(undefined); }, [topNotification, setTopNotification]); - if (!topNotification) { - return <>; - } - return (
- { - if (!topNotification.preventDismiss) { - dismissNotification(); - } else { - if (topNotification.onClose) { - topNotification.onClose(); + + + {topNotification && ( + { + if (!topNotification.preventDismiss) { + dismissNotification(); + } else { + if (topNotification.onClose) { + topNotification.onClose(); + } } - } - }} - showIcon={true} - className="flex rounded mb-2 w-full" - > - {topNotification.message} - + }} + showIcon={true} + className="flex rounded mb-2 w-full" + > + {topNotification.message} + + )}
); } diff --git a/components/dashboard/src/app/AppRoutes.tsx b/components/dashboard/src/app/AppRoutes.tsx index 135e39b023ef78..26b49f750e0f8f 100644 --- a/components/dashboard/src/app/AppRoutes.tsx +++ b/components/dashboard/src/app/AppRoutes.tsx @@ -79,6 +79,7 @@ const ConfigurationDetailPage = React.lazy( ); const PrebuildListPage = React.lazy(() => import(/* webpackPrefetch: true */ "../prebuilds/list/PrebuildListPage")); +const AdminPage = React.lazy(() => import(/* webpackPrefetch: true */ "../org-admin/AdminPage")); export const AppRoutes = () => { const hash = getURLHash(); @@ -205,6 +206,7 @@ export const AppRoutes = () => { {/* TODO: migrate other org settings pages underneath /settings prefix so we can utilize nested routes */} + diff --git a/components/dashboard/src/data/maintenance-mode/maintenance-mode-mutation.ts b/components/dashboard/src/data/maintenance-mode/maintenance-mode-mutation.ts new file mode 100644 index 00000000000000..6aeb8d2a86faa6 --- /dev/null +++ b/components/dashboard/src/data/maintenance-mode/maintenance-mode-mutation.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2025 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 { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useCurrentOrg } from "../organizations/orgs-query"; +import { organizationClient } from "../../service/public-api"; +import { maintenanceModeQueryKey } from "./maintenance-mode-query"; + +export interface SetMaintenanceModeArgs { + enabled: boolean; +} + +export const useSetMaintenanceModeMutation = () => { + const { data: org } = useCurrentOrg(); + const queryClient = useQueryClient(); + const organizationId = org?.id ?? ""; + + return useMutation({ + mutationFn: async ({ enabled }) => { + if (!organizationId) { + throw new Error("No organization selected"); + } + + try { + const response = await organizationClient.setOrganizationMaintenanceMode({ + organizationId, + enabled, + }); + return response.enabled; + } catch (error) { + console.error("Failed to set maintenance mode", error); + throw error; + } + }, + onSuccess: (result) => { + // Update the cache + queryClient.setQueryData(maintenanceModeQueryKey(organizationId), result); + }, + }); +}; diff --git a/components/dashboard/src/data/maintenance-mode/maintenance-mode-query.ts b/components/dashboard/src/data/maintenance-mode/maintenance-mode-query.ts new file mode 100644 index 00000000000000..b7513c97d0de30 --- /dev/null +++ b/components/dashboard/src/data/maintenance-mode/maintenance-mode-query.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2025 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 { useQuery } from "@tanstack/react-query"; +import { useCurrentOrg } from "../organizations/orgs-query"; +import { organizationClient } from "../../service/public-api"; + +export const maintenanceModeQueryKey = (orgId: string) => ["maintenance-mode", orgId]; + +export const useMaintenanceMode = () => { + const { data: org } = useCurrentOrg(); + + const { data: isMaintenanceMode = false, isLoading } = useQuery( + maintenanceModeQueryKey(org?.id || ""), + async () => { + if (!org?.id) return false; + + try { + const response = await organizationClient.getOrganizationMaintenanceMode({ + organizationId: org.id, + }); + return response.enabled; + } catch (error) { + console.error("Failed to fetch maintenance mode status", error); + return false; + } + }, + { + enabled: !!org?.id, + staleTime: 30 * 1000, // 30 seconds + refetchInterval: 60 * 1000, // 1 minute + }, + ); + + return { + isMaintenanceMode, + isLoading, + }; +}; diff --git a/components/dashboard/src/data/maintenance-mode/maintenance-notification-mutation.ts b/components/dashboard/src/data/maintenance-mode/maintenance-notification-mutation.ts new file mode 100644 index 00000000000000..9538e190794cf1 --- /dev/null +++ b/components/dashboard/src/data/maintenance-mode/maintenance-notification-mutation.ts @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2025 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 { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useCurrentOrg } from "../organizations/orgs-query"; +import { organizationClient } from "../../service/public-api"; +import { MaintenanceNotification } from "@gitpod/gitpod-protocol"; +import { maintenanceNotificationQueryKey } from "./maintenance-notification-query"; + +export interface SetMaintenanceNotificationArgs { + isEnabled: boolean; + customMessage?: string; +} + +export const useSetMaintenanceNotificationMutation = () => { + const { data: org } = useCurrentOrg(); + const queryClient = useQueryClient(); + const organizationId = org?.id ?? ""; + + return useMutation({ + mutationFn: async ({ isEnabled, customMessage }) => { + if (!organizationId) { + throw new Error("No organization selected"); + } + + try { + const response = await organizationClient.setMaintenanceNotification({ + organizationId, + isEnabled, + customMessage, + }); + + const result: MaintenanceNotification = { + enabled: response.isEnabled, + message: response.message, + }; + + return result; + } catch (error) { + console.error("Failed to set maintenance notification", error); + throw error; + } + }, + onSuccess: (result) => { + // Update the cache + queryClient.setQueryData(maintenanceNotificationQueryKey(organizationId), result); + }, + }); +}; diff --git a/components/dashboard/src/data/maintenance-mode/maintenance-notification-query.ts b/components/dashboard/src/data/maintenance-mode/maintenance-notification-query.ts new file mode 100644 index 00000000000000..9d32aba99e9887 --- /dev/null +++ b/components/dashboard/src/data/maintenance-mode/maintenance-notification-query.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2025 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 { useQuery } from "@tanstack/react-query"; +import { useCurrentOrg } from "../organizations/orgs-query"; +import { organizationClient } from "../../service/public-api"; +import { MaintenanceNotification } from "@gitpod/gitpod-protocol"; + +export const maintenanceNotificationQueryKey = (orgId: string) => ["maintenance-notification", orgId]; + +export const useMaintenanceNotification = () => { + const { data: org } = useCurrentOrg(); + + const { data, isLoading } = useQuery( + maintenanceNotificationQueryKey(org?.id || ""), + async () => { + if (!org?.id) return { enabled: false }; + + try { + const response = await organizationClient.getMaintenanceNotification({ + organizationId: org.id, + }); + return { + enabled: response.isEnabled, + message: response.message, + }; + } catch (error) { + console.error("Failed to fetch maintenance notification settings", error); + return { enabled: false }; + } + }, + { + enabled: !!org?.id, + staleTime: 30 * 1000, // 30 seconds + refetchInterval: 60 * 1000, // 1 minute + }, + ); + + return { + isNotificationEnabled: data?.enabled || false, + notificationMessage: data?.message, + isLoading, + }; +}; diff --git a/components/dashboard/src/menu/OrganizationSelector.tsx b/components/dashboard/src/menu/OrganizationSelector.tsx index 362f5bbdcc8c33..1d5fc433443b6e 100644 --- a/components/dashboard/src/menu/OrganizationSelector.tsx +++ b/components/dashboard/src/menu/OrganizationSelector.tsx @@ -127,6 +127,17 @@ export default function OrganizationSelector() { separator: false, link: "/settings", }); + + if (isOwner && isDedicated) { + // Add Admin link for owners + linkEntries.push({ + title: "Organization Administration", + customContent: Organization Administration, + active: false, + separator: false, + link: "/org-admin", + }); + } } } diff --git a/components/dashboard/src/org-admin/AdminPage.tsx b/components/dashboard/src/org-admin/AdminPage.tsx new file mode 100644 index 00000000000000..b1f66983a544c1 --- /dev/null +++ b/components/dashboard/src/org-admin/AdminPage.tsx @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2025 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 React, { useEffect } from "react"; +import { useHistory } from "react-router-dom"; +import { useUserLoader } from "../hooks/use-user-loader"; +import { useCurrentOrg } from "../data/organizations/orgs-query"; +import { useIsOwner } from "../data/organizations/members-query"; +import Header from "../components/Header"; +import { SpinnerLoader } from "../components/Loader"; +import { RunningWorkspacesCard } from "./RunningWorkspacesCard"; +import { MaintenanceModeCard } from "./MaintenanceModeCard"; +import { MaintenanceNotificationCard } from "./MaintenanceNotificationCard"; +import { Heading2 } from "@podkit/typography/Headings"; + +const AdminPage: React.FC = () => { + const history = useHistory(); + const { loading: userLoading } = useUserLoader(); + const { data: currentOrg, isLoading: orgLoading } = useCurrentOrg(); + const isOwner = useIsOwner(); + + useEffect(() => { + if (userLoading || orgLoading) { + return; + } + if (!isOwner) { + history.replace("/workspaces"); + } + }, [isOwner, userLoading, orgLoading, history, currentOrg?.id]); + + return ( +
+
+
+ Infrastructure Rollout + + {userLoading || + orgLoading || + (!isOwner && ( +
+ +
+ ))} + + {!orgLoading && !currentOrg && ( +
+ Could not load organization details. Please ensure you are part of an organization. +
+ )} + + {currentOrg && ( + <> + + + + + )} +
+
+ ); +}; + +export default AdminPage; diff --git a/components/dashboard/src/org-admin/MaintenanceModeBanner.tsx b/components/dashboard/src/org-admin/MaintenanceModeBanner.tsx new file mode 100644 index 00000000000000..7c33b26cf8594b --- /dev/null +++ b/components/dashboard/src/org-admin/MaintenanceModeBanner.tsx @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2025 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 { FC } from "react"; +import Alert from "../components/Alert"; +import { useMaintenanceMode } from "../data/maintenance-mode/maintenance-mode-query"; + +export const MaintenanceModeBanner: FC = () => { + const { isMaintenanceMode } = useMaintenanceMode(); + + if (!isMaintenanceMode) { + return null; + } + + return ( + +
+ System is in maintenance mode. + Starting new workspaces is currently disabled by your organization owner. +
+
+ ); +}; diff --git a/components/dashboard/src/org-admin/MaintenanceModeCard.tsx b/components/dashboard/src/org-admin/MaintenanceModeCard.tsx new file mode 100644 index 00000000000000..6b7e20b0f5bf3a --- /dev/null +++ b/components/dashboard/src/org-admin/MaintenanceModeCard.tsx @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2025 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 { FC } from "react"; +import { useToast } from "../components/toasts/Toasts"; +import { Button } from "@podkit/buttons/Button"; +import { useMaintenanceMode } from "../data/maintenance-mode/maintenance-mode-query"; +import { useSetMaintenanceModeMutation } from "../data/maintenance-mode/maintenance-mode-mutation"; +import { ConfigurationSettingsField } from "../repositories/detail/ConfigurationSettingsField"; +import { Heading3 } from "@podkit/typography/Headings"; + +export const MaintenanceModeCard: FC = () => { + const { isMaintenanceMode, isLoading } = useMaintenanceMode(); + const setMaintenanceModeMutation = useSetMaintenanceModeMutation(); + const toast = useToast(); + + const toggleMaintenanceMode = async () => { + try { + const newState = !isMaintenanceMode; + const result = await setMaintenanceModeMutation.mutateAsync({ enabled: newState }); + + toast.toast({ + message: `Maintenance mode ${result ? "enabled" : "disabled"}`, + type: "success", + }); + } catch (error) { + console.error("Failed to toggle maintenance mode", error); + toast.toast({ message: "Failed to toggle maintenance mode", type: "error" }); + } + }; + + return ( + +
+
+ Maintenance Mode +

+ When enabled, users cannot start new workspaces and a notification is displayed. +

+
+ +
+
+ ); +}; diff --git a/components/dashboard/src/org-admin/MaintenanceNotificationBanner.tsx b/components/dashboard/src/org-admin/MaintenanceNotificationBanner.tsx new file mode 100644 index 00000000000000..638fd4223a8474 --- /dev/null +++ b/components/dashboard/src/org-admin/MaintenanceNotificationBanner.tsx @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2025 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 { FC } from "react"; +import Alert from "../components/Alert"; +import { useMaintenanceNotification } from "../data/maintenance-mode/maintenance-notification-query"; +import { useMaintenanceMode } from "../data/maintenance-mode/maintenance-mode-query"; +import { DEFAULT_MESSAGE } from "./MaintenanceNotificationCard"; + +export const MaintenanceNotificationBanner: FC = () => { + const { isNotificationEnabled, notificationMessage } = useMaintenanceNotification(); + const { isMaintenanceMode } = useMaintenanceMode(); + + // if both maintenance mode and scheduled notification are enabled, + // only show the maintenance mode notification + if (isMaintenanceMode || !isNotificationEnabled) { + return null; + } + + const displayMessage = notificationMessage || DEFAULT_MESSAGE; + + return ( + +
+ Scheduled Maintenance: + {displayMessage} +
+
+ ); +}; diff --git a/components/dashboard/src/org-admin/MaintenanceNotificationCard.tsx b/components/dashboard/src/org-admin/MaintenanceNotificationCard.tsx new file mode 100644 index 00000000000000..882200d93448bf --- /dev/null +++ b/components/dashboard/src/org-admin/MaintenanceNotificationCard.tsx @@ -0,0 +1,144 @@ +/** + * Copyright (c) 2025 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 { FC, useState, useEffect } from "react"; +import { useToast } from "../components/toasts/Toasts"; +import { Button } from "@podkit/buttons/Button"; +import { useMaintenanceNotification } from "../data/maintenance-mode/maintenance-notification-query"; +import { useSetMaintenanceNotificationMutation } from "../data/maintenance-mode/maintenance-notification-mutation"; +import Alert from "../components/Alert"; +import { ConfigurationSettingsField } from "../repositories/detail/ConfigurationSettingsField"; +import { Heading3 } from "@podkit/typography/Headings"; + +export const DEFAULT_MESSAGE = + "On XX-YY-ZZZZ from HH:MM to HH:MM UTC. Workspaces will be stopped and cannot be started during this time."; + +export const MaintenanceNotificationCard: FC = () => { + const { isNotificationEnabled, notificationMessage, isLoading } = useMaintenanceNotification(); + const setMaintenanceNotificationMutation = useSetMaintenanceNotificationMutation(); + const [message, setMessage] = useState(notificationMessage || DEFAULT_MESSAGE); + const [isEditing, setIsEditing] = useState(false); + const toast = useToast(); + + // Update local state when the data from the API changes + useEffect(() => { + setMessage(notificationMessage || DEFAULT_MESSAGE); + }, [notificationMessage]); + + const toggleNotification = async () => { + try { + const newState = !isNotificationEnabled; + const result = await setMaintenanceNotificationMutation.mutateAsync({ + isEnabled: newState, + customMessage: message, + }); + + toast.toast({ + message: `Maintenance notification ${result.enabled ? "enabled" : "disabled"}`, + type: "success", + }); + + setIsEditing(false); + } catch (error) { + console.error("Failed to toggle maintenance notification", error); + toast.toast({ message: "Failed to toggle maintenance notification", type: "error" }); + } + }; + + const saveMessage = async () => { + try { + await setMaintenanceNotificationMutation.mutateAsync({ + isEnabled: isNotificationEnabled, + customMessage: message, + }); + + toast.toast({ + message: "Maintenance notification message updated", + type: "success", + }); + + setIsEditing(false); + } catch (error) { + console.error("Failed to update maintenance notification message", error); + toast.toast({ message: "Failed to update maintenance notification message", type: "error" }); + } + }; + + return ( + +
+
+ Scheduled Maintenance Notification +

+ Display a notification banner to inform users about upcoming maintenance. +

+
+ +
+ + {/* Message input section */} +
+ + {isEditing ? ( +
+