mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-28 12:50:32 +01:00
Compare commits
9 Commits
custom-rec
...
feat/add-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b02e17a0d4 | ||
|
|
637e194d1c | ||
|
|
9eb8d6466c | ||
|
|
0aec7a9fe8 | ||
|
|
bebe4ebb89 | ||
|
|
2209708caa | ||
|
|
1f813ce21f | ||
|
|
f082821ac2 | ||
|
|
3c5bd81421 |
@@ -14,7 +14,9 @@ export function ShiftHoldOverlayController({
|
||||
|
||||
const actions = createShortcutActions({
|
||||
navigate: noop,
|
||||
handleThemeChange: noop,
|
||||
// Overlay is read-only — actions never fire — so we only need a no-op
|
||||
// that satisfies the typed signature.
|
||||
handleThemeChange: (): void => undefined,
|
||||
});
|
||||
|
||||
const visible = useShiftHoldOverlay({
|
||||
|
||||
@@ -116,17 +116,35 @@ jest.mock('hooks/useNotifications', (): unknown => ({
|
||||
}));
|
||||
|
||||
// mock theme hook
|
||||
jest.mock('hooks/useDarkMode', (): unknown => ({
|
||||
useThemeMode: (): {
|
||||
//
|
||||
// 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 = (): {
|
||||
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());
|
||||
|
||||
@@ -20,6 +20,8 @@ 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';
|
||||
@@ -47,7 +49,8 @@ export function CmdKPalette({
|
||||
}): JSX.Element | null {
|
||||
const { open, setOpen } = useCmdK();
|
||||
|
||||
const { setAutoSwitch, setTheme, theme } = useThemeMode();
|
||||
const { theme } = useThemeMode();
|
||||
const selectTheme = useThemeSelection();
|
||||
const location = useLocation();
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
@@ -80,14 +83,12 @@ export function CmdKPalette({
|
||||
|
||||
useEffect(cmdKEffect, [setOpen]);
|
||||
|
||||
function handleThemeChange(value: string): void {
|
||||
function handleThemeChange(value: ThemeMode): void {
|
||||
logEvent('Account Settings: Theme Changed', { theme: value });
|
||||
if (value === 'auto') {
|
||||
setAutoSwitch(true);
|
||||
} else {
|
||||
setAutoSwitch(false);
|
||||
setTheme(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));
|
||||
}
|
||||
|
||||
function onClickHandler(key: string): void {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { GlobalShortcutsName } from 'constants/shortcuts/globalShortcuts';
|
||||
import { THEME_MODE } from 'hooks/useDarkMode/constant';
|
||||
import { THEME_MODE, ThemeMode } from 'hooks/useDarkMode/constant';
|
||||
import {
|
||||
BarChart,
|
||||
BellDot,
|
||||
@@ -34,7 +34,7 @@ export type CmdAction = {
|
||||
|
||||
type ActionDeps = {
|
||||
navigate: (path: string) => void;
|
||||
handleThemeChange: (mode: string) => void;
|
||||
handleThemeChange: (mode: ThemeMode) => void;
|
||||
/**
|
||||
* Provided only when the AI Assistant feature is available for the current
|
||||
* tenant. When present, the palette surfaces an "Open AI Assistant" entry
|
||||
|
||||
@@ -12,6 +12,8 @@ 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();
|
||||
@@ -56,9 +58,11 @@ jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: jest.fn(() => true),
|
||||
useSystemTheme: jest.fn(() => 'dark'),
|
||||
default: jest.fn(() => ({
|
||||
theme: 'dark',
|
||||
setTheme: setThemeFunction,
|
||||
toggleTheme: toggleThemeFunction,
|
||||
autoSwitch: false,
|
||||
setAutoSwitch: jest.fn(),
|
||||
setAutoSwitch: setAutoSwitchFunction,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -134,7 +138,8 @@ describe('MySettings Flows', () => {
|
||||
fireEvent.click(lightOption);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toggleThemeFunction).toHaveBeenCalled();
|
||||
expect(setAutoSwitchFunction).toHaveBeenCalledWith(false);
|
||||
expect(setThemeFunction).toHaveBeenCalledWith('light');
|
||||
expect(logEventFunction).toHaveBeenCalledWith(
|
||||
'Account Settings: Theme Changed',
|
||||
{
|
||||
@@ -142,6 +147,10 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ 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';
|
||||
@@ -23,9 +25,10 @@ import './MySettings.styles.scss';
|
||||
function MySettings(): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
||||
const { toggleTheme, autoSwitch, setAutoSwitch } = useThemeMode();
|
||||
const { autoSwitch } = useThemeMode();
|
||||
const systemTheme = useSystemTheme();
|
||||
const { notifications } = useNotifications();
|
||||
const selectTheme = useThemeSelection();
|
||||
|
||||
const [sideNavPinned, setSideNavPinned] = useState(false);
|
||||
|
||||
@@ -58,7 +61,7 @@ function MySettings(): JSX.Element {
|
||||
<Moon data-testid="dark-theme-icon" size={12} /> Dark{' '}
|
||||
</div>
|
||||
),
|
||||
value: 'dark',
|
||||
value: THEME_MODE.DARK,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
@@ -69,7 +72,7 @@ function MySettings(): JSX.Element {
|
||||
</Tag>
|
||||
</div>
|
||||
),
|
||||
value: 'light',
|
||||
value: THEME_MODE.LIGHT,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
@@ -77,46 +80,31 @@ function MySettings(): JSX.Element {
|
||||
<MonitorCog size={12} data-testid="auto-theme-icon" /> System{' '}
|
||||
</div>
|
||||
),
|
||||
value: 'auto',
|
||||
value: THEME_MODE.SYSTEM,
|
||||
},
|
||||
];
|
||||
|
||||
const [theme, setTheme] = useState(() => {
|
||||
if (autoSwitch) {
|
||||
return 'auto';
|
||||
return THEME_MODE.SYSTEM;
|
||||
}
|
||||
return isDarkMode ? 'dark' : 'light';
|
||||
return isDarkMode ? THEME_MODE.DARK : THEME_MODE.LIGHT;
|
||||
});
|
||||
|
||||
const handleThemeChange = ({ target: { value } }: RadioChangeEvent): void => {
|
||||
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();
|
||||
}
|
||||
}
|
||||
const handleThemeChange = (event: RadioChangeEvent): void => {
|
||||
// Radio options below are all THEME_MODE values, so antd's `any`-typed
|
||||
// target.value is safe to narrow here.
|
||||
const value = event.target.value as ThemeMode;
|
||||
logEvent('Account Settings: Theme Changed', { theme: value });
|
||||
selectTheme(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (autoSwitch) {
|
||||
setTheme('auto');
|
||||
setTheme(THEME_MODE.SYSTEM);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDarkMode) {
|
||||
setTheme('dark');
|
||||
} else {
|
||||
setTheme('light');
|
||||
}
|
||||
setTheme(isDarkMode ? THEME_MODE.DARK : THEME_MODE.LIGHT);
|
||||
}, [autoSwitch, isDarkMode]);
|
||||
|
||||
const handleSideNavPinnedChange = (checked: boolean): void => {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -2,4 +2,6 @@ export const THEME_MODE = {
|
||||
LIGHT: 'light',
|
||||
DARK: 'dark',
|
||||
SYSTEM: 'auto',
|
||||
};
|
||||
} as const;
|
||||
|
||||
export type ThemeMode = typeof THEME_MODE[keyof typeof THEME_MODE];
|
||||
|
||||
@@ -18,7 +18,13 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import { THEME_MODE } from './constant';
|
||||
|
||||
export const ThemeContext = createContext({
|
||||
export const ThemeContext = createContext<{
|
||||
theme: string;
|
||||
toggleTheme: () => void;
|
||||
autoSwitch: boolean;
|
||||
setAutoSwitch: Dispatch<SetStateAction<boolean>>;
|
||||
setTheme: Dispatch<SetStateAction<string>>;
|
||||
}>({
|
||||
theme: THEME_MODE.DARK,
|
||||
toggleTheme: (): void => {},
|
||||
autoSwitch: false,
|
||||
|
||||
67
frontend/src/hooks/useDarkMode/useThemeSelection.ts
Normal file
67
frontend/src/hooks/useDarkMode/useThemeSelection.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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;
|
||||
@@ -826,3 +826,22 @@ body.ai-assistant-panel-open {
|
||||
:root {
|
||||
--input-focus-outline-width: 0;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
193
frontend/src/utils/__tests__/themeTransition.test.ts
Normal file
193
frontend/src/utils/__tests__/themeTransition.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
115
frontend/src/utils/themeTransition.ts
Normal file
115
frontend/src/utils/themeTransition.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
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);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ type Alertmanager interface {
|
||||
PutAlerts(context.Context, string, alertmanagertypes.PostableAlerts) error
|
||||
|
||||
// TestReceiver sends a test alert to a receiver.
|
||||
TestReceiver(context.Context, string, *alertmanagertypes.Receiver) error
|
||||
TestReceiver(context.Context, string, alertmanagertypes.Receiver) error
|
||||
|
||||
// TestAlert sends an alert to a list of receivers.
|
||||
TestAlert(ctx context.Context, orgID string, ruleID string, receiversMap map[*alertmanagertypes.PostableAlert][]string) error
|
||||
@@ -40,10 +40,10 @@ type Alertmanager interface {
|
||||
GetChannelByID(context.Context, string, valuer.UUID) (*alertmanagertypes.Channel, error)
|
||||
|
||||
// UpdateChannel updates a channel for the organization.
|
||||
UpdateChannelByReceiverAndID(context.Context, string, *alertmanagertypes.Receiver, valuer.UUID) error
|
||||
UpdateChannelByReceiverAndID(context.Context, string, alertmanagertypes.Receiver, valuer.UUID) error
|
||||
|
||||
// CreateChannel creates a channel for the organization.
|
||||
CreateChannel(context.Context, string, *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)
|
||||
CreateChannel(context.Context, string, alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)
|
||||
|
||||
// DeleteChannelByID deletes a channel for the organization.
|
||||
DeleteChannelByID(context.Context, string, valuer.UUID) error
|
||||
|
||||
@@ -26,8 +26,8 @@ var customNotifierIntegrations = []string{
|
||||
msteamsv2.Integration,
|
||||
}
|
||||
|
||||
func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
|
||||
upstreamIntegrations, err := receiver.BuildReceiverIntegrations(*nc.Receiver, tmpl, logger)
|
||||
func NewReceiverIntegrations(nc alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
|
||||
upstreamIntegrations, err := receiver.BuildReceiverIntegrations(nc, tmpl, logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -275,11 +275,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
|
||||
server.logger.InfoContext(ctx, "skipping creation of receiver not referenced by any route", slog.String("receiver", rcv.Name))
|
||||
continue
|
||||
}
|
||||
extendedRcv, err := alertmanagerConfig.GetReceiver(rcv.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
integrations, err := alertmanagernotify.NewReceiverIntegrations(extendedRcv, server.tmpl, server.logger, server.templater)
|
||||
integrations, err := alertmanagernotify.NewReceiverIntegrations(rcv, server.tmpl, server.logger, server.templater)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -354,7 +350,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
|
||||
return nil
|
||||
}
|
||||
|
||||
func (server *Server) TestReceiver(ctx context.Context, receiver *alertmanagertypes.Receiver) error {
|
||||
func (server *Server) TestReceiver(ctx context.Context, receiver alertmanagertypes.Receiver) error {
|
||||
testAlert := alertmanagertypes.NewTestAlert(receiver, time.Now(), time.Now())
|
||||
return alertmanagertypes.TestReceiver(ctx, receiver, alertmanagernotify.NewReceiverIntegrations, server.alertmanagerConfig, server.tmpl, server.logger, server.templater, testAlert.Labels, testAlert)
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ func TestServerTestReceiverTypeWebhook(t *testing.T) {
|
||||
webhookURL, err := url.Parse("http://" + webhookListener.Addr().String() + "/webhook")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = server.TestReceiver(context.Background(), &alertmanagertypes.Receiver{Receiver: &config.Receiver{
|
||||
err = server.TestReceiver(context.Background(), alertmanagertypes.Receiver{
|
||||
Name: "test-receiver",
|
||||
WebhookConfigs: []*config.WebhookConfig{
|
||||
{
|
||||
@@ -83,7 +83,7 @@ func TestServerTestReceiverTypeWebhook(t *testing.T) {
|
||||
URL: config.SecretTemplateURL(webhookURL.String()),
|
||||
},
|
||||
},
|
||||
}})
|
||||
})
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, requestBody.String(), "test-receiver")
|
||||
@@ -101,7 +101,7 @@ func TestServerPutAlerts(t *testing.T) {
|
||||
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{Receiver: &config.Receiver{
|
||||
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
|
||||
Name: "test-receiver",
|
||||
WebhookConfigs: []*config.WebhookConfig{
|
||||
{
|
||||
@@ -109,7 +109,7 @@ func TestServerPutAlerts(t *testing.T) {
|
||||
URL: config.SecretTemplateURL("http://localhost/test-receiver"),
|
||||
},
|
||||
},
|
||||
}}))
|
||||
}))
|
||||
|
||||
require.NoError(t, server.SetConfig(context.Background(), amConfig))
|
||||
|
||||
@@ -181,7 +181,7 @@ func TestServerTestAlert(t *testing.T) {
|
||||
webhook2URL, err := url.Parse("http://" + webhook2Listener.Addr().String() + "/webhook")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{Receiver: &config.Receiver{
|
||||
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
|
||||
Name: "receiver-1",
|
||||
WebhookConfigs: []*config.WebhookConfig{
|
||||
{
|
||||
@@ -189,9 +189,9 @@ func TestServerTestAlert(t *testing.T) {
|
||||
URL: config.SecretTemplateURL(webhook1URL.String()),
|
||||
},
|
||||
},
|
||||
}}))
|
||||
}))
|
||||
|
||||
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{Receiver: &config.Receiver{
|
||||
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
|
||||
Name: "receiver-2",
|
||||
WebhookConfigs: []*config.WebhookConfig{
|
||||
{
|
||||
@@ -199,7 +199,7 @@ func TestServerTestAlert(t *testing.T) {
|
||||
URL: config.SecretTemplateURL(webhook2URL.String()),
|
||||
},
|
||||
},
|
||||
}}))
|
||||
}))
|
||||
|
||||
require.NoError(t, server.SetConfig(context.Background(), amConfig))
|
||||
defer func() {
|
||||
@@ -273,7 +273,7 @@ func TestServerTestAlertContinuesOnFailure(t *testing.T) {
|
||||
webhookURL, err := url.Parse("http://" + webhookListener.Addr().String() + "/webhook")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{Receiver: &config.Receiver{
|
||||
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
|
||||
Name: "working-receiver",
|
||||
WebhookConfigs: []*config.WebhookConfig{
|
||||
{
|
||||
@@ -281,9 +281,9 @@ func TestServerTestAlertContinuesOnFailure(t *testing.T) {
|
||||
URL: config.SecretTemplateURL(webhookURL.String()),
|
||||
},
|
||||
},
|
||||
}}))
|
||||
}))
|
||||
|
||||
require.NoError(t, amConfig.CreateReceiver(&alertmanagertypes.Receiver{Receiver: &config.Receiver{
|
||||
require.NoError(t, amConfig.CreateReceiver(alertmanagertypes.Receiver{
|
||||
Name: "failing-receiver",
|
||||
WebhookConfigs: []*config.WebhookConfig{
|
||||
{
|
||||
@@ -291,7 +291,7 @@ func TestServerTestAlertContinuesOnFailure(t *testing.T) {
|
||||
URL: config.SecretTemplateURL("http://localhost:1/webhook"),
|
||||
},
|
||||
},
|
||||
}}))
|
||||
}))
|
||||
|
||||
require.NoError(t, server.SetConfig(context.Background(), amConfig))
|
||||
defer func() {
|
||||
|
||||
@@ -110,7 +110,7 @@ func (_c *MockAlertmanager_Collect_Call) RunAndReturn(run func(context1 context.
|
||||
}
|
||||
|
||||
// CreateChannel provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) CreateChannel(context1 context.Context, s string, v *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
|
||||
func (_mock *MockAlertmanager) CreateChannel(context1 context.Context, s string, v alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
|
||||
ret := _mock.Called(context1, s, v)
|
||||
|
||||
if len(ret) == 0 {
|
||||
@@ -119,17 +119,17 @@ func (_mock *MockAlertmanager) CreateChannel(context1 context.Context, s string,
|
||||
|
||||
var r0 *alertmanagertypes.Channel
|
||||
var r1 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)); ok {
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)); ok {
|
||||
return returnFunc(context1, s, v)
|
||||
}
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver) *alertmanagertypes.Channel); ok {
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.Receiver) *alertmanagertypes.Channel); ok {
|
||||
r0 = returnFunc(context1, s, v)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).(*alertmanagertypes.Channel)
|
||||
}
|
||||
}
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, *alertmanagertypes.Receiver) error); ok {
|
||||
if returnFunc, ok := ret.Get(1).(func(context.Context, string, alertmanagertypes.Receiver) error); ok {
|
||||
r1 = returnFunc(context1, s, v)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
@@ -145,12 +145,12 @@ type MockAlertmanager_CreateChannel_Call struct {
|
||||
// CreateChannel is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - v *alertmanagertypes.Receiver
|
||||
// - v alertmanagertypes.Receiver
|
||||
func (_e *MockAlertmanager_Expecter) CreateChannel(context1 interface{}, s interface{}, v interface{}) *MockAlertmanager_CreateChannel_Call {
|
||||
return &MockAlertmanager_CreateChannel_Call{Call: _e.mock.On("CreateChannel", context1, s, v)}
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_CreateChannel_Call) Run(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver)) *MockAlertmanager_CreateChannel_Call {
|
||||
func (_c *MockAlertmanager_CreateChannel_Call) Run(run func(context1 context.Context, s string, v alertmanagertypes.Receiver)) *MockAlertmanager_CreateChannel_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
@@ -160,9 +160,9 @@ func (_c *MockAlertmanager_CreateChannel_Call) Run(run func(context1 context.Con
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 *alertmanagertypes.Receiver
|
||||
var arg2 alertmanagertypes.Receiver
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(*alertmanagertypes.Receiver)
|
||||
arg2 = args[2].(alertmanagertypes.Receiver)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
@@ -178,7 +178,7 @@ func (_c *MockAlertmanager_CreateChannel_Call) Return(channel *alertmanagertypes
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_CreateChannel_Call) RunAndReturn(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)) *MockAlertmanager_CreateChannel_Call {
|
||||
func (_c *MockAlertmanager_CreateChannel_Call) RunAndReturn(run func(context1 context.Context, s string, v alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error)) *MockAlertmanager_CreateChannel_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -1579,7 +1579,7 @@ func (_c *MockAlertmanager_TestAlert_Call) RunAndReturn(run func(ctx context.Con
|
||||
}
|
||||
|
||||
// TestReceiver provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string, v *alertmanagertypes.Receiver) error {
|
||||
func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string, v alertmanagertypes.Receiver) error {
|
||||
ret := _mock.Called(context1, s, v)
|
||||
|
||||
if len(ret) == 0 {
|
||||
@@ -1587,7 +1587,7 @@ func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string,
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver) error); ok {
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.Receiver) error); ok {
|
||||
r0 = returnFunc(context1, s, v)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
@@ -1603,12 +1603,12 @@ type MockAlertmanager_TestReceiver_Call struct {
|
||||
// TestReceiver is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - v *alertmanagertypes.Receiver
|
||||
// - v alertmanagertypes.Receiver
|
||||
func (_e *MockAlertmanager_Expecter) TestReceiver(context1 interface{}, s interface{}, v interface{}) *MockAlertmanager_TestReceiver_Call {
|
||||
return &MockAlertmanager_TestReceiver_Call{Call: _e.mock.On("TestReceiver", context1, s, v)}
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_TestReceiver_Call) Run(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver)) *MockAlertmanager_TestReceiver_Call {
|
||||
func (_c *MockAlertmanager_TestReceiver_Call) Run(run func(context1 context.Context, s string, v alertmanagertypes.Receiver)) *MockAlertmanager_TestReceiver_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
@@ -1618,9 +1618,9 @@ func (_c *MockAlertmanager_TestReceiver_Call) Run(run func(context1 context.Cont
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 *alertmanagertypes.Receiver
|
||||
var arg2 alertmanagertypes.Receiver
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(*alertmanagertypes.Receiver)
|
||||
arg2 = args[2].(alertmanagertypes.Receiver)
|
||||
}
|
||||
run(
|
||||
arg0,
|
||||
@@ -1636,7 +1636,7 @@ func (_c *MockAlertmanager_TestReceiver_Call) Return(err error) *MockAlertmanage
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_TestReceiver_Call) RunAndReturn(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver) error) *MockAlertmanager_TestReceiver_Call {
|
||||
func (_c *MockAlertmanager_TestReceiver_Call) RunAndReturn(run func(context1 context.Context, s string, v alertmanagertypes.Receiver) error) *MockAlertmanager_TestReceiver_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
@@ -1705,7 +1705,7 @@ func (_c *MockAlertmanager_UpdateAllRoutePoliciesByRuleId_Call) RunAndReturn(run
|
||||
}
|
||||
|
||||
// UpdateChannelByReceiverAndID provides a mock function for the type MockAlertmanager
|
||||
func (_mock *MockAlertmanager) UpdateChannelByReceiverAndID(context1 context.Context, s string, v *alertmanagertypes.Receiver, uUID valuer.UUID) error {
|
||||
func (_mock *MockAlertmanager) UpdateChannelByReceiverAndID(context1 context.Context, s string, v alertmanagertypes.Receiver, uUID valuer.UUID) error {
|
||||
ret := _mock.Called(context1, s, v, uUID)
|
||||
|
||||
if len(ret) == 0 {
|
||||
@@ -1713,7 +1713,7 @@ func (_mock *MockAlertmanager) UpdateChannelByReceiverAndID(context1 context.Con
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.Receiver, valuer.UUID) error); ok {
|
||||
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.Receiver, valuer.UUID) error); ok {
|
||||
r0 = returnFunc(context1, s, v, uUID)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
@@ -1729,13 +1729,13 @@ type MockAlertmanager_UpdateChannelByReceiverAndID_Call struct {
|
||||
// UpdateChannelByReceiverAndID is a helper method to define mock.On call
|
||||
// - context1 context.Context
|
||||
// - s string
|
||||
// - v *alertmanagertypes.Receiver
|
||||
// - v alertmanagertypes.Receiver
|
||||
// - uUID valuer.UUID
|
||||
func (_e *MockAlertmanager_Expecter) UpdateChannelByReceiverAndID(context1 interface{}, s interface{}, v interface{}, uUID interface{}) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
|
||||
return &MockAlertmanager_UpdateChannelByReceiverAndID_Call{Call: _e.mock.On("UpdateChannelByReceiverAndID", context1, s, v, uUID)}
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Run(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver, uUID valuer.UUID)) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
|
||||
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Run(run func(context1 context.Context, s string, v alertmanagertypes.Receiver, uUID valuer.UUID)) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
|
||||
_c.Call.Run(func(args mock.Arguments) {
|
||||
var arg0 context.Context
|
||||
if args[0] != nil {
|
||||
@@ -1745,9 +1745,9 @@ func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Run(run func(conte
|
||||
if args[1] != nil {
|
||||
arg1 = args[1].(string)
|
||||
}
|
||||
var arg2 *alertmanagertypes.Receiver
|
||||
var arg2 alertmanagertypes.Receiver
|
||||
if args[2] != nil {
|
||||
arg2 = args[2].(*alertmanagertypes.Receiver)
|
||||
arg2 = args[2].(alertmanagertypes.Receiver)
|
||||
}
|
||||
var arg3 valuer.UUID
|
||||
if args[3] != nil {
|
||||
@@ -1768,7 +1768,7 @@ func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) Return(err error)
|
||||
return _c
|
||||
}
|
||||
|
||||
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) RunAndReturn(run func(context1 context.Context, s string, v *alertmanagertypes.Receiver, uUID valuer.UUID) error) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
|
||||
func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) RunAndReturn(run func(context1 context.Context, s string, v alertmanagertypes.Receiver, uUID valuer.UUID) error) *MockAlertmanager_UpdateChannelByReceiverAndID_Call {
|
||||
_c.Call.Return(run)
|
||||
return _c
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ func (service *Service) PutAlerts(ctx context.Context, orgID string, alerts aler
|
||||
return server.PutAlerts(ctx, alerts)
|
||||
}
|
||||
|
||||
func (service *Service) TestReceiver(ctx context.Context, orgID string, receiver *alertmanagertypes.Receiver) error {
|
||||
func (service *Service) TestReceiver(ctx context.Context, orgID string, receiver alertmanagertypes.Receiver) error {
|
||||
service.serversMtx.RLock()
|
||||
defer service.serversMtx.RUnlock()
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ func (provider *provider) PutAlerts(ctx context.Context, orgID string, alerts al
|
||||
return provider.service.PutAlerts(ctx, orgID, alerts)
|
||||
}
|
||||
|
||||
func (provider *provider) TestReceiver(ctx context.Context, orgID string, receiver *alertmanagertypes.Receiver) error {
|
||||
func (provider *provider) TestReceiver(ctx context.Context, orgID string, receiver alertmanagertypes.Receiver) error {
|
||||
return provider.service.TestReceiver(ctx, orgID, receiver)
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ func (provider *provider) GetChannelByID(ctx context.Context, orgID string, chan
|
||||
return provider.configStore.GetChannelByID(ctx, orgID, channelID)
|
||||
}
|
||||
|
||||
func (provider *provider) UpdateChannelByReceiverAndID(ctx context.Context, orgID string, receiver *alertmanagertypes.Receiver, id valuer.UUID) error {
|
||||
func (provider *provider) UpdateChannelByReceiverAndID(ctx context.Context, orgID string, receiver alertmanagertypes.Receiver, id valuer.UUID) error {
|
||||
channel, err := provider.configStore.GetChannelByID(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -210,7 +210,7 @@ func (provider *provider) DeleteChannelByID(ctx context.Context, orgID string, c
|
||||
}))
|
||||
}
|
||||
|
||||
func (provider *provider) CreateChannel(ctx context.Context, orgID string, receiver *alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
|
||||
func (provider *provider) CreateChannel(ctx context.Context, orgID string, receiver alertmanagertypes.Receiver) (*alertmanagertypes.Channel, error) {
|
||||
config, err := provider.configStore.Get(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -269,7 +269,7 @@ func (migration *addAlertmanager) msTeamsChannelToMSTeamsV2Channel(c *alertmanag
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *addAlertmanager) msTeamsReceiverToMSTeamsV2Receiver(receiver *alertmanagertypes.Receiver) *alertmanagertypes.Receiver {
|
||||
func (migration *addAlertmanager) msTeamsReceiverToMSTeamsV2Receiver(receiver alertmanagertypes.Receiver) alertmanagertypes.Receiver {
|
||||
if receiver.MSTeamsConfigs == nil {
|
||||
return receiver
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ func NewAlertsFromPostableAlerts(ctx context.Context, postableAlerts PostableAle
|
||||
return validAlerts, errs
|
||||
}
|
||||
|
||||
func NewTestAlert(receiver *Receiver, startsAt time.Time, updatedAt time.Time) *Alert {
|
||||
func NewTestAlert(receiver Receiver, startsAt time.Time, updatedAt time.Time) *Alert {
|
||||
return &Alert{
|
||||
Alert: model.Alert{
|
||||
StartsAt: startsAt,
|
||||
|
||||
@@ -56,7 +56,7 @@ type Channel struct {
|
||||
|
||||
// NewChannelFromReceiver creates a new Channel from a Receiver.
|
||||
// It can return nil if the receiver is the default receiver.
|
||||
func NewChannelFromReceiver(receiver *Receiver, orgID string) (*Channel, error) {
|
||||
func NewChannelFromReceiver(receiver config.Receiver, orgID string) (*Channel, error) {
|
||||
if receiver.Name == DefaultReceiverName {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelInvalid, "cannot use %s name as a channel name", receiver.Name)
|
||||
}
|
||||
@@ -74,61 +74,51 @@ func NewChannelFromReceiver(receiver *Receiver, orgID string) (*Channel, error)
|
||||
OrgID: orgID,
|
||||
}
|
||||
|
||||
// The embedded *config.Receiver marshals inline, so json.Marshal(receiver)
|
||||
// emits every upstream notifier config plus any SigNoz-native ones in a
|
||||
// single pass.
|
||||
data, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channel.Data = string(data)
|
||||
// Use reflection to examine receiver struct fields
|
||||
receiverType := reflect.TypeOf(receiver)
|
||||
receiverVal := reflect.ValueOf(receiver)
|
||||
|
||||
channel.Type = receiverChannelType(receiver)
|
||||
if channel.Type == "" {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelInvalid, "channel '%s' must have at least one notification configuration (e.g., email_configs, webhook_configs, slack_configs)", receiver.Name)
|
||||
}
|
||||
|
||||
return &channel, nil
|
||||
}
|
||||
|
||||
// receiverChannelType derives the channel.Type discriminator from the
|
||||
// configured notifier by reflecting over the receiver. It walks both the
|
||||
// SigNoz Receiver's own fields (for native notifiers) and the embedded
|
||||
// config.Receiver's fields (for upstream notifiers); the first non-empty
|
||||
// `*_configs` slice wins.
|
||||
func receiverChannelType(receiver *Receiver) string {
|
||||
if t := nonEmptyConfigsField(reflect.ValueOf(*receiver)); t != "" {
|
||||
return t
|
||||
}
|
||||
if t := nonEmptyConfigsField(reflect.ValueOf(*receiver.Receiver)); t != "" {
|
||||
return t
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func nonEmptyConfigsField(v reflect.Value) string {
|
||||
t := v.Type()
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
fieldVal := v.Field(i)
|
||||
// Iterate through fields looking for *Config fields
|
||||
for i := 0; i < receiverType.NumField(); i++ {
|
||||
field := receiverType.Field(i)
|
||||
fieldVal := receiverVal.Field(i)
|
||||
|
||||
// Skip if not a slice or is empty
|
||||
if fieldVal.Kind() != reflect.Slice || fieldVal.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get channel type from yaml tag
|
||||
yamlTag := field.Tag.Get("yaml")
|
||||
if yamlTag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract the base type name (e.g., "email_configs" -> "email").
|
||||
// Extract the base type name (e.g., "email_configs" -> "email")
|
||||
matches := receiverTypeRegex.FindStringSubmatch(yamlTag)
|
||||
if len(matches) != 2 {
|
||||
continue
|
||||
}
|
||||
return matches[1]
|
||||
|
||||
channelType := matches[1]
|
||||
|
||||
// Marshal config data to JSON
|
||||
configData, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
channel.Type = channelType
|
||||
channel.Data = string(configData)
|
||||
break
|
||||
}
|
||||
return ""
|
||||
|
||||
// If we were unable to find the channel type, return an error
|
||||
if channel.Type == "" {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelInvalid, "channel '%s' must have at least one notification configuration (e.g., email_configs, webhook_configs, slack_configs)", receiver.Name)
|
||||
}
|
||||
|
||||
return &channel, nil
|
||||
}
|
||||
|
||||
func NewConfigFromChannels(globalConfig GlobalConfig, routeConfig RouteConfig, channels Channels, orgID string) (*Config, error) {
|
||||
@@ -192,7 +182,7 @@ func NewStatsFromChannels(channels Channels) map[string]any {
|
||||
return stats
|
||||
}
|
||||
|
||||
func (c *Channel) Update(receiver *Receiver) error {
|
||||
func (c *Channel) Update(receiver Receiver) error {
|
||||
channel, err := NewChannelFromReceiver(receiver, c.OrgID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -202,7 +192,6 @@ func (c *Channel) Update(receiver *Receiver) error {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeAlertmanagerChannelNameMismatch, "cannot update channel name")
|
||||
}
|
||||
|
||||
c.Type = channel.Type
|
||||
c.Data = channel.Data
|
||||
c.UpdatedAt = time.Now()
|
||||
|
||||
@@ -221,21 +210,15 @@ func (PostableChannel) JSONSchema() (jsonschema.Schema, error) {
|
||||
schema.WithRequired("name")
|
||||
|
||||
var oneOf []jsonschema.SchemaOrBool
|
||||
// Walk both the SigNoz Receiver's own fields (for native notifiers) and the
|
||||
// embedded config.Receiver's fields (for upstream notifiers) — together
|
||||
// they make up PostableChannel's JSON shape.
|
||||
collect := func(t reflect.Type) {
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
jsonTag := strings.Split(t.Field(i).Tag.Get("json"), ",")[0]
|
||||
if !strings.HasSuffix(jsonTag, "_configs") {
|
||||
continue
|
||||
}
|
||||
branch := (&jsonschema.Schema{}).WithRequired(jsonTag)
|
||||
oneOf = append(oneOf, branch.ToSchemaOrBool())
|
||||
receiverType := reflect.TypeOf(Receiver{})
|
||||
for i := 0; i < receiverType.NumField(); i++ {
|
||||
jsonTag := strings.Split(receiverType.Field(i).Tag.Get("json"), ",")[0]
|
||||
if !strings.HasSuffix(jsonTag, "_configs") {
|
||||
continue
|
||||
}
|
||||
branch := (&jsonschema.Schema{}).WithRequired(jsonTag)
|
||||
oneOf = append(oneOf, branch.ToSchemaOrBool())
|
||||
}
|
||||
collect(reflect.TypeOf(Receiver{}))
|
||||
collect(reflect.TypeOf(config.Receiver{}))
|
||||
|
||||
schema.WithOneOf(oneOf...)
|
||||
|
||||
|
||||
@@ -285,8 +285,7 @@ func TestNewChannelFromReceiver(t *testing.T) {
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
receiver := testCase.receiver
|
||||
channel, err := NewChannelFromReceiver(&Receiver{Receiver: &receiver}, "1")
|
||||
channel, err := NewChannelFromReceiver(testCase.receiver, "1")
|
||||
if !testCase.pass {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
@@ -300,33 +299,3 @@ func TestNewChannelFromReceiver(t *testing.T) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TestNewChannelFromReceiverGoogleChat covers the SigNoz-native side of the
|
||||
// receiver: the Type discriminator and the per-row Data both come from the
|
||||
// embed's googlechat_configs field — no upstream notifier config is present.
|
||||
func TestNewChannelFromReceiverGoogleChat(t *testing.T) {
|
||||
webhookURL, err := url.Parse("https://chat.googleapis.com/v1/spaces/test/messages")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
receiver := &Receiver{
|
||||
Receiver: &config.Receiver{Name: "googlechat-receiver"},
|
||||
GoogleChatConfigs: []*GoogleChatReceiverConfig{
|
||||
{
|
||||
WebhookURL: &config.SecretURL{URL: webhookURL},
|
||||
Title: "Alert",
|
||||
Text: "Body",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
channel, err := NewChannelFromReceiver(receiver, "1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "googlechat-receiver", channel.Name)
|
||||
assert.Equal(t, "googlechat", channel.Type)
|
||||
assert.JSONEq(t,
|
||||
`{"name":"googlechat-receiver","googlechat_configs":[{"send_resolved":false,"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages","title":"Alert","text":"Body"}]}`,
|
||||
channel.Data,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,55 +59,12 @@ type Config struct {
|
||||
|
||||
// storeableConfig is the representation of the config in the store
|
||||
storeableConfig *StoreableConfig
|
||||
|
||||
// customConfigs holds the SigNoz-native notifier configs (which the upstream
|
||||
// config.Receiver cannot carry) keyed by receiver name. It runs in parallel
|
||||
// with alertmanagerConfig.Receivers (which holds the upstream base); the two
|
||||
// halves are zipped back together by GetReceiver and serialized together by
|
||||
// newRawFromConfig.
|
||||
customConfigs map[string]customReceiverConfigs
|
||||
}
|
||||
|
||||
// customReceiverConfigs is the per-receiver sidecar for SigNoz-native notifier
|
||||
// configs that the upstream config.Receiver cannot carry. To add another
|
||||
// native notifier, mirror the GoogleChat field below: declare a typed slice
|
||||
// here, add the matching field on Receiver, and extend customConfigsOf and
|
||||
// isEmpty. The serialization (storedConfig) and in-memory plumbing
|
||||
// (CreateReceiver / GetReceiver / newRawFromConfig) need no further changes.
|
||||
type customReceiverConfigs struct {
|
||||
GoogleChat []*GoogleChatReceiverConfig
|
||||
}
|
||||
|
||||
func (c customReceiverConfigs) isEmpty() bool {
|
||||
return len(c.GoogleChat) == 0
|
||||
}
|
||||
|
||||
// customConfigsOf extracts the SigNoz-native configs carried on a Receiver.
|
||||
func customConfigsOf(receiver *Receiver) customReceiverConfigs {
|
||||
return customReceiverConfigs{
|
||||
GoogleChat: receiver.GoogleChatConfigs,
|
||||
}
|
||||
}
|
||||
|
||||
// storedConfig is the serialization unit persisted to StoreableConfig.Config.
|
||||
// Embedding *config.Config emits every upstream field (global, route,
|
||||
// inhibit_rules, templates, ...); the outer Receivers field shadows the
|
||||
// embedded config.Config.Receivers — both marshal to the JSON key "receivers"
|
||||
// and the shallower outer field dominates per encoding/json's visibility
|
||||
// rules — so the receivers are emitted as the extended *Receiver. When the
|
||||
// first SigNoz-native notifier field is added to Receiver, it round-trips
|
||||
// through this unit automatically, with no further changes here.
|
||||
type storedConfig struct {
|
||||
*config.Config
|
||||
Receivers []*Receiver `json:"receivers"`
|
||||
}
|
||||
|
||||
func NewConfig(c *config.Config, orgID string) *Config {
|
||||
customConfigs := make(map[string]customReceiverConfigs)
|
||||
raw := string(newRawFromConfig(c, customConfigs))
|
||||
raw := string(newRawFromConfig(c))
|
||||
return &Config{
|
||||
alertmanagerConfig: c,
|
||||
customConfigs: customConfigs,
|
||||
storeableConfig: &StoreableConfig{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
@@ -124,14 +81,13 @@ func NewConfig(c *config.Config, orgID string) *Config {
|
||||
}
|
||||
|
||||
func NewConfigFromStoreableConfig(sc *StoreableConfig) (*Config, error) {
|
||||
alertmanagerConfig, customConfigs, err := newConfigFromString(sc.Config)
|
||||
alertmanagerConfig, err := newConfigFromString(sc.Config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Config{
|
||||
alertmanagerConfig: alertmanagerConfig,
|
||||
customConfigs: customConfigs,
|
||||
storeableConfig: sc,
|
||||
}, nil
|
||||
}
|
||||
@@ -157,49 +113,32 @@ func NewDefaultConfig(globalConfig GlobalConfig, routeConfig RouteConfig, orgID
|
||||
}, orgID), nil
|
||||
}
|
||||
|
||||
func newConfigFromString(s string) (*config.Config, map[string]customReceiverConfigs, error) {
|
||||
stored := storedConfig{Config: new(config.Config)}
|
||||
if err := json.Unmarshal([]byte(s), &stored); err != nil {
|
||||
return nil, nil, err
|
||||
func newConfigFromString(s string) (*config.Config, error) {
|
||||
config := new(config.Config)
|
||||
err := json.Unmarshal([]byte(s), config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
amConfig := stored.Config
|
||||
amConfig.Receivers = make([]config.Receiver, len(stored.Receivers))
|
||||
customConfigs := make(map[string]customReceiverConfigs)
|
||||
for i, receiver := range config.Receivers {
|
||||
bytes, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Re-run each receiver through NewReceiver so upstream defaults are
|
||||
// applied (mirrors the create path) and native fields are pulled off the
|
||||
// embed at the same time.
|
||||
for i, rcv := range stored.Receivers {
|
||||
rcvJSON, err := json.Marshal(rcv)
|
||||
receiver, err := NewReceiver(string(bytes))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
parsed, err := NewReceiver(string(rcvJSON))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
amConfig.Receivers[i] = *parsed.Receiver
|
||||
if custom := customConfigsOf(parsed); !custom.isEmpty() {
|
||||
customConfigs[parsed.Name] = custom
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config.Receivers[i] = receiver
|
||||
}
|
||||
|
||||
return amConfig, customConfigs, nil
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func newRawFromConfig(c *config.Config, customConfigs map[string]customReceiverConfigs) []byte {
|
||||
receivers := make([]*Receiver, len(c.Receivers))
|
||||
for i := range c.Receivers {
|
||||
base := c.Receivers[i]
|
||||
custom := customConfigs[base.Name]
|
||||
receivers[i] = &Receiver{
|
||||
Receiver: &base,
|
||||
GoogleChatConfigs: custom.GoogleChat,
|
||||
}
|
||||
}
|
||||
|
||||
b, err := json.Marshal(storedConfig{Config: c, Receivers: receivers})
|
||||
func newRawFromConfig(c *config.Config) []byte {
|
||||
b, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
// Taking inspiration from the upstream. This is never expected to happen.
|
||||
return []byte(fmt.Sprintf("<error creating config string: %s>", err))
|
||||
@@ -212,16 +151,6 @@ func newConfigHash(s string) [16]byte {
|
||||
return md5.Sum([]byte(s))
|
||||
}
|
||||
|
||||
// flush re-serializes the config into the storeable representation and
|
||||
// refreshes its hash and timestamp. Call it after every mutation of
|
||||
// alertmanagerConfig or customConfigs.
|
||||
func (c *Config) flush() {
|
||||
raw := string(newRawFromConfig(c.alertmanagerConfig, c.customConfigs))
|
||||
c.storeableConfig.Config = raw
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(raw))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
func (c *Config) CopyWithReset() (*Config, error) {
|
||||
newConfig, err := NewDefaultConfig(
|
||||
*c.alertmanagerConfig.Global,
|
||||
@@ -250,7 +179,9 @@ func (c *Config) SetGlobalConfig(globalConfig GlobalConfig) error {
|
||||
globalConfig.SMTPRequireTLS = smtpRequireTLS
|
||||
|
||||
c.alertmanagerConfig.Global = &globalConfig
|
||||
c.flush()
|
||||
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -262,7 +193,9 @@ func (c *Config) SetRouteConfig(routeConfig RouteConfig) error {
|
||||
}
|
||||
c.alertmanagerConfig.Route = route
|
||||
|
||||
c.flush()
|
||||
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -274,7 +207,9 @@ func (c *Config) AddInhibitRules(rules []config.InhibitRule) error {
|
||||
|
||||
c.alertmanagerConfig.InhibitRules = append(c.alertmanagerConfig.InhibitRules, rules...)
|
||||
|
||||
c.flush()
|
||||
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -287,7 +222,7 @@ func (c *Config) StoreableConfig() *StoreableConfig {
|
||||
return c.storeableConfig
|
||||
}
|
||||
|
||||
func (c *Config) CreateReceiver(receiver *Receiver) error {
|
||||
func (c *Config) CreateReceiver(receiver config.Receiver) error {
|
||||
// check that receiver name is not already used
|
||||
for _, existingReceiver := range c.alertmanagerConfig.Receivers {
|
||||
if existingReceiver.Name == receiver.Name {
|
||||
@@ -301,41 +236,33 @@ func (c *Config) CreateReceiver(receiver *Receiver) error {
|
||||
}
|
||||
|
||||
c.alertmanagerConfig.Route.Routes = append(c.alertmanagerConfig.Route.Routes, route)
|
||||
c.alertmanagerConfig.Receivers = append(c.alertmanagerConfig.Receivers, *receiver.Receiver)
|
||||
c.setCustomConfigs(receiver)
|
||||
c.alertmanagerConfig.Receivers = append(c.alertmanagerConfig.Receivers, receiver)
|
||||
|
||||
if err := c.alertmanagerConfig.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
|
||||
return err
|
||||
}
|
||||
c.applyNativeDefaults()
|
||||
|
||||
c.flush()
|
||||
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) GetReceiver(name string) (*Receiver, error) {
|
||||
for i := range c.alertmanagerConfig.Receivers {
|
||||
if c.alertmanagerConfig.Receivers[i].Name == name {
|
||||
// Copy out of the slice to avoid handing the caller a pointer
|
||||
// into a slice that may later be reallocated by append.
|
||||
base := c.alertmanagerConfig.Receivers[i]
|
||||
custom := c.customConfigs[name]
|
||||
return &Receiver{
|
||||
Receiver: &base,
|
||||
GoogleChatConfigs: custom.GoogleChat,
|
||||
}, nil
|
||||
func (c *Config) GetReceiver(name string) (Receiver, error) {
|
||||
for _, receiver := range c.alertmanagerConfig.Receivers {
|
||||
if receiver.Name == name {
|
||||
return receiver, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.Newf(errors.TypeNotFound, ErrCodeAlertmanagerChannelNotFound, "channel with name %q not found", name)
|
||||
return Receiver{}, errors.Newf(errors.TypeNotFound, ErrCodeAlertmanagerChannelNotFound, "channel with name %q not found", name)
|
||||
}
|
||||
|
||||
func (c *Config) UpdateReceiver(receiver *Receiver) error {
|
||||
func (c *Config) UpdateReceiver(receiver config.Receiver) error {
|
||||
// find and update receiver
|
||||
for i, existingReceiver := range c.alertmanagerConfig.Receivers {
|
||||
if existingReceiver.Name == receiver.Name {
|
||||
c.alertmanagerConfig.Receivers[i] = *receiver.Receiver
|
||||
c.setCustomConfigs(receiver)
|
||||
c.alertmanagerConfig.Receivers[i] = receiver
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -343,9 +270,10 @@ func (c *Config) UpdateReceiver(receiver *Receiver) error {
|
||||
if err := c.alertmanagerConfig.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
|
||||
return err
|
||||
}
|
||||
c.applyNativeDefaults()
|
||||
|
||||
c.flush()
|
||||
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -370,48 +298,13 @@ func (c *Config) DeleteReceiver(name string) error {
|
||||
}
|
||||
}
|
||||
|
||||
delete(c.customConfigs, name)
|
||||
|
||||
c.flush()
|
||||
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setCustomConfigs records (or clears) the SigNoz-native configs for a
|
||||
// receiver in the in-memory sidecar.
|
||||
func (c *Config) setCustomConfigs(receiver *Receiver) {
|
||||
if custom := customConfigsOf(receiver); !custom.isEmpty() {
|
||||
c.customConfigs[receiver.Name] = custom
|
||||
} else {
|
||||
delete(c.customConfigs, receiver.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// applyNativeDefaults threads global-scoped defaults (currently:
|
||||
// Global.HTTPConfig) into each SigNoz-native notifier config that has not
|
||||
// supplied its own. This mirrors the corresponding loop in upstream's
|
||||
// config.Config.UnmarshalYAML, which does
|
||||
//
|
||||
// wh.HTTPConfig = cmp.Or(wh.HTTPConfig, c.Global.HTTPConfig)
|
||||
//
|
||||
// for each upstream notifier config. Call it from mutation paths after the
|
||||
// upstream UnmarshalYAML pass has run. Extend it when adding another native
|
||||
// notifier type that needs anything threaded from Global.
|
||||
func (c *Config) applyNativeDefaults() {
|
||||
if c.alertmanagerConfig.Global == nil {
|
||||
return
|
||||
}
|
||||
httpDefault := c.alertmanagerConfig.Global.HTTPConfig
|
||||
|
||||
for _, custom := range c.customConfigs {
|
||||
for _, gc := range custom.GoogleChat {
|
||||
if gc.HTTPConfig == nil {
|
||||
gc.HTTPConfig = httpDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) CreateRuleIDMatcher(ruleID string, receiverNames []string) error {
|
||||
if c.alertmanagerConfig.Route == nil {
|
||||
return errors.New(errors.TypeInvalidInput, ErrCodeAlertmanagerConfigInvalid, "route is nil")
|
||||
@@ -425,7 +318,9 @@ func (c *Config) CreateRuleIDMatcher(ruleID string, receiverNames []string) erro
|
||||
}
|
||||
}
|
||||
|
||||
c.flush()
|
||||
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -444,7 +339,9 @@ func (c *Config) DeleteRuleIDInhibitor(ruleID string) error {
|
||||
}
|
||||
}
|
||||
c.alertmanagerConfig.InhibitRules = filteredRules
|
||||
c.flush()
|
||||
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -465,7 +362,9 @@ func (c *Config) DeleteRuleIDMatcher(ruleID string) error {
|
||||
}
|
||||
}
|
||||
|
||||
c.flush()
|
||||
c.storeableConfig.Config = string(newRawFromConfig(c.alertmanagerConfig))
|
||||
c.storeableConfig.Hash = fmt.Sprintf("%x", newConfigHash(c.storeableConfig.Config))
|
||||
c.storeableConfig.UpdatedAt = time.Now()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ func TestCreateRuleIDMatcher(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, receiver := range tc.receivers {
|
||||
err := cfg.CreateReceiver(&Receiver{Receiver: &receiver})
|
||||
err := cfg.CreateReceiver(receiver)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ func TestDeleteRuleIDMatcher(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, receiver := range tc.receivers {
|
||||
err := cfg.CreateReceiver(&Receiver{Receiver: &receiver})
|
||||
err := cfg.CreateReceiver(receiver)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
@@ -329,68 +329,3 @@ func TestSetGlobalConfigPreservesSMTPRequireTLS(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigPreservesGoogleChatConfigs is the round-trip proof for the
|
||||
// native-notifier seam: create a Config with a GoogleChat receiver, serialize
|
||||
// to the storeable blob, reload via NewConfigFromStoreableConfig, and verify
|
||||
// the GoogleChatConfigs survive the trip — through both the blob and the
|
||||
// in-memory sidecar — and that GetReceiver zips them back into a *Receiver.
|
||||
func TestConfigPreservesGoogleChatConfigs(t *testing.T) {
|
||||
webhookURL, err := url.Parse("https://chat.googleapis.com/v1/spaces/test/messages")
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg, err := NewDefaultConfig(
|
||||
GlobalConfig{SMTPSmarthost: config.HostPort{Host: "localhost", Port: "25"}, SMTPFrom: "test@example.com"},
|
||||
RouteConfig{GroupInterval: time.Minute, GroupWait: time.Minute, RepeatInterval: time.Minute},
|
||||
"1",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
receiver := &Receiver{
|
||||
Receiver: &config.Receiver{Name: "googlechat-receiver"},
|
||||
GoogleChatConfigs: []*GoogleChatReceiverConfig{
|
||||
{
|
||||
WebhookURL: &config.SecretURL{URL: webhookURL},
|
||||
Title: "Alert",
|
||||
Text: "Body",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, cfg.CreateReceiver(receiver))
|
||||
|
||||
// In-memory: GetReceiver zips the base and the native sidecar back together.
|
||||
got, err := cfg.GetReceiver("googlechat-receiver")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got.GoogleChatConfigs, 1)
|
||||
assert.Equal(t, "Alert", got.GoogleChatConfigs[0].Title)
|
||||
assert.Equal(t, "Body", got.GoogleChatConfigs[0].Text)
|
||||
|
||||
// Global threading: HTTPConfig was nil on input, so applyNativeDefaults
|
||||
// should have filled it in from Config.Global.HTTPConfig. This mirrors
|
||||
// upstream's webhook/email/slack defaulting in config.Config.UnmarshalYAML.
|
||||
require.NotNil(t, got.GoogleChatConfigs[0].HTTPConfig, "HTTPConfig should be threaded from Global")
|
||||
assert.Same(t, cfg.alertmanagerConfig.Global.HTTPConfig, got.GoogleChatConfigs[0].HTTPConfig)
|
||||
|
||||
// Persisted blob: reload it and confirm the same.
|
||||
reloaded, err := NewConfigFromStoreableConfig(cfg.StoreableConfig())
|
||||
require.NoError(t, err)
|
||||
|
||||
reloadedReceiver, err := reloaded.GetReceiver("googlechat-receiver")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reloadedReceiver.GoogleChatConfigs, 1)
|
||||
assert.Equal(t, "Alert", reloadedReceiver.GoogleChatConfigs[0].Title)
|
||||
assert.Equal(t, "Body", reloadedReceiver.GoogleChatConfigs[0].Text)
|
||||
assert.Equal(t, "https://chat.googleapis.com/v1/spaces/test/messages", reloadedReceiver.GoogleChatConfigs[0].WebhookURL.String())
|
||||
// HTTPConfig persisted into the blob and re-hydrated on load.
|
||||
require.NotNil(t, reloadedReceiver.GoogleChatConfigs[0].HTTPConfig)
|
||||
|
||||
// Update path keeps the sidecar in sync.
|
||||
receiver.GoogleChatConfigs[0].Title = "Updated"
|
||||
require.NoError(t, cfg.UpdateReceiver(receiver))
|
||||
|
||||
updated, err := cfg.GetReceiver("googlechat-receiver")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, updated.GoogleChatConfigs, 1)
|
||||
assert.Equal(t, "Updated", updated.GoogleChatConfigs[0].Title)
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
)
|
||||
|
||||
// GoogleChatReceiverConfig is a SigNoz-native notifier config that upstream
|
||||
// alertmanager does not know about. It is carried on Receiver alongside the
|
||||
// embedded *config.Receiver and round-trips through JSON via that embed's
|
||||
// struct tags — Channel.Data and the stored config blob preserve it
|
||||
// automatically without any separate registry or marshalling.
|
||||
//
|
||||
// The shape mirrors upstream's notifier configs (e.g. SlackConfig): the
|
||||
// inline-embedded NotifierConfig contributes send_resolved + the
|
||||
// SendResolved() method that the notify pipeline uses to gate resolved
|
||||
// notifications, and HTTPConfig is filled in from Config.Global.HTTPConfig
|
||||
// when omitted (see Config.applyNativeDefaults). Only the config shape is
|
||||
// defined here; a future notifier package would consume these fields, POST
|
||||
// to the webhook, and implement notify.Notifier.
|
||||
type GoogleChatReceiverConfig struct {
|
||||
config.NotifierConfig `yaml:",inline" json:",inline"`
|
||||
|
||||
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`
|
||||
|
||||
WebhookURL *config.SecretURL `yaml:"webhook_url,omitempty" json:"webhook_url,omitempty"`
|
||||
Title string `yaml:"title,omitempty" json:"title,omitempty"`
|
||||
Text string `yaml:"text,omitempty" json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultGoogleChatReceiverConfig holds the defaults applied by
|
||||
// GoogleChatReceiverConfig.UnmarshalYAML before user-specified fields are
|
||||
// overlaid. Mirrors upstream's DefaultSlackConfig / DefaultPagerdutyConfig.
|
||||
var DefaultGoogleChatReceiverConfig = GoogleChatReceiverConfig{
|
||||
NotifierConfig: config.NotifierConfig{
|
||||
VSendResolved: false,
|
||||
},
|
||||
Title: `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`,
|
||||
Text: `{{ range .Alerts -}}
|
||||
*Alert:* {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}{{ if .Annotations.summary }}
|
||||
*Summary:* {{ .Annotations.summary }}{{ end }}{{ if .Annotations.description }}
|
||||
*Description:* {{ .Annotations.description }}{{ end }}
|
||||
{{ end }}`,
|
||||
}
|
||||
|
||||
// UnmarshalYAML implements the per-config defaulting pattern used by every
|
||||
// upstream notifier config: install the defaults first, then overlay the
|
||||
// user-specified fields. Triggered by the yaml round-trip in NewReceiver.
|
||||
func (c *GoogleChatReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
*c = DefaultGoogleChatReceiverConfig
|
||||
type plain GoogleChatReceiverConfig
|
||||
return unmarshal((*plain)(c))
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func TestAddRuleIDToRoute(t *testing.T) {
|
||||
{
|
||||
name: "Simple",
|
||||
route: func() *config.Route {
|
||||
route, err := NewRouteFromReceiver(&Receiver{Receiver: &config.Receiver{Name: "test"}})
|
||||
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
return route
|
||||
@@ -33,7 +33,7 @@ func TestAddRuleIDToRoute(t *testing.T) {
|
||||
{
|
||||
name: "AlreadyExists",
|
||||
route: func() *config.Route {
|
||||
route, err := NewRouteFromReceiver(&Receiver{Receiver: &config.Receiver{Name: "test"}})
|
||||
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = addRuleIDToRoute(route, "1")
|
||||
@@ -84,7 +84,7 @@ func TestRemoveRuleIDFromRoute(t *testing.T) {
|
||||
{
|
||||
name: "Simple",
|
||||
route: func() *config.Route {
|
||||
route, err := NewRouteFromReceiver(&Receiver{Receiver: &config.Receiver{Name: "test"}})
|
||||
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = addRuleIDToRoute(route, "1")
|
||||
@@ -98,7 +98,7 @@ func TestRemoveRuleIDFromRoute(t *testing.T) {
|
||||
{
|
||||
name: "DoesNotExist",
|
||||
route: func() *config.Route {
|
||||
route, err := NewRouteFromReceiver(&Receiver{Receiver: &config.Receiver{Name: "test"}})
|
||||
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
return route
|
||||
@@ -109,7 +109,7 @@ func TestRemoveRuleIDFromRoute(t *testing.T) {
|
||||
{
|
||||
name: "DeleteMatcher",
|
||||
route: func() *config.Route {
|
||||
route, err := NewRouteFromReceiver(&Receiver{Receiver: &config.Receiver{Name: "test"}})
|
||||
route, err := NewRouteFromReceiver(Receiver{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
||||
return route
|
||||
|
||||
@@ -17,4 +17,4 @@ type Templater interface {
|
||||
|
||||
// ReceiverIntegrationsFunc constructs the notify.Integration list for a
|
||||
// configured receiver.
|
||||
type ReceiverIntegrationsFunc = func(nc *Receiver, tmpl *template.Template, logger *slog.Logger, templater Templater) ([]notify.Integration, error)
|
||||
type ReceiverIntegrationsFunc = func(nc Receiver, tmpl *template.Template, logger *slog.Logger, templater Templater) ([]notify.Integration, error)
|
||||
|
||||
@@ -17,97 +17,40 @@ import (
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
// Receiver is the SigNoz receiver type. It embeds the upstream alertmanager
|
||||
// config.Receiver so every upstream notifier field (slack_configs,
|
||||
// webhook_configs, email_configs, ...) is promoted inline and round-trips
|
||||
// through encoding/json transparently.
|
||||
//
|
||||
// The struct is also the home for SigNoz-native notifier configs that upstream
|
||||
// alertmanager does not know about (currently: GoogleChatConfigs). Because
|
||||
// config.Receiver has no custom (Un)MarshalJSON, declaring a sibling field on
|
||||
// this struct with matching json/yaml tags lets json.Marshal and json.Unmarshal
|
||||
// carry that field alongside the upstream ones in a single pass — no
|
||||
// allow-list, no post-marshal patching.
|
||||
//
|
||||
// To add another native notifier, mirror GoogleChatConfigs: add a typed slice
|
||||
// field here, add the same field on customReceiverConfigs in config.go, and
|
||||
// extend customConfigsOf / isEmpty. The serialization, in-memory storage, and
|
||||
// channel-type detection all flow from there without further changes.
|
||||
type Receiver struct {
|
||||
*config.Receiver
|
||||
GoogleChatConfigs []*GoogleChatReceiverConfig `json:"googlechat_configs,omitempty" yaml:"googlechat_configs,omitempty"`
|
||||
}
|
||||
type (
|
||||
// Receiver is the type for the receiver configuration.
|
||||
Receiver = config.Receiver
|
||||
)
|
||||
|
||||
// NewReceiver builds a Receiver from its JSON representation, applying the
|
||||
// per-config defaults from each notifier's UnmarshalYAML (mirrors upstream:
|
||||
// every notifier config sets `*c = DefaultXxxConfig` first, then overlays the
|
||||
// user-specified fields). Global-scoped defaulting (HTTPConfig from
|
||||
// Config.Global.HTTPConfig) happens later, in Config.applyNativeDefaults.
|
||||
//
|
||||
// The only default missed is `send_resolved` (a bool) which, if absent from
|
||||
// the input, stays false.
|
||||
func NewReceiver(input string) (*Receiver, error) {
|
||||
receiver := &Receiver{Receiver: &config.Receiver{}}
|
||||
if err := json.Unmarshal([]byte(input), receiver); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Default the embedded upstream base. Each upstream *Config's UnmarshalYAML
|
||||
// runs during the yaml roundtrip and applies its DefaultXxxConfig.
|
||||
withDefaults, err := defaultedBaseReceiver(receiver.Receiver)
|
||||
// Creates a new receiver from a string. The input is initialized with the default values from the upstream alertmanager.
|
||||
// The only default value which is missed is `send_resolved` (as it is a bool) which if not set in the input will always be set to `false`.
|
||||
func NewReceiver(input string) (Receiver, error) {
|
||||
receiver := Receiver{}
|
||||
err := json.Unmarshal([]byte(input), &receiver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receiver.Receiver = withDefaults
|
||||
|
||||
// Default each SigNoz-native notifier config the same way. Extend this
|
||||
// block when adding another native notifier type.
|
||||
for i, gc := range receiver.GoogleChatConfigs {
|
||||
defaulted, err := defaultedNotifierConfig(gc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receiver.GoogleChatConfigs[i] = defaulted
|
||||
return Receiver{}, err
|
||||
}
|
||||
|
||||
return receiver, nil
|
||||
}
|
||||
|
||||
func defaultedBaseReceiver(base *config.Receiver) (*config.Receiver, error) {
|
||||
bytes, err := yaml.Marshal(base)
|
||||
// We marshal and unmarshal the receiver to ensure that the receiver is
|
||||
// initialized with defaults from the upstream alertmanager.
|
||||
bytes, err := yaml.Marshal(receiver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return Receiver{}, err
|
||||
}
|
||||
|
||||
withDefaults := &config.Receiver{}
|
||||
if err := yaml.Unmarshal(bytes, withDefaults); err != nil {
|
||||
return nil, err
|
||||
receiverWithDefaults := Receiver{}
|
||||
if err := yaml.Unmarshal(bytes, &receiverWithDefaults); err != nil {
|
||||
return Receiver{}, err
|
||||
}
|
||||
|
||||
if err := withDefaults.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
|
||||
return nil, err
|
||||
if err := receiverWithDefaults.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
|
||||
return Receiver{}, err
|
||||
}
|
||||
|
||||
return withDefaults, nil
|
||||
return receiverWithDefaults, nil
|
||||
}
|
||||
|
||||
// defaultedNotifierConfig applies a single notifier config's per-config defaults
|
||||
// by round-tripping it through yaml. UnmarshalYAML on the config type runs
|
||||
// during the unmarshal step and installs DefaultXxxConfig before re-overlaying
|
||||
// the user-specified fields (the standard upstream defaulting pattern).
|
||||
func defaultedNotifierConfig[T any](cfg *T) (*T, error) {
|
||||
bytes, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := new(T)
|
||||
if err := yaml.Unmarshal(bytes, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestReceiver(ctx context.Context, receiver *Receiver, receiverIntegrationsFunc ReceiverIntegrationsFunc, config *Config, tmpl *template.Template, logger *slog.Logger, templater Templater, lSet model.LabelSet, alert ...*Alert) error {
|
||||
func TestReceiver(ctx context.Context, receiver Receiver, receiverIntegrationsFunc ReceiverIntegrationsFunc, config *Config, tmpl *template.Template, logger *slog.Logger, templater Templater, lSet model.LabelSet, alert ...*Alert) error {
|
||||
ctx = notify.WithGroupKey(ctx, fmt.Sprintf("%s-%s-%d", receiver.Name, lSet.Fingerprint(), time.Now().Unix()))
|
||||
ctx = notify.WithGroupLabels(ctx, lSet)
|
||||
ctx = notify.WithReceiverName(ctx, receiver.Name)
|
||||
@@ -124,12 +67,12 @@ func TestReceiver(ctx context.Context, receiver *Receiver, receiverIntegrationsF
|
||||
return err
|
||||
}
|
||||
|
||||
defaultedReceiver, err := testConfig.GetReceiver(receiver.Name)
|
||||
receiver, err = testConfig.GetReceiver(receiver.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
integrations, err := receiverIntegrationsFunc(defaultedReceiver, tmpl, logger, templater)
|
||||
integrations, err := receiverIntegrationsFunc(receiver, tmpl, logger, templater)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -21,18 +21,6 @@ func TestNewReceiver(t *testing.T) {
|
||||
expected: `{"name":"telegram","telegram_configs":[{"send_resolved":false,"token":"1234567890","chat":12345,"message":"{{ template \"telegram.default.message\" . }}","parse_mode":"HTML"}]}`,
|
||||
pass: true,
|
||||
},
|
||||
{
|
||||
// GoogleChatConfig exercises the SigNoz-native side of the
|
||||
// Receiver embed: googlechat_configs is unmarshalled into the
|
||||
// sibling field and re-marshalled alongside the upstream fields
|
||||
// in a single pass. send_resolved is contributed by the embedded
|
||||
// NotifierConfig and is always emitted (no omitempty), matching
|
||||
// upstream's behaviour for every other notifier config.
|
||||
name: "GoogleChatConfig",
|
||||
input: `{"name":"googlechat","googlechat_configs":[{"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages","title":"Alert","text":"Body"}]}`,
|
||||
expected: `{"name":"googlechat","googlechat_configs":[{"send_resolved":false,"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages","title":"Alert","text":"Body"}]}`,
|
||||
pass: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -51,35 +39,3 @@ func TestNewReceiver(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewReceiverGoogleChatAppliesDefaults verifies the per-config defaulting
|
||||
// mechanism for SigNoz-native configs: when the user omits Title / Text /
|
||||
// send_resolved, GoogleChatReceiverConfig.UnmarshalYAML installs the values
|
||||
// from DefaultGoogleChatReceiverConfig before any user-specified fields are
|
||||
// overlaid. This mirrors how every upstream notifier config defaults itself
|
||||
// (e.g. DefaultSlackConfig).
|
||||
func TestNewReceiverGoogleChatAppliesDefaults(t *testing.T) {
|
||||
receiver, err := NewReceiver(`{"name":"googlechat","googlechat_configs":[{"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages"}]}`)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, receiver.GoogleChatConfigs, 1)
|
||||
|
||||
got := receiver.GoogleChatConfigs[0]
|
||||
assert.Equal(t, DefaultGoogleChatReceiverConfig.Title, got.Title, "Title should fall back to the default template")
|
||||
assert.Equal(t, DefaultGoogleChatReceiverConfig.Text, got.Text, "Text should fall back to the default template")
|
||||
assert.Equal(t, DefaultGoogleChatReceiverConfig.VSendResolved, got.SendResolved(), "send_resolved should fall back to the default")
|
||||
}
|
||||
|
||||
// TestNewReceiverGoogleChatPreservesUserOverrides verifies that user-specified
|
||||
// values survive the defaulting pass — the default is installed first, then
|
||||
// the user's fields are overlaid. send_resolved=true from the input must win
|
||||
// over the default's false.
|
||||
func TestNewReceiverGoogleChatPreservesUserOverrides(t *testing.T) {
|
||||
receiver, err := NewReceiver(`{"name":"googlechat","googlechat_configs":[{"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages","title":"X","text":"Y","send_resolved":true}]}`)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, receiver.GoogleChatConfigs, 1)
|
||||
|
||||
got := receiver.GoogleChatConfigs[0]
|
||||
assert.Equal(t, "X", got.Title)
|
||||
assert.Equal(t, "Y", got.Text)
|
||||
assert.True(t, got.SendResolved())
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func NewRouteFromRouteConfig(route *config.Route, cfg RouteConfig) (*config.Rout
|
||||
return route, nil
|
||||
}
|
||||
|
||||
func NewRouteFromReceiver(receiver *Receiver) (*config.Route, error) {
|
||||
func NewRouteFromReceiver(receiver Receiver) (*config.Route, error) {
|
||||
route := &config.Route{Receiver: receiver.Name, Continue: true, Matchers: config.Matchers{noRuleIDMatcher}}
|
||||
if err := route.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
|
||||
return nil, err
|
||||
|
||||
Reference in New Issue
Block a user