Compare commits

..

11 Commits

Author SHA1 Message Date
Jatinderjit Singh
36417a5f9e test: cover recurring schedule active window in IsActive
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 23:44:39 +05:30
Jatinderjit Singh
989b1252df test: cover fixed schedule active window in IsActive
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 23:22:36 +05:30
Jatinderjit Singh
51cb119f79 fix: make startTime a required field 2026-05-29 22:50:06 +05:30
Jatinderjit Singh
180a2c067f refactor: remove redundant code 2026-05-29 22:32:17 +05:30
Jatinderjit Singh
83351ca01d fix: use embedded timezone in start/end times
Accept times in any timezone, but always convert them to the selected
timezone. The conversion is required to correctly handle the recurring
maintenances for timezones where DST is involved.
2026-05-29 21:18:45 +05:30
Jatinderjit Singh
b11e2af392 fix: remove recurrence.startTime/endTime usages 2026-05-29 21:18:45 +05:30
Jatinderjit Singh
7f6e89ea22 fix: upcoming check for recurring maintenances 2026-05-29 21:18:45 +05:30
Jatinderjit Singh
8aeb9b5a77 refactor: code cleanup 2026-05-29 21:18:45 +05:30
Jatinderjit Singh
46c8f3579e refactor(alertmanager): drop start/end bounds from Recurrence
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 18:39:40 +05:30
Jatinderjit Singh
9ff045482f feat(alertmanager): migrate recurrence bounds to schedule level
Promote startTime/endTime from a planned maintenance's nested recurrence
up to the schedule level. For recurring maintenances the recurrence
bounds were the source of truth; the recurrence struct loses these
fields in the next step, so the values are moved while they can still be
read. The migration operates on raw JSON for that reason.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 17:21:00 +05:30
swapnil-signoz
910645516d chore: remove cloud integration service cascade delete constraint (#11480)
* chore: remove cloud integration service cascade delete constraint

* refactor: manually recreate table
2026-05-29 11:21:05 +00:00
33 changed files with 759 additions and 1054 deletions

View File

@@ -309,10 +309,6 @@ components:
properties:
duration:
type: string
endTime:
format: date-time
nullable: true
type: string
repeatOn:
items:
$ref: '#/components/schemas/AlertmanagertypesRepeatOn'
@@ -320,11 +316,7 @@ components:
type: array
repeatType:
$ref: '#/components/schemas/AlertmanagertypesRepeatType'
startTime:
format: date-time
type: string
required:
- startTime
- duration
- repeatType
type: object
@@ -358,6 +350,7 @@ components:
type: string
required:
- timezone
- startTime
type: object
AuthtypesAttributeMapping:
properties:

View File

@@ -162,21 +162,11 @@ export interface AlertmanagertypesRecurrenceDTO {
* @type string
*/
duration: string;
/**
* @type string,null
* @format date-time
*/
endTime?: string | null;
/**
* @type array,null
*/
repeatOn?: AlertmanagertypesRepeatOnDTO[] | null;
repeatType: AlertmanagertypesRepeatTypeDTO;
/**
* @type string
* @format date-time
*/
startTime: string;
}
export interface AlertmanagertypesScheduleDTO {
@@ -190,7 +180,7 @@ export interface AlertmanagertypesScheduleDTO {
* @type string
* @format date-time
*/
startTime?: string;
startTime: string;
/**
* @type string
*/

View File

@@ -14,9 +14,7 @@ export function ShiftHoldOverlayController({
const actions = createShortcutActions({
navigate: noop,
// Overlay is read-only — actions never fire — so we only need a no-op
// that satisfies the typed signature.
handleThemeChange: (): void => undefined,
handleThemeChange: noop,
});
const visible = useShiftHoldOverlay({

View File

@@ -116,35 +116,17 @@ jest.mock('hooks/useNotifications', (): unknown => ({
}));
// mock theme hook
//
// We spread jest.requireActual so additions to hooks/useDarkMode (new hooks,
// re-exports, contexts) keep working in this test without needing the mock to
// re-enumerate every export. We only override the hooks the palette actually
// calls.
jest.mock('hooks/useDarkMode', (): unknown => {
const actual = jest.requireActual('hooks/useDarkMode');
const useThemeModeMock = (): {
jest.mock('hooks/useDarkMode', (): unknown => ({
useThemeMode: (): {
setAutoSwitch: jest.Mock;
setTheme: jest.Mock;
toggleTheme: jest.Mock;
theme: string;
autoSwitch: boolean;
} => ({
setAutoSwitch: jest.fn(),
setTheme: jest.fn(),
toggleTheme: jest.fn(),
theme: 'dark',
autoSwitch: false,
});
return {
...actual,
__esModule: true,
default: useThemeModeMock,
useThemeMode: useThemeModeMock,
useIsDarkMode: (): boolean => true,
useSystemTheme: (): 'dark' | 'light' => 'dark',
};
});
}),
}));
// mock updateUserPreference API and react-query mutation
jest.mock('api/v1/user/preferences/name/update', (): jest.Mock => jest.fn());

View File

@@ -20,8 +20,6 @@ import {
useAIAssistantStore,
} from 'container/AIAssistant/store/useAIAssistantStore';
import { useThemeMode } from 'hooks/useDarkMode';
import { ThemeMode } from 'hooks/useDarkMode/constant';
import { useThemeSelection } from 'hooks/useDarkMode/useThemeSelection';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import history from 'lib/history';
import { ROLES as UserRole } from 'types/roles';
@@ -49,8 +47,7 @@ export function CmdKPalette({
}): JSX.Element | null {
const { open, setOpen } = useCmdK();
const { theme } = useThemeMode();
const selectTheme = useThemeSelection();
const { setAutoSwitch, setTheme, theme } = useThemeMode();
const location = useLocation();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const startNewConversation = useAIAssistantStore(
@@ -83,12 +80,14 @@ export function CmdKPalette({
useEffect(cmdKEffect, [setOpen]);
function handleThemeChange(value: ThemeMode): void {
function handleThemeChange(value: string): void {
logEvent('Account Settings: Theme Changed', { theme: value });
// Close the palette inside the same flushSync batch as the theme change
// so its dismissal is part of the captured "new" frame of the wipe;
// otherwise the dialog would be visible in both snapshots and flicker.
selectTheme(value, () => setOpen(false));
if (value === 'auto') {
setAutoSwitch(true);
} else {
setAutoSwitch(false);
setTheme(value);
}
}
function onClickHandler(key: string): void {

View File

@@ -1,7 +1,7 @@
import React from 'react';
import ROUTES from 'constants/routes';
import { GlobalShortcutsName } from 'constants/shortcuts/globalShortcuts';
import { THEME_MODE, ThemeMode } from 'hooks/useDarkMode/constant';
import { THEME_MODE } from 'hooks/useDarkMode/constant';
import {
BarChart,
BellDot,
@@ -34,7 +34,7 @@ export type CmdAction = {
type ActionDeps = {
navigate: (path: string) => void;
handleThemeChange: (mode: ThemeMode) => void;
handleThemeChange: (mode: string) => void;
/**
* Provided only when the AI Assistant feature is available for the current
* tenant. When present, the palette surfaces an "Open AI Assistant" entry

View File

@@ -12,8 +12,6 @@ import APIError from 'types/api/error';
import { toast } from '@signozhq/ui/sonner';
const toggleThemeFunction = jest.fn();
const setThemeFunction = jest.fn();
const setAutoSwitchFunction = jest.fn();
const logEventFunction = jest.fn();
const copyToClipboardFn = jest.fn();
const editUserFn = jest.fn();
@@ -58,11 +56,9 @@ jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: jest.fn(() => true),
useSystemTheme: jest.fn(() => 'dark'),
default: jest.fn(() => ({
theme: 'dark',
setTheme: setThemeFunction,
toggleTheme: toggleThemeFunction,
autoSwitch: false,
setAutoSwitch: setAutoSwitchFunction,
setAutoSwitch: jest.fn(),
})),
}));
@@ -138,8 +134,7 @@ describe('MySettings Flows', () => {
fireEvent.click(lightOption);
await waitFor(() => {
expect(setAutoSwitchFunction).toHaveBeenCalledWith(false);
expect(setThemeFunction).toHaveBeenCalledWith('light');
expect(toggleThemeFunction).toHaveBeenCalled();
expect(logEventFunction).toHaveBeenCalledWith(
'Account Settings: Theme Changed',
{
@@ -147,10 +142,6 @@ describe('MySettings Flows', () => {
},
);
});
// Lock in that the new selectTheme flow does not call toggleTheme;
// otherwise we'd double-flip on top of the explicit setTheme call.
expect(toggleThemeFunction).not.toHaveBeenCalled();
});
});

View File

@@ -9,8 +9,6 @@ import updateUserPreference from 'api/v1/user/preferences/name/update';
import { AxiosError } from 'axios';
import { USER_PREFERENCES } from 'constants/userPreferences';
import useThemeMode, { useIsDarkMode, useSystemTheme } from 'hooks/useDarkMode';
import { THEME_MODE, ThemeMode } from 'hooks/useDarkMode/constant';
import { useThemeSelection } from 'hooks/useDarkMode/useThemeSelection';
import { useNotifications } from 'hooks/useNotifications';
import { MonitorCog, Moon, Sun } from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
@@ -26,10 +24,9 @@ import './MySettings.styles.scss';
function MySettings(): JSX.Element {
const isDarkMode = useIsDarkMode();
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { autoSwitch } = useThemeMode();
const { toggleTheme, autoSwitch, setAutoSwitch } = useThemeMode();
const systemTheme = useSystemTheme();
const { notifications } = useNotifications();
const selectTheme = useThemeSelection();
const [sideNavPinned, setSideNavPinned] = useState(false);
@@ -62,7 +59,7 @@ function MySettings(): JSX.Element {
<Moon data-testid="dark-theme-icon" size={12} /> Dark{' '}
</div>
),
value: THEME_MODE.DARK,
value: 'dark',
},
{
label: (
@@ -71,7 +68,7 @@ function MySettings(): JSX.Element {
<Badge color="robin">Beta</Badge>
</div>
),
value: THEME_MODE.LIGHT,
value: 'light',
},
{
label: (
@@ -79,31 +76,46 @@ function MySettings(): JSX.Element {
<MonitorCog size={12} data-testid="auto-theme-icon" /> System{' '}
</div>
),
value: THEME_MODE.SYSTEM,
value: 'auto',
},
];
const [theme, setTheme] = useState(() => {
if (autoSwitch) {
return THEME_MODE.SYSTEM;
return 'auto';
}
return isDarkMode ? THEME_MODE.DARK : THEME_MODE.LIGHT;
return isDarkMode ? 'dark' : 'light';
});
const handleThemeChange = (value: string): void => {
// ToggleGroupSimple items above are all THEME_MODE values, so narrowing
// the string here is safe.
const mode = value as ThemeMode;
logEvent('Account Settings: Theme Changed', { theme: mode });
selectTheme(mode);
logEvent('Account Settings: Theme Changed', {
theme: value,
});
setTheme(value);
if (value === 'auto') {
setAutoSwitch(true);
} else {
setAutoSwitch(false);
// Only toggle if the current theme is different from the target
const targetIsDark = value === 'dark';
if (targetIsDark !== isDarkMode) {
toggleTheme();
}
}
};
useEffect(() => {
if (autoSwitch) {
setTheme(THEME_MODE.SYSTEM);
setTheme('auto');
return;
}
setTheme(isDarkMode ? THEME_MODE.DARK : THEME_MODE.LIGHT);
if (isDarkMode) {
setTheme('dark');
} else {
setTheme('light');
}
}, [autoSwitch, isDarkMode]);
const handleSideNavPinnedChange = (checked: boolean): void => {

View File

@@ -152,6 +152,11 @@ export function PlannedDowntimeForm(
const saveHandler = useCallback(
async (values: PlannedDowntimeFormData) => {
const { startTime, timezone } = values;
if (!startTime || !timezone) {
// unreachable
return;
}
const data: AlertmanagertypesPostablePlannedMaintenanceDTO = {
alertIds:
values.alertRuleScope === 'all'
@@ -162,9 +167,9 @@ export function PlannedDowntimeForm(
name: values.name,
scope: values.scope,
schedule: {
startTime: values.startTime?.format(),
startTime: startTime.format(),
endTime: values.endTime?.format(),
timezone: values.timezone!,
timezone,
recurrence: values.recurrence,
},
};
@@ -201,25 +206,17 @@ export function PlannedDowntimeForm(
],
);
const onFinish = async (values: PlannedDowntimeFormData): Promise<void> => {
const { recurrence } = values;
const recurrenceData =
!recurrence ||
recurrence.repeatType === recurrenceOptions.doesNotRepeat.value
? undefined
: {
duration: recurrence.duration
? `${recurrence.duration}${durationUnit}`
: '',
startTime: values.startTime!.format(),
endTime: values.endTime?.format(),
repeatOn: recurrence.repeatOn,
repeatType: recurrence.repeatType,
};
const rec = values.recurrence;
const recurrence =
rec && rec.repeatType !== recurrenceOptions.doesNotRepeat.value
? {
duration: `${rec.duration}${durationUnit}`,
repeatOn: rec.repeatOn,
repeatType: rec.repeatType,
}
: undefined;
await saveHandler({
...values,
recurrence: recurrenceData,
});
await saveHandler({ ...values, recurrence });
};
const handleFormData = (data: Partial<PlannedDowntimeFormData>): void => {
@@ -276,9 +273,6 @@ export function PlannedDowntimeForm(
const formattedInitialValues = useMemo((): PlannedDowntimeFormData => {
const { schedule } = initialValues;
const startTime = schedule?.recurrence?.startTime || schedule?.startTime;
const endTime = schedule?.recurrence?.endTime || schedule?.endTime;
const initialAlertIds = initialValues.alertIds || [];
return {
@@ -286,8 +280,12 @@ export function PlannedDowntimeForm(
alertRuleScope:
isEditMode && initialAlertIds.length === 0 ? 'all' : 'specific',
alertRules: getAlertOptionsFromIds(initialAlertIds, alertOptions),
startTime: startTime ? dayjs(startTime).tz(schedule.timezone) : null,
endTime: endTime ? dayjs(endTime).tz(schedule.timezone) : null,
startTime: schedule?.startTime
? dayjs(schedule.startTime).tz(schedule.timezone)
: null,
endTime: schedule?.endTime
? dayjs(schedule.endTime).tz(schedule.timezone)
: null,
recurrence: {
...schedule?.recurrence,
repeatType: !isScheduleRecurring(schedule)

View File

@@ -142,7 +142,6 @@ export function CollapseListContent({
updated_by_name?: string;
alertOptions?: DefaultOptionType[];
}): JSX.Element {
const repeats = schedule?.recurrence;
const renderItems = (title: string, value: ReactNode): JSX.Element => (
<div className="render-item-collapse-list">
<Typography>{title}</Typography>
@@ -193,10 +192,7 @@ export function CollapseListContent({
'Timezone',
<Typography>{schedule?.timezone || '-'}</Typography>,
)}
{renderItems(
'Repeats',
<Typography>{recurrenceInfo(repeats, schedule?.timezone)}</Typography>,
)}
{renderItems('Repeats', <Typography>{recurrenceInfo(schedule)}</Typography>)}
{renderItems(
'Alerts silenced',
alertOptions?.length ? (

View File

@@ -6,7 +6,7 @@ import type {
DeleteDowntimeScheduleByIDPathParameters,
RenderErrorResponseDTO,
AlertmanagertypesPlannedMaintenanceDTO,
AlertmanagertypesRecurrenceDTO,
AlertmanagertypesScheduleDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import { AxiosError } from 'axios';
@@ -66,14 +66,17 @@ export const getAlertOptionsFromIds = (
);
export const recurrenceInfo = (
recurrence?: AlertmanagertypesRecurrenceDTO | null,
timezone?: string,
schedule?: AlertmanagertypesScheduleDTO | null,
): string => {
if (!schedule) {
return 'No';
}
const { startTime, endTime, timezone, recurrence } = schedule;
if (!recurrence) {
return 'No';
}
const { startTime, duration, repeatOn, repeatType, endTime } = recurrence;
const { duration, repeatOn, repeatType } = recurrence;
const formattedStartTime = startTime
? formatDateTime(startTime, timezone)
@@ -95,7 +98,7 @@ export const defaultInitialValues: Partial<AlertmanagertypesPlannedMaintenanceDT
timezone: '',
endTime: undefined,
recurrence: undefined,
startTime: undefined,
startTime: '',
},
alertIds: [],
createdAt: undefined,

View File

@@ -11,7 +11,7 @@ export const buildSchedule = (
schedule: Partial<AlertmanagertypesScheduleDTO>,
): AlertmanagertypesScheduleDTO => ({
timezone: schedule?.timezone ?? '',
startTime: schedule?.startTime,
startTime: schedule?.startTime ?? '',
endTime: schedule?.endTime,
recurrence: schedule?.recurrence,
});

View File

@@ -1,164 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { useThemeSelection } from '../useThemeSelection';
const setThemeMock = jest.fn();
const setAutoSwitchMock = jest.fn();
let themeValue = 'dark';
let systemThemeValue: 'dark' | 'light' = 'light';
jest.mock('hooks/useDarkMode', () => ({
__esModule: true,
default: (): {
theme: string;
setTheme: jest.Mock;
setAutoSwitch: jest.Mock;
toggleTheme: jest.Mock;
autoSwitch: boolean;
} => ({
theme: themeValue,
setTheme: setThemeMock,
setAutoSwitch: setAutoSwitchMock,
toggleTheme: jest.fn(),
autoSwitch: false,
}),
useThemeMode: (): {
theme: string;
setTheme: jest.Mock;
setAutoSwitch: jest.Mock;
toggleTheme: jest.Mock;
autoSwitch: boolean;
} => ({
theme: themeValue,
setTheme: setThemeMock,
setAutoSwitch: setAutoSwitchMock,
toggleTheme: jest.fn(),
autoSwitch: false,
}),
useSystemTheme: (): 'dark' | 'light' => systemThemeValue,
useIsDarkMode: (): boolean => themeValue === 'dark',
}));
const canAnimateMock = jest.fn();
const runTransitionMock = jest.fn();
jest.mock('utils/themeTransition', () => ({
__esModule: true,
canAnimateThemeTransition: (): boolean => canAnimateMock(),
runThemeTransition: (cb: () => void): void => runTransitionMock(cb),
}));
describe('useThemeSelection', () => {
beforeEach(() => {
jest.clearAllMocks();
themeValue = 'dark';
systemThemeValue = 'light';
canAnimateMock.mockReturnValue(false);
// Default behaviour: invoke the applyChange callback synchronously.
runTransitionMock.mockImplementation((cb: () => void) => cb());
});
it('applies an explicit light theme without auto-switch', () => {
themeValue = 'dark';
const { result } = renderHook(() => useThemeSelection());
act(() => result.current('light'));
expect(setAutoSwitchMock).toHaveBeenCalledWith(false);
expect(setThemeMock).toHaveBeenCalledWith('light');
});
it('applies an explicit dark theme without auto-switch', () => {
themeValue = 'light';
const { result } = renderHook(() => useThemeSelection());
act(() => result.current('dark'));
expect(setAutoSwitchMock).toHaveBeenCalledWith(false);
expect(setThemeMock).toHaveBeenCalledWith('dark');
});
it('SYSTEM with a light system preference resolves to setTheme("light") + auto on', () => {
themeValue = 'dark';
systemThemeValue = 'light';
const { result } = renderHook(() => useThemeSelection());
act(() => result.current('auto'));
expect(setAutoSwitchMock).toHaveBeenCalledWith(true);
// Explicit resolved value is what keeps the wipe snapshot accurate;
// see the comment in useThemeSelection for the failure mode.
expect(setThemeMock).toHaveBeenCalledWith('light');
});
it('SYSTEM with a dark system preference resolves to setTheme("dark") + auto on', () => {
themeValue = 'light';
systemThemeValue = 'dark';
const { result } = renderHook(() => useThemeSelection());
act(() => result.current('auto'));
expect(setAutoSwitchMock).toHaveBeenCalledWith(true);
expect(setThemeMock).toHaveBeenCalledWith('dark');
});
it('invokes onApplied inside the same batch, after the state mutations', () => {
themeValue = 'dark';
const onApplied = jest.fn();
const { result } = renderHook(() => useThemeSelection());
act(() => result.current('light', onApplied));
expect(onApplied).toHaveBeenCalledTimes(1);
expect(setThemeMock.mock.invocationCallOrder[0]).toBeLessThan(
onApplied.mock.invocationCallOrder[0],
);
});
it('routes through runThemeTransition when the dark↔light state actually flips', () => {
themeValue = 'dark';
canAnimateMock.mockReturnValue(true);
const { result } = renderHook(() => useThemeSelection());
act(() => result.current('light'));
expect(runTransitionMock).toHaveBeenCalledTimes(1);
expect(setThemeMock).toHaveBeenCalledWith('light');
});
it('skips runThemeTransition when no dark↔light flip happens', () => {
themeValue = 'dark';
canAnimateMock.mockReturnValue(true);
const { result } = renderHook(() => useThemeSelection());
act(() => result.current('dark'));
expect(runTransitionMock).not.toHaveBeenCalled();
// applyChange still ran inline.
expect(setThemeMock).toHaveBeenCalledWith('dark');
});
it('skips runThemeTransition when SYSTEM resolves to the currently-rendered theme', () => {
themeValue = 'dark';
systemThemeValue = 'dark';
canAnimateMock.mockReturnValue(true);
const { result } = renderHook(() => useThemeSelection());
act(() => result.current('auto'));
expect(runTransitionMock).not.toHaveBeenCalled();
expect(setAutoSwitchMock).toHaveBeenCalledWith(true);
expect(setThemeMock).toHaveBeenCalledWith('dark');
});
it('skips runThemeTransition when capability check is false even if the theme flips', () => {
themeValue = 'dark';
canAnimateMock.mockReturnValue(false);
const { result } = renderHook(() => useThemeSelection());
act(() => result.current('light'));
expect(runTransitionMock).not.toHaveBeenCalled();
expect(setThemeMock).toHaveBeenCalledWith('light');
});
});

View File

@@ -2,6 +2,4 @@ export const THEME_MODE = {
LIGHT: 'light',
DARK: 'dark',
SYSTEM: 'auto',
} as const;
export type ThemeMode = (typeof THEME_MODE)[keyof typeof THEME_MODE];
};

View File

@@ -18,13 +18,7 @@ import { LOCALSTORAGE } from 'constants/localStorage';
import { THEME_MODE } from './constant';
export const ThemeContext = createContext<{
theme: string;
toggleTheme: () => void;
autoSwitch: boolean;
setAutoSwitch: Dispatch<SetStateAction<boolean>>;
setTheme: Dispatch<SetStateAction<string>>;
}>({
export const ThemeContext = createContext({
theme: THEME_MODE.DARK,
toggleTheme: (): void => {},
autoSwitch: false,

View File

@@ -1,67 +0,0 @@
import { useCallback } from 'react';
import {
canAnimateThemeTransition,
runThemeTransition,
} from 'utils/themeTransition';
import useThemeMode, { useSystemTheme } from './index';
import { THEME_MODE, ThemeMode } from './constant';
type SelectTheme = (value: ThemeMode, onApplied?: () => void) => void;
// Centralises the "apply a theme selection" flow used by MySettings and the
// command palette: figures out whether the visible (dark↔light) theme is
// actually flipping, applies the state change, and — when capable — wraps the
// change in a left→right view-transition wipe.
//
// `value` is one of THEME_MODE.{LIGHT,DARK,SYSTEM}; `onApplied` runs inside the
// same flushSync batch as the theme change (useful for, e.g., closing the
// command palette so its dismissal is part of the captured "new" snapshot).
export function useThemeSelection(): SelectTheme {
const { theme, setTheme, setAutoSwitch } = useThemeMode();
const systemTheme = useSystemTheme();
return useCallback<SelectTheme>(
(value, onApplied) => {
const currentIsDark = theme === THEME_MODE.DARK;
// When switching to SYSTEM, the visible theme flips iff the OS preference
// differs from what we're currently rendering. For explicit LIGHT/DARK,
// resolvedTargetIsDark is just (value === DARK).
const resolvedTargetIsDark =
value === THEME_MODE.SYSTEM
? systemTheme === THEME_MODE.DARK
: value === THEME_MODE.DARK;
const isSystem = value === THEME_MODE.SYSTEM;
// Always push the resolved LIGHT/DARK through setTheme synchronously so
// the View Transition snapshot reflects the new theme. If we relied on
// ThemeProvider's effect (setAutoSwitch → re-render → effect →
// setThemeState), the flip wouldn't be guaranteed to run inside this
// flushSync batch and the wipe would capture old → old, then snap.
const resolvedTheme = resolvedTargetIsDark
? THEME_MODE.DARK
: THEME_MODE.LIGHT;
// runThemeTransition needs a zero-arg callback, so this closure is
// unavoidable. It allocates once per selection — cheap enough that
// micro-optimising it would just obscure the flow.
const apply = (): void => {
setAutoSwitch(isSystem);
setTheme(resolvedTheme);
onApplied?.();
};
const willFlipDarkMode = resolvedTargetIsDark !== currentIsDark;
if (!willFlipDarkMode || !canAnimateThemeTransition()) {
apply();
return;
}
runThemeTransition(apply);
},
[theme, systemTheme, setTheme, setAutoSwitch],
);
}
export default useThemeSelection;

View File

@@ -828,22 +828,3 @@ body.ai-assistant-panel-open {
--tab-list-primary-gap: 12px;
--radius-2: 4px;
}
// Scoped to .theme-wipe-active (toggled on <html> in runThemeTransition) so
// these overrides don't leak into any unrelated view transitions added later.
// We disable the default UA crossfade so the JS-driven clip-path wipe is the
// only visible effect, and stack the new snapshot above the old.
html.theme-wipe-active {
&::view-transition-old(root),
&::view-transition-new(root) {
animation: none;
}
&::view-transition-old(root) {
z-index: 1;
}
&::view-transition-new(root) {
z-index: 2;
}
}

View File

@@ -1,193 +0,0 @@
import {
canAnimateThemeTransition,
runThemeTransition,
THEME_WIPE_ACTIVE_CLASS,
} from '../themeTransition';
type StartVT = (cb: () => void) => {
ready: Promise<void>;
finished: Promise<void>;
};
const installStartViewTransition = (impl?: StartVT): jest.Mock => {
const defaultImpl: StartVT = (cb) => {
cb();
return { ready: Promise.resolve(), finished: Promise.resolve() };
};
const fn = jest.fn(impl ?? defaultImpl);
Object.defineProperty(document, 'startViewTransition', {
configurable: true,
writable: true,
value: fn,
});
return fn;
};
const removeStartViewTransition = (): void => {
Object.defineProperty(document, 'startViewTransition', {
configurable: true,
writable: true,
value: undefined,
});
};
const setReducedMotion = (matches: boolean): void => {
(window.matchMedia as jest.Mock) = jest
.fn()
.mockImplementation((query: string) => ({
matches: query === '(prefers-reduced-motion: reduce)' ? matches : false,
media: query,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
};
describe('canAnimateThemeTransition', () => {
afterEach(() => {
removeStartViewTransition();
});
it('returns false when document.startViewTransition is unavailable', () => {
removeStartViewTransition();
setReducedMotion(false);
expect(canAnimateThemeTransition()).toBe(false);
});
it('returns false when prefers-reduced-motion is reduce', () => {
installStartViewTransition();
setReducedMotion(true);
expect(canAnimateThemeTransition()).toBe(false);
});
it('returns true when API is supported and motion is allowed', () => {
installStartViewTransition();
setReducedMotion(false);
expect(canAnimateThemeTransition()).toBe(true);
});
});
describe('runThemeTransition', () => {
afterEach(() => {
removeStartViewTransition();
document.documentElement.classList.remove(THEME_WIPE_ACTIVE_CLASS);
});
it('falls back to running applyChange directly when API is missing', () => {
removeStartViewTransition();
const applyChange = jest.fn();
runThemeTransition(applyChange);
expect(applyChange).toHaveBeenCalledTimes(1);
expect(
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
).toBe(false);
});
it('invokes startViewTransition and runs applyChange inside its callback', () => {
const startVT = installStartViewTransition();
const applyChange = jest.fn();
runThemeTransition(applyChange);
expect(startVT).toHaveBeenCalledTimes(1);
expect(applyChange).toHaveBeenCalledTimes(1);
});
it('toggles the wipe-active class on <html> for the lifetime of the transition', async () => {
let resolveFinished: () => void = (): void => {};
installStartViewTransition((cb) => {
cb();
return {
ready: Promise.resolve(),
finished: new Promise<void>((resolve) => {
resolveFinished = resolve;
}),
};
});
runThemeTransition(() => undefined);
expect(
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
).toBe(true);
resolveFinished();
await Promise.resolve();
await Promise.resolve();
expect(
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
).toBe(false);
});
it('keeps the wipe-active class through overlapping transitions', async () => {
let resolveA: () => void = (): void => {};
let resolveB: () => void = (): void => {};
let callIndex = 0;
installStartViewTransition((cb) => {
cb();
callIndex += 1;
if (callIndex === 1) {
return {
ready: Promise.resolve(),
finished: new Promise<void>((resolve) => {
resolveA = resolve;
}),
};
}
return {
ready: Promise.resolve(),
finished: new Promise<void>((resolve) => {
resolveB = resolve;
}),
};
});
runThemeTransition(() => undefined);
runThemeTransition(() => undefined);
expect(
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
).toBe(true);
// First transition finishes — class must stay because B is still in flight.
resolveA();
await Promise.resolve();
await Promise.resolve();
expect(
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
).toBe(true);
resolveB();
await Promise.resolve();
await Promise.resolve();
expect(
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
).toBe(false);
});
it('falls back to applyChange and releases the class when startViewTransition throws before its callback runs', () => {
installStartViewTransition(() => {
throw new Error('boom');
});
const applyChange = jest.fn();
runThemeTransition(applyChange);
expect(applyChange).toHaveBeenCalledTimes(1);
expect(
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
).toBe(false);
});
it('does not double-invoke applyChange when startViewTransition throws after its callback runs', () => {
installStartViewTransition((cb) => {
cb();
throw new Error('post-cb');
});
const applyChange = jest.fn();
runThemeTransition(applyChange);
expect(applyChange).toHaveBeenCalledTimes(1);
expect(
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
).toBe(false);
});
});

View File

@@ -1,115 +0,0 @@
import { flushSync } from 'react-dom';
const WIPE_DURATION_MS = 400;
const WIPE_EASING = 'ease-out';
// Toggled on <html> for the duration of the wipe so the CSS overrides
// (animation: none on ::view-transition-{old,new}(root)) don't leak into
// any future, unrelated view transitions in the app.
export const THEME_WIPE_ACTIVE_CLASS = 'theme-wipe-active';
type ViewTransition = {
ready: Promise<void>;
finished: Promise<void>;
};
type DocumentWithVT = Document & {
startViewTransition?: (callback: () => void) => ViewTransition;
};
// Rapid theme switches cancel the in-flight transition and immediately start a
// new one; if we removed the class on the first transition's settled promise,
// we'd strip the CSS override mid-way through the next wipe and the user
// would briefly see the UA crossfade. Refcount so the class only comes off
// once every transition we started has settled.
let wipeActiveRefCount = 0;
const acquireWipeClass = (root: HTMLElement): void => {
wipeActiveRefCount += 1;
root.classList.add(THEME_WIPE_ACTIVE_CLASS);
};
const releaseWipeClass = (root: HTMLElement): void => {
wipeActiveRefCount = Math.max(0, wipeActiveRefCount - 1);
if (wipeActiveRefCount === 0) {
root.classList.remove(THEME_WIPE_ACTIVE_CLASS);
}
};
// Identity of the transition we most recently started. Used to skip the
// .animate() call on a stale transition whose .ready resolved after a newer
// transition has already taken over the ::view-transition-new pseudo-element.
let currentTransition: ViewTransition | null = null;
export function canAnimateThemeTransition(): boolean {
const doc = document as DocumentWithVT;
if (typeof doc.startViewTransition !== 'function') {
return false;
}
return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
// Runs `applyChange` inside a View Transition and wipes the new theme in from
// left to right via a polygon clip-path on ::view-transition-new(root).
// Callers should gate on canAnimateThemeTransition() first; this is a safe
// no-animation fallback otherwise.
export function runThemeTransition(applyChange: () => void): void {
const doc = document as DocumentWithVT;
if (!doc.startViewTransition) {
applyChange();
return;
}
const root = document.documentElement;
acquireWipeClass(root);
// Some Chromium versions throw if startViewTransition is called while
// another transition is in setup. Track whether the callback ran so we
// don't double-apply if the throw happens mid-callback.
let applied = false;
let transition: ViewTransition;
try {
transition = doc.startViewTransition(() => {
applied = true;
flushSync(applyChange);
});
} catch {
releaseWipeClass(root);
if (!applied) {
applyChange();
}
return;
}
currentTransition = transition;
const from = 'polygon(0 0, 0 0, 0 100%, 0 100%)';
const to = 'polygon(0 0, 100% 0, 100% 100%, 0 100%)';
transition.ready
.then(() => {
// If a newer transition has superseded this one between
// startViewTransition() and `ready` resolving, the browser has
// already cancelled our pseudo-element. Calling .animate() on it now
// would race with the newer transition's own animation.
if (currentTransition !== transition) {
return;
}
root.animate(
{ clipPath: [from, to] },
{
duration: WIPE_DURATION_MS,
easing: WIPE_EASING,
pseudoElement: '::view-transition-new(root)',
},
);
})
.catch(() => {
// Transition cancelled — applyChange has already run.
});
const cleanup = (): void => {
if (currentTransition === transition) {
currentTransition = null;
}
releaseWipeClass(root);
};
transition.finished.then(cleanup).catch(cleanup);
}

View File

@@ -13,7 +13,6 @@ import (
"github.com/DATA-DOG/go-sqlmock"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
@@ -30,7 +29,7 @@ import (
func newTestMaintenanceStore() alertmanagertypes.MaintenanceStore {
ss := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
return sqlalertmanagerstore.NewMaintenanceStore(ss, factorytest.NewSettings())
return sqlalertmanagerstore.NewMaintenanceStore(ss)
}
func TestServerSetConfigAndStop(t *testing.T) {

View File

@@ -2,11 +2,9 @@ package sqlalertmanagerstore
import (
"context"
"log/slog"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
@@ -16,13 +14,11 @@ import (
type maintenance struct {
sqlstore sqlstore.SQLStore
logger *slog.Logger
}
func NewMaintenanceStore(store sqlstore.SQLStore, providerSettings factory.ProviderSettings) alertmanagertypes.MaintenanceStore {
func NewMaintenanceStore(store sqlstore.SQLStore) alertmanagertypes.MaintenanceStore {
return &maintenance{
sqlstore: store,
logger: providerSettings.Logger,
}
}
@@ -41,11 +37,7 @@ func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string)
gettablePlannedMaintenance := make([]*alertmanagertypes.PlannedMaintenance, 0)
for _, gettableMaintenancesRule := range gettableMaintenancesRules {
m := gettableMaintenancesRule.ToPlannedMaintenance()
gettablePlannedMaintenance = append(gettablePlannedMaintenance, m)
if m.HasScheduleRecurrenceBoundsMismatch() {
r.logger.WarnContext(ctx, "planned_downtime_recurrence_schedule_mismatch", slog.String("maintenance_id", m.ID.StringValue()))
}
gettablePlannedMaintenance = append(gettablePlannedMaintenance, gettableMaintenancesRule.ToPlannedMaintenance())
}
return gettablePlannedMaintenance, nil

View File

@@ -46,7 +46,7 @@ func NewFactory(
) factory.ProviderFactory[ruler.Ruler, ruler.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config ruler.Config) (ruler.Ruler, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore, providerSettings)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
managerOpts := &rules.ManagerOptions{
TelemetryStore: telemetryStore,

View File

@@ -41,7 +41,7 @@ func TestNewHandlers(t *testing.T) {
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore, providerSettings)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
alertmanager, err := signozalertmanager.New(providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager, maintenanceStore)
require.NoError(t, err)
tokenizer := tokenizertest.NewMockTokenizer(t)

View File

@@ -42,7 +42,7 @@ func TestNewModules(t *testing.T) {
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
notificationManager := nfmanagertest.NewMock()
require.NoError(t, err)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore, providerSettings)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
alertmanager, err := signozalertmanager.New(providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager, maintenanceStore)
require.NoError(t, err)
tokenizer := tokenizertest.NewMockTokenizer(t)

View File

@@ -210,6 +210,8 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewMigrateInstalledIntegrationDashboardsFactory(sqlstore),
sqlmigration.NewAddDashboardNameFactory(sqlstore, sqlschema),
sqlmigration.NewFixChangelogOperationTypeFactory(sqlstore, sqlschema),
sqlmigration.NewCloudIntegrationRemoveCascadeDeleteFactory(sqlschema),
sqlmigration.NewMigrateRecurrenceBoundsFactory(sqlstore),
)
}

View File

@@ -8,7 +8,6 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
@@ -64,7 +63,7 @@ func TestNewProviderFactories(t *testing.T) {
store := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
orgGetter := implorganization.NewGetter(implorganization.NewStore(store), nil)
notificationManager := nfmanagertest.NewMock()
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(store, factorytest.NewSettings())
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(store)
NewAlertmanagerProviderFactories(store, orgGetter, notificationManager, maintenanceStore)
})

View File

@@ -377,7 +377,7 @@ func New(
return nil, err
}
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore, providerSettings)
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
// Initialize alertmanager from the available alertmanager provider factories
alertmanager, err := factory.NewProviderFromNamedMap(

View File

@@ -0,0 +1,130 @@
package sqlmigration
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type cloudIntegrationRemoveCascadeDelete struct {
sqlschema sqlschema.SQLSchema
}
type ciServiceRow struct {
bun.BaseModel `bun:"table:cloud_integration_service"`
ID string `bun:"id"`
CreatedAt time.Time `bun:"created_at"`
UpdatedAt time.Time `bun:"updated_at"`
Type string `bun:"type"`
Config string `bun:"config"`
CloudIntegrationID string `bun:"cloud_integration_id"`
}
func NewCloudIntegrationRemoveCascadeDeleteFactory(sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("ci_remove_cascade_delete"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &cloudIntegrationRemoveCascadeDelete{sqlschema: sqlschema}, nil
},
)
}
func (migration *cloudIntegrationRemoveCascadeDelete) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *cloudIntegrationRemoveCascadeDelete) Up(ctx context.Context, db *bun.DB) error {
if err := migration.sqlschema.ToggleFKEnforcement(ctx, db, false); err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
// get all existing rows
var rows []*ciServiceRow
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
// get existing table
table, _, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("cloud_integration_service"))
if err != nil {
return err
}
// drop the existing table
for _, sql := range migration.sqlschema.Operator().DropTable(table) {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
// create new table without cascade delete FK
newTable := &sqlschema.Table{
Name: sqlschema.TableName("cloud_integration_service"),
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "type", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "config", DataType: sqlschema.DataTypeText, Nullable: true},
{Name: "cloud_integration_id", DataType: sqlschema.DataTypeText, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
ColumnNames: []sqlschema.ColumnName{"id"},
},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("cloud_integration_id"),
ReferencedTableName: sqlschema.TableName("cloud_integration"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
}
// create table
for _, sql := range migration.sqlschema.Operator().CreateTable(newTable) {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
// add back existing rows
if len(rows) > 0 {
if _, err := tx.NewInsert().Model(&rows).Exec(ctx); err != nil {
return err
}
}
// create existing unique index on (cloud_integration_id, type)
indexSQLs := migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{
TableName: "cloud_integration_service",
ColumnNames: []sqlschema.ColumnName{"cloud_integration_id", "type"},
})
for _, sql := range indexSQLs {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return migration.sqlschema.ToggleFKEnforcement(ctx, db, true)
}
func (migration *cloudIntegrationRemoveCascadeDelete) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,122 @@
package sqlmigration
import (
"context"
"encoding/json"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type migrateRecurrenceBounds struct {
sqlstore sqlstore.SQLStore
}
type plannedMaintenanceScheduleRow struct {
bun.BaseModel `bun:"table:planned_maintenance"`
ID string `bun:"id"`
Schedule string `bun:"schedule"`
}
func NewMigrateRecurrenceBoundsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("migrate_recurrence_bounds"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &migrateRecurrenceBounds{sqlstore: sqlstore}, nil
},
)
}
func (migration *migrateRecurrenceBounds) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
// Up moves the start/end bounds of a recurring planned maintenance from the
// nested recurrence object up to the schedule level. Until now both the
// schedule and its recurrence carried their own startTime/endTime, with the
// recurrence values taking precedence when a recurrence was present. The
// recurrence fields are being dropped, so the recurrence bounds (the source of
// truth for recurring maintenances) are promoted to the schedule before the
// struct loses those fields.
//
// We deliberately operate on the raw JSON instead of the Recurrence struct:
// that struct loses its StartTime/EndTime fields in the same change set, so it
// can no longer read the values this migration needs to move.
func (migration *migrateRecurrenceBounds) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
rows := make([]*plannedMaintenanceScheduleRow, 0)
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
for _, row := range rows {
schedule := make(map[string]json.RawMessage)
if err := json.Unmarshal([]byte(row.Schedule), &schedule); err != nil {
return err
}
recurrenceRaw, ok := schedule["recurrence"]
if !ok || string(recurrenceRaw) == "null" {
continue
}
recurrence := make(map[string]json.RawMessage)
if err := json.Unmarshal(recurrenceRaw, &recurrence); err != nil {
return err
}
// Promote the recurrence bounds (source of truth) to the schedule
// level, then drop them from the recurrence.
if startTime, ok := recurrence["startTime"]; ok {
schedule["startTime"] = startTime
delete(recurrence, "startTime")
}
if endTime, ok := recurrence["endTime"]; ok && string(endTime) != "null" {
schedule["endTime"] = endTime
} else {
// The recurrence had no end time, so the schedule must not carry
// a stale one duplicated by the UI.
delete(schedule, "endTime")
}
delete(recurrence, "endTime")
newRecurrence, err := json.Marshal(recurrence)
if err != nil {
return err
}
schedule["recurrence"] = newRecurrence
newSchedule, err := json.Marshal(schedule)
if err != nil {
return err
}
if _, err := tx.NewUpdate().
Model((*plannedMaintenanceScheduleRow)(nil)).
Set("schedule = ?", string(newSchedule)).
Where("id = ?", row.ID).
Exec(ctx); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *migrateRecurrenceBounds) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -3,6 +3,7 @@ package alertmanagertypes
import (
"context"
"encoding/json"
"slices"
"time"
"github.com/expr-lang/expr"
@@ -107,10 +108,12 @@ func (p *PostablePlannedMaintenance) Validate() error {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "invalid timezone in the payload")
}
if !p.Schedule.StartTime.IsZero() && !p.Schedule.EndTime.IsZero() {
if p.Schedule.StartTime.After(p.Schedule.EndTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "start time cannot be after end time")
}
if p.Schedule.StartTime.IsZero() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing start time in the payload")
}
if !p.Schedule.EndTime.IsZero() && p.Schedule.StartTime.After(p.Schedule.EndTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "start time cannot be after end time")
}
if p.Schedule.Recurrence != nil {
@@ -120,9 +123,6 @@ func (p *PostablePlannedMaintenance) Validate() error {
if p.Schedule.Recurrence.Duration.IsZero() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing duration in the payload")
}
if p.Schedule.Recurrence.EndTime != nil && p.Schedule.Recurrence.EndTime.Before(p.Schedule.Recurrence.StartTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "end time cannot be before start time")
}
}
if p.Scope != "" {
if _, err := expr.Compile(p.Scope, expr.AllowUndefinedVariables(), expr.AsBool()); err != nil {
@@ -148,134 +148,84 @@ type PlannedMaintenanceWithRules struct {
Rules []*StorablePlannedMaintenanceRule `bun:"rel:has-many,join:id=planned_maintenance_id"`
}
// HasScheduleRecurrenceBoundsMismatch reports whether a recurring maintenance
// has different start/end bounds in Schedule and Schedule.Recurrence.
//
// This is used to detect if there are any entries with recurrence that don't
// have the same timestamps stored at the schedule-level.
// UI payloads duplicated those values in both places, but direct API users may
// have stored bounds that are missing from, or different than, the schedule-level bounds.
// We need to observe these before we can safely drop Recurrence.StartTime and
// Recurrence.EndTime.
func (m *PlannedMaintenance) HasScheduleRecurrenceBoundsMismatch() bool {
recurrence := m.Schedule.Recurrence
if recurrence == nil {
return false
}
return !recurrence.StartTime.Equal(m.Schedule.StartTime) ||
(recurrence.EndTime == nil && !m.Schedule.EndTime.IsZero()) ||
(recurrence.EndTime != nil && !recurrence.EndTime.Equal(m.Schedule.EndTime))
// AppliesTo reports whether this maintenance applies to the given rule.
// An empty RuleIDs set means the maintenance applies to all rules.
func (m *PlannedMaintenance) AppliesTo(ruleID string) bool {
return len(m.RuleIDs) == 0 || slices.Contains(m.RuleIDs, ruleID)
}
func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time, lset model.LabelSet) (bool, error) {
// Check if the alert ID is in the maintenance window
found := false
if len(m.RuleIDs) > 0 {
for _, alertID := range m.RuleIDs {
if alertID == ruleID {
found = true
break
}
}
}
// If no alert ids, then skip all alerts
if len(m.RuleIDs) == 0 {
found = true
}
if !found {
return false, nil
}
if !m.IsActive(now) {
if !m.AppliesTo(ruleID) || !m.IsActive(now) {
return false, nil
}
if m.Scope != "" {
result, err := EvalScopeExpression(m.Scope, lset)
if err != nil {
skip, err := EvalScopeExpression(m.Scope, lset)
if err != nil || !skip {
return false, err
}
if !result {
return false, nil
}
}
return true, nil
}
// IsActive reports whether [now] falls inside the maintenance window's schedule.
func (m *PlannedMaintenance) IsActive(now time.Time) bool {
// If alert is found, we check if it should be skipped based on the schedule
// Check if maintenance window has not started yet
if now.Before(m.Schedule.StartTime) {
return false
}
// Check if maintenance window has expired
if !m.Schedule.EndTime.IsZero() && now.After(m.Schedule.EndTime) {
return false
}
// Fixed schedule
if m.Schedule.Recurrence == nil {
return true
}
loc, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
return false
}
startTime := m.Schedule.StartTime
endTime := m.Schedule.EndTime
recurrence := m.Schedule.Recurrence
// fixed schedule — only when no recurrence is configured.
// When recurrence is set, the recurring check below handles everything;
// falling through here would cause the window to match the absolute
// StartTimeEndTime range instead of the daily/weekly/monthly pattern.
if recurrence == nil && !startTime.IsZero() && !endTime.IsZero() {
if now.Equal(startTime) || now.Equal(endTime) ||
(now.After(startTime) && now.Before(endTime)) {
return true
}
now = now.In(loc)
switch m.Schedule.Recurrence.RepeatType {
case RepeatTypeDaily:
return m.checkDaily(now, loc)
case RepeatTypeWeekly:
return m.checkWeekly(now, loc)
case RepeatTypeMonthly:
return m.checkMonthly(now, loc)
default:
return false
}
// recurring schedule
if recurrence != nil {
// Make sure the recurrence has started
if now.Before(recurrence.StartTime) {
return false
}
// Check if recurrence has expired
if recurrence.EndTime != nil {
if !recurrence.EndTime.IsZero() && now.After(*recurrence.EndTime) {
return false
}
}
currentTime := now.In(loc)
switch recurrence.RepeatType {
case RepeatTypeDaily:
return m.checkDaily(currentTime, recurrence, loc)
case RepeatTypeWeekly:
return m.checkWeekly(currentTime, recurrence, loc)
case RepeatTypeMonthly:
return m.checkMonthly(currentTime, recurrence, loc)
}
}
return false
}
// checkDaily rebases the recurrence start to today (or yesterday if needed)
// and returns true if currentTime is within [candidate, candidate+Duration].
func (m *PlannedMaintenance) checkDaily(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
func (m *PlannedMaintenance) checkDaily(currentTime time.Time, loc *time.Location) bool {
candidate := time.Date(
currentTime.Year(), currentTime.Month(), currentTime.Day(),
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
m.Schedule.StartTime.Hour(), m.Schedule.StartTime.Minute(), 0, 0,
loc,
)
if candidate.After(currentTime) {
candidate = candidate.AddDate(0, 0, -1)
}
return currentTime.Sub(candidate) <= rec.Duration.Duration()
return currentTime.Sub(candidate) <= m.Schedule.Recurrence.Duration.Duration()
}
// checkWeekly finds the most recent allowed occurrence by rebasing the recurrences
// time-of-day onto the allowed weekday. It does this for each allowed day and returns true
// if the current time falls within the candidate window.
func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, loc *time.Location) bool {
rec := m.Schedule.Recurrence
// If no days specified, treat as every day (like daily).
if len(rec.RepeatOn) == 0 {
return m.checkDaily(currentTime, rec, loc)
return m.checkDaily(currentTime, loc)
}
for _, day := range rec.RepeatOn {
@@ -288,7 +238,7 @@ func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, rec *Recurrence,
// Build a candidate occurrence by rebasing today's date to the allowed weekday.
candidate := time.Date(
currentTime.Year(), currentTime.Month(), currentTime.Day(),
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
m.Schedule.StartTime.Hour(), m.Schedule.StartTime.Minute(), 0, 0,
loc,
).AddDate(0, 0, delta)
// If the candidate is in the future, subtract 7 days.
@@ -304,8 +254,9 @@ func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, rec *Recurrence,
// checkMonthly rebases the candidate occurrence using the recurrence's day-of-month.
// If the candidate for the current month is in the future, it uses the previous month.
func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
refDay := rec.StartTime.Day()
func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, loc *time.Location) bool {
startTime := m.Schedule.StartTime
refDay := startTime.Day()
year, month, _ := currentTime.Date()
lastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, loc).Day()
day := refDay
@@ -313,7 +264,7 @@ func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, rec *Recurrence
day = lastDay
}
candidate := time.Date(year, month, day,
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
startTime.Hour(), startTime.Minute(), startTime.Second(), startTime.Nanosecond(),
loc,
)
if candidate.After(currentTime) {
@@ -323,33 +274,30 @@ func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, rec *Recurrence
lastDayPrev := time.Date(y, m+1, 0, 0, 0, 0, 0, loc).Day()
if refDay > lastDayPrev {
candidate = time.Date(y, m, lastDayPrev,
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
startTime.Hour(), startTime.Minute(), startTime.Second(), startTime.Nanosecond(),
loc,
)
} else {
candidate = time.Date(y, m, refDay,
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
startTime.Hour(), startTime.Minute(), startTime.Second(), startTime.Nanosecond(),
loc,
)
}
}
return currentTime.Sub(candidate) <= rec.Duration.Duration()
return currentTime.Sub(candidate) <= m.Schedule.Recurrence.Duration.Duration()
}
func (m *PlannedMaintenance) IsUpcoming() bool {
loc, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
return false
}
now := time.Now().In(loc)
now := time.Now()
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
return now.Before(m.Schedule.StartTime)
if m.IsRecurring() {
// Note: this would return true even if the maintenance is active.
// This isn't an issue right now because the only usage happens after the `IsActive` check.
return m.Schedule.EndTime.IsZero() || now.Before(m.Schedule.EndTime)
}
if m.Schedule.Recurrence != nil {
return now.Before(m.Schedule.Recurrence.StartTime)
}
return false
// Fixed schedule
return now.Before(m.Schedule.StartTime)
}
func (m *PlannedMaintenance) IsRecurring() bool {
@@ -367,15 +315,16 @@ func (m *PlannedMaintenance) Validate() error {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing timezone in the payload")
}
_, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
if _, err := time.LoadLocation(m.Schedule.Timezone); err != nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "invalid timezone in the payload")
}
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
if m.Schedule.StartTime.After(m.Schedule.EndTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "start time cannot be after end time")
}
if m.Schedule.StartTime.IsZero() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing start time in the payload")
}
if !m.Schedule.EndTime.IsZero() && m.Schedule.StartTime.After(m.Schedule.EndTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "start time cannot be after end time")
}
if m.Schedule.Recurrence != nil {
@@ -385,28 +334,31 @@ func (m *PlannedMaintenance) Validate() error {
if m.Schedule.Recurrence.Duration.IsZero() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing duration in the payload")
}
if m.Schedule.Recurrence.EndTime != nil && m.Schedule.Recurrence.EndTime.Before(m.Schedule.Recurrence.StartTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "end time cannot be before start time")
}
if m.Scope != "" {
if _, err := expr.Compile(m.Scope, expr.AllowUndefinedVariables(), expr.AsBool()); err != nil {
err := errors.Newf(
errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload,
"invalid scope: %s", err.Error(),
)
return err.WithUrl(scopeDocUrl)
}
}
return nil
}
func (m PlannedMaintenance) MarshalJSON() ([]byte, error) {
now := time.Now().In(time.FixedZone(m.Schedule.Timezone, 0))
var status MaintenanceStatus
if m.IsActive(now) {
if m.IsActive(time.Now()) {
status = MaintenanceStatusActive
} else if m.IsUpcoming() {
status = MaintenanceStatusUpcoming
} else {
status = MaintenanceStatusExpired
}
var kind MaintenanceKind
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() && m.Schedule.EndTime.After(m.Schedule.StartTime) {
kind = MaintenanceKindFixed
} else {
kind := MaintenanceKindFixed
if m.Schedule.Recurrence != nil {
kind = MaintenanceKindRecurring
}

View File

@@ -8,11 +8,6 @@ import (
"github.com/prometheus/common/model"
)
// Helper function to create a time pointer.
func timePtr(t time.Time) *time.Time {
return &t
}
func TestShouldSkipMaintenance(t *testing.T) {
cases := []struct {
name string
@@ -24,9 +19,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "only-on-saturday",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "Europe/London",
Timezone: "Europe/London",
StartTime: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("24h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday, RepeatOnTuesday, RepeatOnWednesday, RepeatOnThursday, RepeatOnFriday, RepeatOnSunday},
@@ -41,10 +36,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-across-midnight-previous-day",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
Duration: valuer.MustParseTextDuration("4h"), // Until Tuesday 02:00
Duration: valuer.MustParseTextDuration("4h"), // Until Tuesday 02:00
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday
},
@@ -58,10 +53,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-across-midnight-previous-day",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
Duration: valuer.MustParseTextDuration("4h"), // Until Tuesday 02:00
Duration: valuer.MustParseTextDuration("4h"), // Until Tuesday 02:00
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday
},
@@ -75,10 +70,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-across-midnight-previous-day",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
Duration: valuer.MustParseTextDuration("52h"), // Until Thursday 02:00
Duration: valuer.MustParseTextDuration("52h"), // Until Thursday 02:00
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday
},
@@ -92,10 +87,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-across-midnight-previous-day-not-in-repeaton",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 4, 2, 22, 0, 0, 0, time.UTC), // Tuesday 22:00
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 2, 22, 0, 0, 0, time.UTC), // Tuesday 22:00
Duration: valuer.MustParseTextDuration("4h"), // Until Wednesday 02:00
Duration: valuer.MustParseTextDuration("4h"), // Until Wednesday 02:00
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnTuesday}, // Only Tuesday
},
@@ -109,10 +104,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "daily-maintenance-across-midnight",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 23, 0, 0, 0, time.UTC), // 23:00
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 23, 0, 0, 0, time.UTC), // 23:00
Duration: valuer.MustParseTextDuration("2h"), // Until 01:00 next day
Duration: valuer.MustParseTextDuration("2h"), // Until 01:00 next day
RepeatType: RepeatTypeDaily,
},
},
@@ -125,9 +120,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "at-start-time-boundary",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
@@ -141,9 +136,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "at-end-time-boundary",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
@@ -157,9 +152,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-multi-day-duration",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 1, 28, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 28, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("72h"), // 3 days
RepeatType: RepeatTypeMonthly,
},
@@ -173,9 +168,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-multi-day-duration",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 1, 28, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 28, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("72h"), // 3 days
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnSunday},
@@ -190,9 +185,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-crosses-to-next-month",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 1, 30, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 30, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("48h"), // 2 days, crosses to Feb 1
RepeatType: RepeatTypeMonthly,
},
@@ -206,9 +201,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "timezone-offset-test",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "America/New_York", // UTC-5 or UTC-4 depending on DST
Timezone: "America/New_York", // UTC-5 or UTC-4 depending on DST
StartTime: time.Date(2024, 1, 1, 22, 0, 0, 0, time.FixedZone("America/New_York", -5*3600)),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 22, 0, 0, 0, time.FixedZone("America/New_York", -5*3600)),
Duration: valuer.MustParseTextDuration("4h"),
RepeatType: RepeatTypeDaily,
},
@@ -222,9 +217,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "daily-maintenance-time-outside-window",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
@@ -238,10 +233,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring-maintenance-with-past-end-date",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
EndTime: time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
EndTime: timePtr(time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC)),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
@@ -255,10 +250,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-maintenance-spans-month-end",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 3, 31, 22, 0, 0, 0, time.UTC), // March 31, 22:00
Recurrence: &Recurrence{
StartTime: time.Date(2024, 3, 31, 22, 0, 0, 0, time.UTC), // March 31, 22:00
Duration: valuer.MustParseTextDuration("6h"), // Until April 1, 04:00
Duration: valuer.MustParseTextDuration("6h"), // Until April 1, 04:00
RepeatType: RepeatTypeMonthly,
},
},
@@ -271,9 +266,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-empty-repeaton",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{}, // Empty - should apply to all days
@@ -288,9 +283,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-maintenance-february-fewer-days",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -303,9 +298,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "daily-maintenance-crosses-midnight",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 23, 30, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 23, 30, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("1h"), // Crosses to 00:30 next day
RepeatType: RepeatTypeDaily,
},
@@ -318,9 +313,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-maintenance-crosses-month-end",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -333,9 +328,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-maintenance-crosses-month-end-and-duration-is-2-days",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 1, 30, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 30, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("48h"), // 2 days duration
RepeatType: RepeatTypeMonthly,
},
@@ -348,10 +343,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-maintenance-crosses-midnight",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 23, 0, 0, 0, time.UTC), // Monday 23:00
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 23, 0, 0, 0, time.UTC), // Monday 23:00
Duration: valuer.MustParseTextDuration("2h"), // Until Tuesday 01:00
Duration: valuer.MustParseTextDuration("2h"), // Until Tuesday 01:00
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday
},
@@ -364,9 +359,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-maintenance-crosses-month-end-and-duration-is-2-days",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -379,9 +374,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "daily-maintenance-crosses-midnight",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 22, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 22, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("4h"), // Until 02:00 next day
RepeatType: RepeatTypeDaily,
},
@@ -394,9 +389,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-maintenance-crosses-month-end-and-duration-is-2-hours",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -445,9 +440,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat sunday, saturday, weekly for 24 hours, in Us/Eastern timezone",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "US/Eastern",
Timezone: "US/Eastern",
StartTime: time.Date(2025, 3, 29, 20, 0, 0, 0, time.FixedZone("US/Eastern", -4*3600)),
Recurrence: &Recurrence{
StartTime: time.Date(2025, 3, 29, 20, 0, 0, 0, time.FixedZone("US/Eastern", -4*3600)),
Duration: valuer.MustParseTextDuration("24h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnSunday, RepeatOnSaturday},
@@ -458,57 +453,57 @@ func TestShouldSkipMaintenance(t *testing.T) {
skip: true,
},
{
name: "recurring maintenance, repeat daily from 12:00 to 14:00",
name: "recurring maintenance, repeat daily from 12:00 to 14:00, ts < start",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
},
},
ts: time.Date(2024, 1, 1, 12, 10, 0, 0, time.UTC),
ts: time.Date(2024, 1, 10, 11, 0, 0, 0, time.UTC),
skip: false,
},
{
name: "recurring maintenance, repeat daily from 12:00 to 14:00, start <= ts <= end",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
},
},
ts: time.Date(2024, 1, 10, 13, 0, 0, 0, time.UTC),
skip: true,
},
{
name: "recurring maintenance, repeat daily from 12:00 to 14:00",
name: "recurring maintenance, repeat daily from 12:00 to 14:00, start > end",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
},
},
ts: time.Date(2024, 1, 1, 14, 0, 0, 0, time.UTC),
skip: true,
},
{
name: "recurring maintenance, repeat daily from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
},
},
ts: time.Date(2024, 4, 1, 12, 10, 0, 0, time.UTC),
skip: true,
ts: time.Date(2024, 1, 10, 15, 0, 0, 0, time.UTC),
skip: false,
},
{
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
@@ -522,9 +517,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
@@ -538,9 +533,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
@@ -554,9 +549,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
@@ -570,9 +565,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
@@ -586,9 +581,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat monthly on 4th from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -601,9 +596,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat monthly on 4th from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -616,9 +611,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat monthly on 4th from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Timezone: "UTC",
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -627,45 +622,6 @@ func TestShouldSkipMaintenance(t *testing.T) {
ts: time.Date(2024, 5, 4, 12, 10, 0, 0, time.UTC),
skip: true,
},
// The recurrence should govern, when set. Not the fixed range.
{
name: "recurring-daily-with-fixed-times-outside-daily-window",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
// These fixed fields should be ignored when Recurrence is set.
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 4, 30, 18, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC), // daily at 14:00
Duration: valuer.MustParseTextDuration("2h"), // until 16:00
RepeatType: RepeatTypeDaily,
},
},
},
// 2026-04-15 11:00 is inside the fixed range but outside the daily 14:00-16:00 window.
ts: time.Date(2026, 4, 15, 11, 0, 0, 0, time.UTC),
skip: false,
},
{
name: "recurring-daily-with-fixed-times-inside-daily-window",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 4, 30, 18, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC),
EndTime: timePtr(time.Date(2026, 4, 30, 18, 0, 0, 0, time.UTC)),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
},
},
// 15:00 is inside the daily 14:00-16:00 window. Should skip.
ts: time.Date(2026, 4, 15, 15, 0, 0, 0, time.UTC),
skip: true,
},
}
for idx, c := range cases {
@@ -679,13 +635,211 @@ func TestShouldSkipMaintenance(t *testing.T) {
}
}
func TestIsActiveFixedSchedule(t *testing.T) {
start := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
end := time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC)
cases := []struct {
name string
startTime time.Time
endTime time.Time
now time.Time
active bool
}{
{
name: "no end, t < start",
startTime: start,
now: start.Add(-time.Hour),
active: false,
},
{
name: "no end, start == t",
startTime: start,
now: start,
active: true,
},
{
// A fixed schedule with no end time stays active indefinitely.
name: "no end, start << t",
startTime: start,
now: start.AddDate(10, 0, 0),
active: true,
},
{
name: "with end, start < t < end",
startTime: start,
endTime: end,
now: start.Add(24 * time.Hour),
active: true,
},
{
name: "with end, t == end",
startTime: start,
endTime: end,
now: end,
active: true,
},
{
name: "with end, end < t",
startTime: start,
endTime: end,
now: end.Add(time.Hour),
active: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
m := &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: c.startTime,
EndTime: c.endTime,
},
}
if got := m.IsActive(c.now); got != c.active {
t.Errorf("IsActive() = %v, want %v", got, c.active)
}
})
}
}
func TestIsActiveRecurringSchedule(t *testing.T) {
// Daily window 12:00-14:00, starting 2024-01-01 (a Monday).
start := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
daily := &Recurrence{
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
}
cases := []struct {
name string
startTime time.Time
endTime time.Time
recurrence *Recurrence
now time.Time
active bool
}{
{
// The recurrence has not begun yet, even though the time-of-day matches.
name: "daily: t < recurrence start",
startTime: start,
recurrence: daily,
now: time.Date(2023, 12, 31, 13, 0, 0, 0, time.UTC),
active: false,
},
{
name: "daily: no end, within window",
startTime: start,
recurrence: daily,
now: time.Date(2024, 6, 15, 13, 0, 0, 0, time.UTC),
active: true,
},
{
name: "daily: no end, outside window",
startTime: start,
recurrence: daily,
now: time.Date(2024, 6, 15, 15, 0, 0, 0, time.UTC),
active: false,
},
{
name: "daily: at window start boundary",
startTime: start,
recurrence: daily,
now: time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC),
active: true,
},
{
name: "daily: at window end boundary",
startTime: start,
recurrence: daily,
now: time.Date(2024, 6, 15, 14, 0, 0, 0, time.UTC),
active: true,
},
{
// Past the recurrence end, the time-of-day match no longer applies.
name: "daily: t > recurrence end",
startTime: start,
endTime: time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC),
recurrence: daily,
now: time.Date(2024, 1, 15, 13, 0, 0, 0, time.UTC),
active: false,
},
{
name: "daily: before recurrence end, within window",
startTime: start,
endTime: time.Date(2024, 1, 10, 23, 0, 0, 0, time.UTC),
recurrence: daily,
now: time.Date(2024, 1, 10, 13, 0, 0, 0, time.UTC),
active: true,
},
{
name: "weekly: on allowed day, within window",
startTime: start, // Monday
recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
},
now: time.Date(2024, 4, 15, 13, 0, 0, 0, time.UTC), // a Monday
active: true,
},
{
name: "weekly: on non-allowed day",
startTime: start,
recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
},
now: time.Date(2024, 4, 16, 13, 0, 0, 0, time.UTC), // a Tuesday
active: false,
},
{
name: "monthly: on day-of-month, within window",
startTime: time.Date(2024, 1, 4, 12, 0, 0, 0, time.UTC),
recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
now: time.Date(2024, 5, 4, 13, 0, 0, 0, time.UTC),
active: true,
},
{
name: "monthly: on different day-of-month",
startTime: time.Date(2024, 1, 4, 12, 0, 0, 0, time.UTC),
recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
now: time.Date(2024, 5, 5, 13, 0, 0, 0, time.UTC),
active: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
m := &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: c.startTime,
EndTime: c.endTime,
Recurrence: c.recurrence,
},
}
if got := m.IsActive(c.now); got != c.active {
t.Errorf("IsActive() = %v, want %v", got, c.active)
}
})
}
}
func TestShouldSkip_Scope(t *testing.T) {
activeSchedule := func() *Schedule {
return &Schedule{
Timezone: "UTC",
StartTime: time.Now().UTC().Add(-time.Hour),
EndTime: time.Now().UTC().Add(time.Hour),
}
activeSchedule := &Schedule{
Timezone: "UTC",
StartTime: time.Now().UTC().Add(-time.Hour),
EndTime: time.Now().UTC().Add(time.Hour),
}
now := time.Now().UTC()
@@ -699,7 +853,7 @@ func TestShouldSkip_Scope(t *testing.T) {
}{
{
name: "empty scope - no label filtering applied",
maintenance: &PlannedMaintenance{Schedule: activeSchedule()},
maintenance: &PlannedMaintenance{Schedule: activeSchedule},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
@@ -707,7 +861,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "scope matches labels",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
@@ -715,7 +869,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "scope does not match labels",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "staging"},
@@ -723,7 +877,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "AND expression - both conditions match",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" AND service = "api"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production" AND service = "api"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production", "service": "api"},
@@ -731,7 +885,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "AND expression - one condition does not match",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" AND service = "api"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production" AND service = "api"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production", "service": "worker"},
@@ -739,7 +893,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "OR expression - first alternative matches",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" OR env = "staging"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production" OR env = "staging"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
@@ -747,7 +901,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "OR expression - second alternative matches",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" OR env = "staging"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production" OR env = "staging"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "staging"},
@@ -755,7 +909,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "OR expression - neither alternative matches",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" OR env = "staging"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production" OR env = "staging"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "development"},
@@ -763,7 +917,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "scope references label absent from lset",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"service": "api"},
@@ -771,7 +925,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "in expression - value is in list",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env in ["production", "staging"]`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env in ["production", "staging"]`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "staging"},
@@ -779,7 +933,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "in expression - value not in list",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env in ["production", "staging"]`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env in ["production", "staging"]`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "development"},
@@ -787,7 +941,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "ruleID in list and scope matches - should skip",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), RuleIDs: []string{"rule-1", "rule-2"}, Scope: `env = "production"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule, RuleIDs: []string{"rule-1", "rule-2"}, Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
@@ -795,7 +949,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "ruleID not in list and scope matches - ruleID gate prevents skip",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), RuleIDs: []string{"rule-2"}, Scope: `env = "production"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule, RuleIDs: []string{"rule-2"}, Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
@@ -803,7 +957,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "ruleID in list but scope does not match - should not skip",
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), RuleIDs: []string{"rule-1"}, Scope: `env = "production"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule, RuleIDs: []string{"rule-1"}, Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "staging"},

View File

@@ -66,9 +66,9 @@ var RepeatOnAllMap = map[RepeatOn]time.Weekday{
RepeatOnSaturday: time.Saturday,
}
// Recurrence describes the repeat pattern of a planned maintenance.
// The window bounds (start/end) live on the enclosing Schedule.
type Recurrence struct {
StartTime time.Time `json:"startTime" required:"true"`
EndTime *time.Time `json:"endTime,omitempty"`
Duration valuer.TextDuration `json:"duration" required:"true"`
RepeatType RepeatType `json:"repeatType" required:"true"`
RepeatOn []RepeatOn `json:"repeatOn"`

View File

@@ -11,7 +11,7 @@ import (
type Schedule struct {
Timezone string `json:"timezone" required:"true"`
StartTime time.Time `json:"startTime,omitempty"`
StartTime time.Time `json:"startTime" required:"true"`
EndTime time.Time `json:"endTime,omitzero"`
Recurrence *Recurrence `json:"recurrence"`
}
@@ -39,29 +39,12 @@ func (s Schedule) MarshalJSON() ([]byte, error) {
return nil, err
}
var startTime, endTime time.Time
if !s.StartTime.IsZero() {
startTime = time.Date(s.StartTime.Year(), s.StartTime.Month(), s.StartTime.Day(), s.StartTime.Hour(), s.StartTime.Minute(), s.StartTime.Second(), s.StartTime.Nanosecond(), loc)
}
// Marshal times in the selected timezone.
// This ensures that recurring events are handled correctly when DST is involved.
startTime := s.StartTime.In(loc)
var endTime time.Time
if !s.EndTime.IsZero() {
endTime = time.Date(s.EndTime.Year(), s.EndTime.Month(), s.EndTime.Day(), s.EndTime.Hour(), s.EndTime.Minute(), s.EndTime.Second(), s.EndTime.Nanosecond(), loc)
}
var recurrence *Recurrence
if s.Recurrence != nil {
recStartTime := time.Date(s.Recurrence.StartTime.Year(), s.Recurrence.StartTime.Month(), s.Recurrence.StartTime.Day(), s.Recurrence.StartTime.Hour(), s.Recurrence.StartTime.Minute(), s.Recurrence.StartTime.Second(), s.Recurrence.StartTime.Nanosecond(), loc)
var recEndTime *time.Time
if s.Recurrence.EndTime != nil {
end := time.Date(s.Recurrence.EndTime.Year(), s.Recurrence.EndTime.Month(), s.Recurrence.EndTime.Day(), s.Recurrence.EndTime.Hour(), s.Recurrence.EndTime.Minute(), s.Recurrence.EndTime.Second(), s.Recurrence.EndTime.Nanosecond(), loc)
recEndTime = &end
}
recurrence = &Recurrence{
StartTime: recStartTime,
EndTime: recEndTime,
Duration: s.Recurrence.Duration,
RepeatType: s.Recurrence.RepeatType,
RepeatOn: s.Recurrence.RepeatOn,
}
endTime = s.EndTime.In(loc)
}
return json.Marshal(&struct {
@@ -73,7 +56,7 @@ func (s Schedule) MarshalJSON() ([]byte, error) {
Timezone: s.Timezone,
StartTime: startTime,
EndTime: endTime,
Recurrence: recurrence,
Recurrence: s.Recurrence,
})
}
@@ -93,14 +76,11 @@ func (s *Schedule) UnmarshalJSON(data []byte) error {
return err
}
var startTime time.Time
if aux.StartTime != "" {
startTime, err = time.Parse(time.RFC3339, aux.StartTime)
if err != nil {
return err
}
s.StartTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), startTime.Hour(), startTime.Minute(), startTime.Second(), startTime.Nanosecond(), loc)
startTime, err := time.Parse(time.RFC3339, aux.StartTime)
if err != nil {
return err
}
startTime = startTime.In(loc)
var endTime time.Time
if aux.EndTime != "" {
@@ -108,35 +88,14 @@ func (s *Schedule) UnmarshalJSON(data []byte) error {
if err != nil {
return err
}
// TODO(jatinderjit): if endTime.IsZero() then we should not set the endTime
s.EndTime = time.Date(endTime.Year(), endTime.Month(), endTime.Day(), endTime.Hour(), endTime.Minute(), endTime.Second(), endTime.Nanosecond(), loc)
if !endTime.IsZero() {
endTime = endTime.In(loc)
}
}
s.Timezone = aux.Timezone
if aux.Recurrence != nil {
recStartTime, err := time.Parse(time.RFC3339, aux.Recurrence.StartTime.Format(time.RFC3339))
if err != nil {
return err
}
var recEndTime *time.Time
if aux.Recurrence.EndTime != nil {
end, err := time.Parse(time.RFC3339, aux.Recurrence.EndTime.Format(time.RFC3339))
if err != nil {
return err
}
endConverted := time.Date(end.Year(), end.Month(), end.Day(), end.Hour(), end.Minute(), end.Second(), end.Nanosecond(), loc)
recEndTime = &endConverted
}
s.Recurrence = &Recurrence{
StartTime: time.Date(recStartTime.Year(), recStartTime.Month(), recStartTime.Day(), recStartTime.Hour(), recStartTime.Minute(), recStartTime.Second(), recStartTime.Nanosecond(), loc),
EndTime: recEndTime,
Duration: aux.Recurrence.Duration,
RepeatType: aux.Recurrence.RepeatType,
RepeatOn: aux.Recurrence.RepeatOn,
}
}
s.StartTime = startTime
s.EndTime = endTime
s.Recurrence = aux.Recurrence
return nil
}