Add workspace DDL lock env var and maintenance mode UI (#19130)

## Summary

- Add `WORKSPACE_SCHEMA_DDL_LOCKED` env-only boolean config variable
that blocks all workspace schema DDL changes when set to `true`. This is
intended for hot upgrades where logical replication cannot handle DDL
changes. Enforced at two chokepoints:
- `WorkspaceMigrationRunnerService.run` — blocks all metadata-driven DDL
(object/field/index CRUD, app sync/uninstall, standard app sync, upgrade
commands)
- `WorkspaceDataSourceService.createWorkspaceDBSchema` /
`deleteWorkspaceDBSchema` — blocks workspace creation (sign-up) and hard
deletion. Uses a dedicated `WorkspaceDataSourceException` (not
ForbiddenException)

- Add maintenance mode feature with Admin Panel UI and user-facing
banner:
- **Backend**: `MaintenanceModeService` stores maintenance window
(startAt, endAt, optional link) in `core.keyValuePair` as
`CONFIG_VARIABLE`. Validates endAt > startAt. Uses `GraphQLISODateTime`
scalar for date fields. Exposed via `clientConfig` REST endpoint and
admin GraphQL mutations (`setMaintenanceMode`, `clearMaintenanceMode`)
- **Admin Panel**: New "Maintenance Mode" section in Health tab with UTC
datetime pickers and activate/deactivate controls
- **Banner**: `InformationBannerMaintenance` displayed at the top of
`DefaultLayout` for all users, using Temporal API for timezone-aware
formatting with an optional "Learn more" link

These two features are **independent** — the DDL lock is controlled via
env var for operational use, while maintenance mode is a UI notification
mechanism controlled from the admin panel.
This commit is contained in:
Charles Bochet
2026-04-02 12:17:04 +02:00
committed by GitHub
parent 9438b9869c
commit 81f10c586f
78 changed files with 2343 additions and 713 deletions
@@ -12,6 +12,8 @@ import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConf
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
import { isCloudflareIntegrationEnabledState } from '@/client-config/states/isCloudflareIntegrationEnabledState';
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
import { maintenanceModeState } from '@/client-config/states/maintenanceModeState';
import { isEmailingDomainsEnabledState } from '@/client-config/states/isEmailingDomainsEnabledState';
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState';
@@ -117,6 +119,10 @@ export const useClientConfig = (): UseClientConfigResult => {
isClickHouseConfiguredState,
);
const setIsDDLLocked = useSetAtomState(isDDLLockedState);
const setMaintenanceMode = useSetAtomState(maintenanceModeState);
const setAppVersion = useSetAtomState(appVersionState);
const fetchClientConfig = useCallback(async () => {
@@ -195,6 +201,8 @@ export const useClientConfig = (): UseClientConfigResult => {
clientConfig?.isCloudflareIntegrationEnabled,
);
setIsClickHouseConfigured(clientConfig?.isClickHouseConfigured ?? false);
setIsDDLLocked(clientConfig?.isWorkspaceSchemaDDLLocked ?? false);
setMaintenanceMode(clientConfig?.maintenance ?? null);
} catch (err) {
const error =
err instanceof Error ? err : new Error('Failed to fetch client config');
@@ -229,7 +237,9 @@ export const useClientConfig = (): UseClientConfigResult => {
setIsEmailingDomainsEnabled,
setIsClickHouseConfigured,
setIsCloudflareIntegrationEnabled,
setIsDDLLocked,
setLabPublicFeatureFlags,
setMaintenanceMode,
setIsMicrosoftCalendarEnabled,
setIsMicrosoftMessagingEnabled,
setSentryConfig,
@@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isDDLLockedState = createAtomState<boolean>({
key: 'isDDLLocked',
defaultValue: false,
});
@@ -0,0 +1,13 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
import { type ClientConfigMaintenanceMode } from '~/generated-metadata/graphql';
type MaintenanceModeState = Pick<
ClientConfigMaintenanceMode,
'startAt' | 'endAt' | 'link'
>;
export const maintenanceModeState =
createAtomState<MaintenanceModeState | null>({
key: 'maintenanceModeState',
defaultValue: null,
});
@@ -4,6 +4,7 @@ import {
type Billing,
type Captcha,
type ClientAiModelConfig,
type ClientConfigMaintenanceMode,
type PublicFeatureFlag,
type Sentry,
type Support,
@@ -33,10 +34,12 @@ export type ClientConfig = {
isEmailingDomainsEnabled: boolean;
isCloudflareIntegrationEnabled: boolean;
isClickHouseConfigured: boolean;
isWorkspaceSchemaDDLLocked: boolean;
publicFeatureFlags: Array<PublicFeatureFlag>;
sentry: Sentry;
signInPrefilled: boolean;
support: Support;
isTwoFactorAuthenticationEnabled: boolean;
allowRequestsToTwentyIcons: boolean;
maintenance?: ClientConfigMaintenanceMode;
};