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:
+4
@@ -1,5 +1,7 @@
|
||||
import { SettingsAdminTabSkeletonLoader } from '@/settings/admin-panel/components/SettingsAdminTabSkeletonLoader';
|
||||
import { SettingsAdminHealthStatusListCard } from '@/settings/admin-panel/health-status/components/SettingsAdminHealthStatusListCard';
|
||||
import { SettingsAdminMaintenanceModeFetchEffect } from '@/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceModeFetchEffect';
|
||||
import { SettingsAdminMaintenanceMode } from '@/settings/admin-panel/health-status/maintenance-mode/components/SettingsAdminMaintenanceMode';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
@@ -22,6 +24,7 @@ export const SettingsAdminHealthStatus = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsAdminMaintenanceModeFetchEffect />
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Health Status`}
|
||||
@@ -32,6 +35,7 @@ export const SettingsAdminHealthStatus = () => {
|
||||
loading={loadingHealthStatus}
|
||||
/>
|
||||
</Section>
|
||||
<SettingsAdminMaintenanceMode />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
import { useMutation } from '@apollo/client/react';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useCallback } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { H2Title, IconLink, IconTool, Status } from 'twenty-ui/display';
|
||||
import { Card, CardContent, Section } from 'twenty-ui/layout';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { maintenanceModeState } from '@/client-config/states/maintenanceModeState';
|
||||
import { CLEAR_MAINTENANCE_MODE } from '@/settings/admin-panel/health-status/maintenance-mode/graphql/mutations/clearMaintenanceMode';
|
||||
import { SET_MAINTENANCE_MODE } from '@/settings/admin-panel/health-status/maintenance-mode/graphql/mutations/setMaintenanceMode';
|
||||
import { adminPanelMaintenanceModeState } from '@/settings/admin-panel/health-status/maintenance-mode/states/adminPanelMaintenanceModeState';
|
||||
import { SettingsDatePickerInput } from '@/settings/components/SettingsDatePickerInput';
|
||||
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { InputHint } from '@/ui/input/components/InputHint';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
|
||||
const StyledFormContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const StyledStatusRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
export const SettingsAdminMaintenanceMode = () => {
|
||||
const [adminPanelMaintenanceMode, setAdminPanelMaintenanceMode] =
|
||||
useAtomState(adminPanelMaintenanceModeState);
|
||||
|
||||
const maintenanceMode = useAtomStateValue(maintenanceModeState);
|
||||
const setMaintenanceMode = useSetAtomState(maintenanceModeState);
|
||||
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const [setMaintenanceModeMutation] = useMutation(SET_MAINTENANCE_MODE);
|
||||
const [clearMaintenanceModeMutation] = useMutation(CLEAR_MAINTENANCE_MODE);
|
||||
|
||||
const isEnabled = isDefined(adminPanelMaintenanceMode);
|
||||
|
||||
const startDate =
|
||||
isEnabled && isNonEmptyString(adminPanelMaintenanceMode.startAt)
|
||||
? new Date(adminPanelMaintenanceMode.startAt)
|
||||
: undefined;
|
||||
|
||||
const endDate =
|
||||
isEnabled && isNonEmptyString(adminPanelMaintenanceMode.endAt)
|
||||
? new Date(adminPanelMaintenanceMode.endAt)
|
||||
: undefined;
|
||||
|
||||
const hasValidDateRange =
|
||||
isDefined(startDate) &&
|
||||
isDefined(endDate) &&
|
||||
endDate.getTime() > startDate.getTime();
|
||||
|
||||
const isScheduled =
|
||||
isDefined(maintenanceMode) &&
|
||||
isNonEmptyString(maintenanceMode.startAt) &&
|
||||
isNonEmptyString(maintenanceMode.endAt);
|
||||
|
||||
const saveToBackend = useCallback(
|
||||
async (startAt: string, endAt: string, link?: string) => {
|
||||
if (!isNonEmptyString(startAt) || !isNonEmptyString(endAt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (new Date(endAt).getTime() <= new Date(startAt).getTime()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await setMaintenanceModeMutation({
|
||||
variables: { startAt, endAt, link },
|
||||
});
|
||||
|
||||
setMaintenanceMode({
|
||||
startAt,
|
||||
endAt,
|
||||
link,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
enqueueErrorSnackBar({
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t`Failed to set maintenance mode.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
[setMaintenanceModeMutation, setMaintenanceMode, enqueueErrorSnackBar],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
async (checked: boolean) => {
|
||||
if (checked) {
|
||||
setAdminPanelMaintenanceMode({ startAt: '', endAt: '' });
|
||||
} else {
|
||||
await clearMaintenanceModeMutation();
|
||||
setAdminPanelMaintenanceMode(null);
|
||||
setMaintenanceMode(null);
|
||||
}
|
||||
},
|
||||
[
|
||||
clearMaintenanceModeMutation,
|
||||
setAdminPanelMaintenanceMode,
|
||||
setMaintenanceMode,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDateChange = useCallback(
|
||||
(field: 'startAt' | 'endAt') => (value: Date | undefined) => {
|
||||
if (!isDefined(adminPanelMaintenanceMode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isoValue = isDefined(value) ? value.toISOString() : '';
|
||||
const nextState = { ...adminPanelMaintenanceMode, [field]: isoValue };
|
||||
|
||||
setAdminPanelMaintenanceMode(nextState);
|
||||
saveToBackend(
|
||||
nextState.startAt,
|
||||
nextState.endAt,
|
||||
isNonEmptyString(nextState.link) ? nextState.link : undefined,
|
||||
);
|
||||
},
|
||||
[adminPanelMaintenanceMode, setAdminPanelMaintenanceMode, saveToBackend],
|
||||
);
|
||||
|
||||
const handleLinkChange = useCallback(
|
||||
(value: string) => {
|
||||
if (!isDefined(adminPanelMaintenanceMode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAdminPanelMaintenanceMode({
|
||||
...adminPanelMaintenanceMode,
|
||||
link: value,
|
||||
});
|
||||
},
|
||||
[adminPanelMaintenanceMode, setAdminPanelMaintenanceMode],
|
||||
);
|
||||
|
||||
const handleLinkBlur = useCallback(() => {
|
||||
if (!isDefined(adminPanelMaintenanceMode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveToBackend(
|
||||
adminPanelMaintenanceMode.startAt,
|
||||
adminPanelMaintenanceMode.endAt,
|
||||
isNonEmptyString(adminPanelMaintenanceMode.link)
|
||||
? adminPanelMaintenanceMode.link
|
||||
: undefined,
|
||||
);
|
||||
}, [adminPanelMaintenanceMode, saveToBackend]);
|
||||
|
||||
const scheduledStartDate = isScheduled
|
||||
? new Date(maintenanceMode.startAt)
|
||||
: undefined;
|
||||
|
||||
const formattedStartDate = isDefined(scheduledStartDate)
|
||||
? scheduledStartDate.toLocaleDateString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
: '';
|
||||
|
||||
const toggleDescription = isScheduled
|
||||
? t`Planned for ${formattedStartDate}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Maintenance`}
|
||||
description={t`Schedule a maintenance window and notify all users`}
|
||||
/>
|
||||
<Card rounded>
|
||||
<SettingsOptionCardContentToggle
|
||||
Icon={IconTool}
|
||||
title={t`Maintenance mode`}
|
||||
description={toggleDescription}
|
||||
checked={isEnabled}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
{isEnabled && (
|
||||
<CardContent>
|
||||
<StyledFormContainer>
|
||||
<SettingsDatePickerInput
|
||||
label={t`Start date`}
|
||||
value={startDate}
|
||||
onChange={handleDateChange('startAt')}
|
||||
/>
|
||||
<div>
|
||||
<SettingsDatePickerInput
|
||||
label={t`End date`}
|
||||
value={endDate}
|
||||
onChange={handleDateChange('endAt')}
|
||||
/>
|
||||
{isDefined(startDate) &&
|
||||
isDefined(endDate) &&
|
||||
!hasValidDateRange && (
|
||||
<InputHint>{t`End date must be after start date.`}</InputHint>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<TextInput
|
||||
label={t`Link`}
|
||||
type="url"
|
||||
value={adminPanelMaintenanceMode.link ?? ''}
|
||||
onChange={handleLinkChange}
|
||||
onBlur={handleLinkBlur}
|
||||
LeftIcon={IconLink}
|
||||
placeholder="https://status.example.com"
|
||||
fullWidth
|
||||
/>
|
||||
<InputHint>
|
||||
{t`If there's no link, no button will appear.`}
|
||||
</InputHint>
|
||||
</div>
|
||||
{isScheduled && (
|
||||
<StyledStatusRow>
|
||||
<Status
|
||||
color="orange"
|
||||
text={t`Planned for ${formattedStartDate}`}
|
||||
/>
|
||||
</StyledStatusRow>
|
||||
)}
|
||||
</StyledFormContainer>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { useEffect } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { GET_MAINTENANCE_MODE } from '@/settings/admin-panel/health-status/maintenance-mode/graphql/queries/getMaintenanceMode';
|
||||
import { adminPanelMaintenanceModeState } from '@/settings/admin-panel/health-status/maintenance-mode/states/adminPanelMaintenanceModeState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { type GetMaintenanceModeQuery } from '~/generated-metadata/graphql';
|
||||
|
||||
export const SettingsAdminMaintenanceModeFetchEffect = () => {
|
||||
const { data } = useQuery<GetMaintenanceModeQuery>(GET_MAINTENANCE_MODE, {
|
||||
fetchPolicy: 'network-only',
|
||||
});
|
||||
|
||||
const setAdminPanelMaintenanceMode = useSetAtomState(
|
||||
adminPanelMaintenanceModeState,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const maintenanceMode = data?.getMaintenanceMode;
|
||||
|
||||
if (!isDefined(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAdminPanelMaintenanceMode(maintenanceMode ?? null);
|
||||
}, [data, setAdminPanelMaintenanceMode]);
|
||||
|
||||
return null;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const CLEAR_MAINTENANCE_MODE = gql`
|
||||
mutation ClearMaintenanceMode {
|
||||
clearMaintenanceMode
|
||||
}
|
||||
`;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const SET_MAINTENANCE_MODE = gql`
|
||||
mutation SetMaintenanceMode(
|
||||
$startAt: DateTime!
|
||||
$endAt: DateTime!
|
||||
$link: String
|
||||
) {
|
||||
setMaintenanceMode(startAt: $startAt, endAt: $endAt, link: $link)
|
||||
}
|
||||
`;
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const GET_MAINTENANCE_MODE = gql`
|
||||
query GetMaintenanceMode {
|
||||
getMaintenanceMode {
|
||||
startAt
|
||||
endAt
|
||||
link
|
||||
}
|
||||
}
|
||||
`;
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
type AdminPanelMaintenanceMode = {
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
link?: string | null;
|
||||
};
|
||||
|
||||
export const adminPanelMaintenanceModeState =
|
||||
createAtomState<AdminPanelMaintenanceMode | null>({
|
||||
key: 'adminPanelMaintenanceModeState',
|
||||
defaultValue: null,
|
||||
});
|
||||
Reference in New Issue
Block a user