Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 22 additions & 20 deletions components/dashboard/src/AppNotifications.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { ScheduledMaintenanceBanner } from "./org-admin/ScheduledMaintenanceBanner";

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

if (!topNotification) {
return <></>;
}

return (
<div className="app-container pt-2">
<Alert
type={topNotification.type}
closable={topNotification.id !== "gitpod-classic-sunset"} // Only show close button if it's not the sunset notification
onClose={() => {
if (!topNotification.preventDismiss) {
dismissNotification();
} else {
if (topNotification.onClose) {
topNotification.onClose();
<MaintenanceModeBanner />
<ScheduledMaintenanceBanner />
{topNotification && (
<Alert
type={topNotification.type}
closable={topNotification.id !== "gitpod-classic-sunset"} // Only show close button if it's not the sunset notification
onClose={() => {
if (!topNotification.preventDismiss) {
dismissNotification();
} else {
if (topNotification.onClose) {
topNotification.onClose();
}
}
}
}}
showIcon={true}
className="flex rounded mb-2 w-full"
>
<span>{topNotification.message}</span>
</Alert>
}}
showIcon={true}
className="flex rounded mb-2 w-full"
>
<span>{topNotification.message}</span>
</Alert>
)}
</div>
);
}
Expand Down
2 changes: 2 additions & 0 deletions components/dashboard/src/app/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -205,6 +206,7 @@ export const AppRoutes = () => {
{/* TODO: migrate other org settings pages underneath /settings prefix so we can utilize nested routes */}
<Route exact path="/billing" component={TeamUsageBasedBilling} />
<Route exact path="/sso" component={SSO} />
<Route exact path="/org-admin" component={AdminPage} />

<Route exact path={`/prebuilds`} component={PrebuildListPage} />
<Route path="/prebuilds/:prebuildId" component={PrebuildDetailPage} />
Expand Down
64 changes: 64 additions & 0 deletions components/dashboard/src/data/maintenance-mode-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* 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, useQueryClient } 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 queryClient = useQueryClient();

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
},
);

const setMaintenanceMode = async (enabled: boolean) => {
if (!org?.id) return false;

try {
const response = await organizationClient.setOrganizationMaintenanceMode({
organizationId: org.id,
enabled,
});
const result = response.enabled;

// Update the cache
queryClient.setQueryData(maintenanceModeQueryKey(org.id), result);

return result;
} catch (error) {
console.error("Failed to set maintenance mode", error);
return false;
}
};

return {
isMaintenanceMode,
isLoading,
setMaintenanceMode,
};
};
77 changes: 77 additions & 0 deletions components/dashboard/src/data/maintenance-notification-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* 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, useQueryClient } 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 queryClient = useQueryClient();

const { data, isLoading } = useQuery<MaintenanceNotification>(
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
},
);

const setMaintenanceNotification = async (
isEnabled: boolean,
customMessage?: string,
): Promise<MaintenanceNotification> => {
if (!org?.id) return { enabled: false, message: "" };

try {
const response = await organizationClient.setMaintenanceNotification({
organizationId: org.id,
isEnabled,
customMessage,
});

const result: MaintenanceNotification = {
enabled: response.isEnabled,
message: response.message,
};

// Update the cache
queryClient.setQueryData(maintenanceNotificationQueryKey(org.id), result);

return result;
} catch (error) {
console.error("Failed to set maintenance notification", error);
return { enabled: false };
}
};

return {
isNotificationEnabled: data?.enabled || false,
notificationMessage: data?.message,
isLoading,
setMaintenanceNotification,
};
};
9 changes: 9 additions & 0 deletions components/dashboard/src/menu/OrganizationSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ export default function OrganizationSelector() {
}
// Show billing if user is an owner of current org
if (isOwner) {
// Add Admin link for owners
linkEntries.push({
title: "Admin",
customContent: <LinkEntry>Admin</LinkEntry>,
active: false,
separator: false,
link: "/org-admin",
});

if (billingMode?.mode === "usage-based") {
linkEntries.push({
title: "Billing",
Expand Down
68 changes: 68 additions & 0 deletions components/dashboard/src/org-admin/AdminPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* 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";

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 (
<div className="flex flex-col w-full">
<Header
title="Organization Administration"
subtitle="Manage your organization's infrastructure and settings."
/>
<div className="app-container py-6">
<h2 className="text-2xl font-semibold text-gray-800 dark:text-gray-100 mb-4">Infrastructure Rollout</h2>

{userLoading ||
orgLoading ||
(!isOwner && (
<div className="flex items-center justify-center w-full p-8">
<SpinnerLoader />
</div>
))}

{!orgLoading && !currentOrg && (
<div className="text-red-500 p-4 bg-red-100 dark:bg-red-900 border border-red-500 rounded-md">
Could not load organization details. Please ensure you are part of an organization.
</div>
)}

{currentOrg && (
<>
<MaintenanceNotificationCard />
<MaintenanceModeCard />
<RunningWorkspacesCard />
</>
)}
</div>
</div>
);
};

export default AdminPage;
26 changes: 26 additions & 0 deletions components/dashboard/src/org-admin/MaintenanceModeBanner.tsx
Original file line number Diff line number Diff line change
@@ -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-query";

export const MaintenanceModeBanner: FC = () => {
const { isMaintenanceMode } = useMaintenanceMode();

if (!isMaintenanceMode) {
return null;
}

return (
<Alert type="warning" className="mb-2">
<div className="flex items-center">
<span className="font-semibold">System is in maintenance mode.</span>
<span className="ml-2">Starting new workspaces is currently disabled.</span>
</div>
</Alert>
);
};
50 changes: 50 additions & 0 deletions components/dashboard/src/org-admin/MaintenanceModeCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* 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-query";

export const MaintenanceModeCard: FC = () => {
const { isMaintenanceMode, isLoading, setMaintenanceMode } = useMaintenanceMode();
const toast = useToast();

const toggleMaintenanceMode = async () => {
try {
const newState = !isMaintenanceMode;
const result = await setMaintenanceMode(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 (
<div className="bg-white dark:bg-gray-800 shadow-md rounded-lg p-4 mb-4">
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-semibold text-gray-700 dark:text-gray-200">Maintenance Mode</h3>
<p className="text-gray-500 dark:text-gray-400">
When enabled, users cannot start new workspaces and a notification is displayed.
</p>
</div>
<Button
variant={isMaintenanceMode ? "secondary" : "default"}
onClick={toggleMaintenanceMode}
disabled={isLoading}
>
{isLoading ? "Loading..." : isMaintenanceMode ? "Disable" : "Enable"}
</Button>
</div>
</div>
);
};
Loading
Loading