Compare commits

..

9 Commits

Author SHA1 Message Date
Gaurav Tewari
b02e17a0d4 chore: minor comments 2026-05-27 14:04:05 +05:30
Gaurav Tewari
637e194d1c chore: more changes 2026-05-27 13:12:31 +05:30
Gaurav Tewari
9eb8d6466c Merge remote-tracking branch 'origin' into feat/add-ripple-effect 2026-05-27 12:38:31 +05:30
Gaurav Tewari
0aec7a9fe8 fix: tests 2026-05-25 23:22:50 +05:30
Gaurav Tewari
bebe4ebb89 fix: update theme 2026-05-25 23:17:50 +05:30
Gaurav Tewari
2209708caa chore: more fixes 2026-05-25 22:53:36 +05:30
Gaurav Tewari
1f813ce21f refactor: update effect 2026-05-25 22:38:01 +05:30
Gaurav Tewari
f082821ac2 feat: add light and dark mode in command pallet 2026-05-25 13:30:44 +05:30
Gaurav Tewari
3c5bd81421 chore: add ripple effect 2026-05-23 22:15:00 +05:30
32 changed files with 806 additions and 594 deletions

View File

@@ -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({

View File

@@ -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());

View File

@@ -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 {

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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 => {

View File

@@ -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');
});
});

View File

@@ -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];

View File

@@ -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,

View 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;

View File

@@ -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;
}
}

View 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);
});
});

View 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);
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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() {

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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...)

View File

@@ -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,
)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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))
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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())
}

View File

@@ -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