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
@@ -1737,6 +1737,12 @@ enum FeatureFlagKey {
IS_DATASOURCE_MIGRATED
}
type ClientConfigMaintenanceMode {
startAt: DateTime!
endAt: DateTime!
link: String
}
type ClientConfig {
appVersion: String
authProviders: AuthProviders!
@@ -1765,6 +1771,8 @@ type ClientConfig {
calendarBookingPageId: String
isCloudflareIntegrationEnabled: Boolean!
isClickHouseConfigured: Boolean!
isWorkspaceSchemaDDLLocked: Boolean!
maintenance: ClientConfigMaintenanceMode
}
type UsageBreakdownItem {
@@ -1961,6 +1969,12 @@ type AdminPanelHealthServiceData {
queues: [AdminPanelWorkerQueueHealth!]
}
type MaintenanceMode {
startAt: DateTime!
endAt: DateTime!
link: String
}
type ModelsDevModelSuggestion {
modelId: String!
name: String!
@@ -3225,6 +3239,7 @@ type Query {
getModelsDevProviders: [ModelsDevProviderSuggestion!]!
getModelsDevSuggestions(providerType: String!): [ModelsDevModelSuggestion!]!
getAdminAiUsageByWorkspace(periodStart: DateTime, periodEnd: DateTime): [UsageBreakdownItem!]!
getMaintenanceMode: MaintenanceMode
getUsageAnalytics(input: UsageAnalyticsInput): UsageAnalytics!
getPostgresCredentials: PostgresCredentials
findManyPublicDomains: [PublicDomain!]!
@@ -3545,6 +3560,8 @@ type Mutation {
removeAiProvider(providerName: String!): Boolean!
addModelToProvider(providerName: String!, modelConfig: JSON!): Boolean!
removeModelFromProvider(providerName: String!, modelName: String!): Boolean!
setMaintenanceMode(startAt: DateTime!, endAt: DateTime!, link: String): Boolean!
clearMaintenanceMode: Boolean!
enablePostgresProxy: PostgresCredentials!
disablePostgresProxy: PostgresCredentials!
createPublicDomain(domain: String!): PublicDomain!
@@ -1424,6 +1424,13 @@ export interface PublicFeatureFlag {
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_AI_ENABLED' | 'IS_COMMAND_MENU_ITEM_ENABLED' | 'IS_MARKETPLACE_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAILING_DOMAIN_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_DRAFT_EMAIL_ENABLED' | 'IS_USAGE_ANALYTICS_ENABLED' | 'IS_RICH_TEXT_V1_MIGRATED' | 'IS_DIRECT_GRAPHQL_EXECUTION_ENABLED' | 'IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED' | 'IS_CONNECTED_ACCOUNT_MIGRATED' | 'IS_GRAPHQL_QUERY_TIMING_ENABLED' | 'IS_RECORD_TABLE_WIDGET_ENABLED' | 'IS_DATASOURCE_MIGRATED'
export interface ClientConfigMaintenanceMode {
startAt: Scalars['DateTime']
endAt: Scalars['DateTime']
link?: Scalars['String']
__typename: 'ClientConfigMaintenanceMode'
}
export interface ClientConfig {
appVersion?: Scalars['String']
authProviders: AuthProviders
@@ -1452,6 +1459,8 @@ export interface ClientConfig {
calendarBookingPageId?: Scalars['String']
isCloudflareIntegrationEnabled: Scalars['Boolean']
isClickHouseConfigured: Scalars['Boolean']
isWorkspaceSchemaDDLLocked: Scalars['Boolean']
maintenance?: ClientConfigMaintenanceMode
__typename: 'ClientConfig'
}
@@ -1621,6 +1630,13 @@ export interface AdminPanelHealthServiceData {
__typename: 'AdminPanelHealthServiceData'
}
export interface MaintenanceMode {
startAt: Scalars['DateTime']
endAt: Scalars['DateTime']
link?: Scalars['String']
__typename: 'MaintenanceMode'
}
export interface ModelsDevModelSuggestion {
modelId: Scalars['String']
name: Scalars['String']
@@ -2776,6 +2792,7 @@ export interface Query {
getModelsDevProviders: ModelsDevProviderSuggestion[]
getModelsDevSuggestions: ModelsDevModelSuggestion[]
getAdminAiUsageByWorkspace: UsageBreakdownItem[]
getMaintenanceMode?: MaintenanceMode
getUsageAnalytics: UsageAnalytics
getPostgresCredentials?: PostgresCredentials
findManyPublicDomains: PublicDomain[]
@@ -2991,6 +3008,8 @@ export interface Mutation {
removeAiProvider: Scalars['Boolean']
addModelToProvider: Scalars['Boolean']
removeModelFromProvider: Scalars['Boolean']
setMaintenanceMode: Scalars['Boolean']
clearMaintenanceMode: Scalars['Boolean']
enablePostgresProxy: PostgresCredentials
disablePostgresProxy: PostgresCredentials
createPublicDomain: PublicDomain
@@ -4508,6 +4527,14 @@ export interface PublicFeatureFlagGenqlSelection{
__scalar?: boolean | number
}
export interface ClientConfigMaintenanceModeGenqlSelection{
startAt?: boolean | number
endAt?: boolean | number
link?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ClientConfigGenqlSelection{
appVersion?: boolean | number
authProviders?: AuthProvidersGenqlSelection
@@ -4536,6 +4563,8 @@ export interface ClientConfigGenqlSelection{
calendarBookingPageId?: boolean | number
isCloudflareIntegrationEnabled?: boolean | number
isClickHouseConfigured?: boolean | number
isWorkspaceSchemaDDLLocked?: boolean | number
maintenance?: ClientConfigMaintenanceModeGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -4711,6 +4740,14 @@ export interface AdminPanelHealthServiceDataGenqlSelection{
__scalar?: boolean | number
}
export interface MaintenanceModeGenqlSelection{
startAt?: boolean | number
endAt?: boolean | number
link?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ModelsDevModelSuggestionGenqlSelection{
modelId?: boolean | number
name?: boolean | number
@@ -5966,6 +6003,7 @@ export interface QueryGenqlSelection{
getModelsDevProviders?: ModelsDevProviderSuggestionGenqlSelection
getModelsDevSuggestions?: (ModelsDevModelSuggestionGenqlSelection & { __args: {providerType: Scalars['String']} })
getAdminAiUsageByWorkspace?: (UsageBreakdownItemGenqlSelection & { __args?: {periodStart?: (Scalars['DateTime'] | null), periodEnd?: (Scalars['DateTime'] | null)} })
getMaintenanceMode?: MaintenanceModeGenqlSelection
getUsageAnalytics?: (UsageAnalyticsGenqlSelection & { __args?: {input?: (UsageAnalyticsInput | null)} })
getPostgresCredentials?: PostgresCredentialsGenqlSelection
findManyPublicDomains?: PublicDomainGenqlSelection
@@ -6200,6 +6238,8 @@ export interface MutationGenqlSelection{
removeAiProvider?: { __args: {providerName: Scalars['String']} }
addModelToProvider?: { __args: {providerName: Scalars['String'], modelConfig: Scalars['JSON']} }
removeModelFromProvider?: { __args: {providerName: Scalars['String'], modelName: Scalars['String']} }
setMaintenanceMode?: { __args: {startAt: Scalars['DateTime'], endAt: Scalars['DateTime'], link?: (Scalars['String'] | null)} }
clearMaintenanceMode?: boolean | number
enablePostgresProxy?: PostgresCredentialsGenqlSelection
disablePostgresProxy?: PostgresCredentialsGenqlSelection
createPublicDomain?: (PublicDomainGenqlSelection & { __args: {domain: Scalars['String']} })
@@ -7502,6 +7542,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const ClientConfigMaintenanceMode_possibleTypes: string[] = ['ClientConfigMaintenanceMode']
export const isClientConfigMaintenanceMode = (obj?: { __typename?: any } | null): obj is ClientConfigMaintenanceMode => {
if (!obj?.__typename) throw new Error('__typename is missing in "isClientConfigMaintenanceMode"')
return ClientConfigMaintenanceMode_possibleTypes.includes(obj.__typename)
}
const ClientConfig_possibleTypes: string[] = ['ClientConfig']
export const isClientConfig = (obj?: { __typename?: any } | null): obj is ClientConfig => {
if (!obj?.__typename) throw new Error('__typename is missing in "isClientConfig"')
@@ -7662,6 +7710,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const MaintenanceMode_possibleTypes: string[] = ['MaintenanceMode']
export const isMaintenanceMode = (obj?: { __typename?: any } | null): obj is MaintenanceMode => {
if (!obj?.__typename) throw new Error('__typename is missing in "isMaintenanceMode"')
return MaintenanceMode_possibleTypes.includes(obj.__typename)
}
const ModelsDevModelSuggestion_possibleTypes: string[] = ['ModelsDevModelSuggestion']
export const isModelsDevModelSuggestion = (obj?: { __typename?: any } | null): obj is ModelsDevModelSuggestion => {
if (!obj?.__typename) throw new Error('__typename is missing in "isModelsDevModelSuggestion"')
File diff suppressed because it is too large Load Diff