Compare commits

...

9 Commits

Author SHA1 Message Date
Abhishek Kumar Singh
0d47ea0bd8 test: fix test to match expected return type from ThresholdRuleEval 2025-12-15 20:15:12 +05:30
Abhishek Kumar Singh
6bdeb54bd6 refactor: added RecoveryTarget in sample returned with SendUnmatched to maintain consistency 2025-12-15 19:32:45 +05:30
Abhishek Kumar Singh
f865442d5b test: added test cases for sendUnmatched 2025-12-15 19:19:56 +05:30
Abhishek Kumar Singh
fa50bd7564 Merge branch 'main' into feat/send_test_notification_correctly 2025-12-15 16:55:57 +05:30
Abhishek Kumar Singh
cad62c4be5 fix: skip recovery threshold check for test notification post SendUnmatched eval (un-necessary) 2025-12-15 16:53:12 +05:30
Nikhil Mantri
497972f23c chore(metrics-explorer): address follow-up comments (#9730) 2025-12-15 14:59:30 +05:30
swapnil-signoz
a9e30919d1 Refactor/aws api gateway dashboard (#9763)
* refactor: updating api gateway dashboard to support multiple types of APIs i.e. REST, HTTP and Websocket.
2025-12-15 14:34:39 +05:30
Ishan
925c4c4a3d fix: UI/UX fixes on Global Actions (CMD / CTRL + K) (#9739)
* feat: command K palette , removed kbar

* chore: updated cmdk for login checks and icons

* feat: updated icons and test cases

* adding more llm monitoring sources to onbaording(frontend) (#9623)

* feat: code update, PR comment fix, package.json update

* feat: code update, removed expand icon, moved keyboard func

* feat: css variable update

* feat: removed kbar from applayout

* feat: updated cursor bot comments

* feat: updated cursor bot and test case file

* feat: scss formatted

* feat: deleted unwanted merge change

---------

Co-authored-by: gkarthi-signoz <goutham@signoz.io>
Co-authored-by: Aditya Singh <adityasinghssj1@gmail.com>
2025-12-15 08:41:00 +05:30
Abhishek Kumar Singh
ff7bc3017f feat: return sample from Eval function if not matched but requested unmatched sample 2025-11-27 17:10:16 +05:30
35 changed files with 4353 additions and 2068 deletions

View File

@@ -247,7 +247,8 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
}
}
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
})
if err != nil {
return nil, err
@@ -299,7 +300,8 @@ func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID,
}
}
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
})
if err != nil {
return nil, err

View File

@@ -48,6 +48,7 @@
"@signozhq/calendar": "0.0.0",
"@signozhq/callout": "0.0.2",
"@signozhq/checkbox": "0.0.2",
"@signozhq/command": "0.0.0",
"@signozhq/design-tokens": "1.1.4",
"@signozhq/input": "0.0.2",
"@signozhq/popover": "0.0.0",
@@ -104,7 +105,6 @@
"i18next-http-backend": "^1.3.2",
"jest": "^27.5.1",
"js-base64": "^3.7.2",
"kbar": "0.1.0-beta.48",
"less": "^4.1.2",
"less-loader": "^10.2.0",
"lodash-es": "^4.17.21",

View File

@@ -4,7 +4,7 @@ import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import AppLoading from 'components/AppLoading/AppLoading';
import KBarCommandPalette from 'components/KBarCommandPalette/KBarCommandPalette';
import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import { FeatureKeys } from 'constants/features';
@@ -24,9 +24,9 @@ import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFall
import posthog from 'posthog-js';
import { useAppContext } from 'providers/App/App';
import { IUser } from 'providers/App/types';
import { CmdKProvider } from 'providers/cmdKProvider';
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { KBarCommandPaletteProvider } from 'providers/KBarCommandPaletteProvider';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import { Suspense, useCallback, useEffect, useState } from 'react';
@@ -364,10 +364,10 @@ function App(): JSX.Element {
<ConfigProvider theme={themeConfig}>
<Router history={history}>
<CompatRouter>
<KBarCommandPaletteProvider>
<KBarCommandPalette />
<CmdKProvider>
<NotificationProvider>
<ErrorModalProvider>
{isLoggedInState && <CmdKPalette userRole={user.role} />}
<PrivateRoute>
<ResourceProvider>
<QueryBuilderProvider>
@@ -398,7 +398,7 @@ function App(): JSX.Element {
</PrivateRoute>
</ErrorModalProvider>
</NotificationProvider>
</KBarCommandPaletteProvider>
</CmdKProvider>
</CompatRouter>
</Router>
</ConfigProvider>

View File

@@ -14,6 +14,8 @@ import '@signozhq/badge';
import '@signozhq/button';
import '@signozhq/calendar';
import '@signozhq/callout';
import '@signozhq/checkbox';
import '@signozhq/command';
import '@signozhq/design-tokens';
import '@signozhq/input';
import '@signozhq/popover';

View File

@@ -1,152 +0,0 @@
.kbar-command-palette__positioner {
position: fixed;
inset: 0;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(6px);
z-index: 50;
}
.kbar-command-palette__animator {
width: 100%;
max-width: 600px;
}
.kbar-command-palette__card {
background: var(--bg-ink-500);
color: var(--text-vanilla-100);
border-radius: 3px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
overflow: hidden;
display: flex;
flex-direction: column;
}
.kbar-command-palette__search {
padding: 12px 16px;
font-size: 13px;
border: none;
border-bottom: 1px solid var(--border-ink-200);
color: var(--text-vanilla-100);
outline: none;
background-color: var(--bg-ink-500);
}
.kbar-command-palette__section {
padding: 8px 16px 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-robin-500);
font-family: 'Inter', sans-serif;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.kbar-command-palette__item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
font-size: 13px;
cursor: pointer;
transition: background 0.15s ease;
}
.kbar-command-palette__item:hover,
.kbar-command-palette__item--active {
background: var(--bg-ink-400);
}
.kbar-command-palette__icon {
flex-shrink: 0;
width: 18px;
height: 18px;
color: #444;
}
.kbar-command-palette__shortcut {
margin-left: auto;
display: flex;
gap: 4px;
}
.kbar-command-palette__key {
padding: 2px 6px;
font-size: 12px;
border-radius: 4px;
background: var(--bg-ink-300);
color: var(--text-vanilla-300);
text-transform: uppercase;
font-family: 'Space Mono', monospace;
}
.kbar-command-palette__results-container {
div {
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-200);
}
}
}
.lightMode {
.kbar-command-palette__positioner {
background: rgba(0, 0, 0, 0.5);
}
.kbar-command-palette__card {
background: #fff;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
.kbar-command-palette__search {
border-bottom: 1px solid #e5e5e5;
color: var(--text-ink-500);
background-color: var(--bg-vanilla-100);
}
.kbar-command-palette__item {
color: var(--text-ink-500);
}
.kbar-command-palette__item:hover,
.kbar-command-palette__item--active {
background: #f5f5f5;
}
.kbar-command-palette__icon {
color: #444;
}
.kbar-command-palette__key {
background: #eee;
color: #555;
}
.kbar-command-palette__results-container {
div {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-vanilla-300);
}
}
}
}

View File

@@ -1,69 +0,0 @@
import './KBarCommandPalette.scss';
import {
KBarAnimator,
KBarPortal,
KBarPositioner,
KBarResults,
KBarSearch,
useMatches,
} from 'kbar';
function Results(): JSX.Element {
const { results } = useMatches();
const renderResults = ({
item,
active,
}: {
item: any;
active: boolean;
}): JSX.Element =>
typeof item === 'string' ? (
<div className="kbar-command-palette__section">{item}</div>
) : (
<div
className={`kbar-command-palette__item ${
active ? 'kbar-command-palette__item--active' : ''
}`}
>
{item.icon}
<span>{item.name}</span>
{item.shortcut?.length ? (
<span className="kbar-command-palette__shortcut">
{item.shortcut.map((sc: string) => (
<kbd key={sc} className="kbar-command-palette__key">
{sc}
</kbd>
))}
</span>
) : null}
</div>
);
return (
<div className="kbar-command-palette__results-container">
<KBarResults items={results} onRender={renderResults} />
</div>
);
}
function KBarCommandPalette(): JSX.Element {
return (
<KBarPortal>
<KBarPositioner className="kbar-command-palette__positioner">
<KBarAnimator className="kbar-command-palette__animator">
<div className="kbar-command-palette__card">
<KBarSearch
className="kbar-command-palette__search"
placeholder="Search or type a command..."
/>
<Results />
</div>
</KBarAnimator>
</KBarPositioner>
</KBarPortal>
);
}
export default KBarCommandPalette;

View File

@@ -0,0 +1,208 @@
/**
* src/components/cmdKPalette/__test__/cmdkPalette.test.tsx
*/
import '@testing-library/jest-dom/extend-expect';
// ---- Mocks (must run BEFORE importing the component) ----
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { render, screen, userEvent } from 'tests/test-utils';
import { CmdKPalette } from '../cmdKPalette';
const HOME_LABEL = 'Go to Home';
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: jest.fn(),
});
});
afterAll(() => {
// restore
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete (HTMLElement.prototype as any).scrollIntoView;
});
// mock history.push / replace / go / location
jest.mock('lib/history', () => {
const location = { pathname: '/', search: '', hash: '' };
const stack: { pathname: string; search: string }[] = [
{ pathname: '/', search: '' },
];
const push = jest.fn((path: string) => {
const [rawPath, rawQuery] = path.split('?');
const pathname = rawPath || '/';
const search = path.includes('?') ? `?${rawQuery || ''}` : '';
location.pathname = pathname;
location.search = search;
stack.push({ pathname, search });
return undefined;
});
const replace = jest.fn((path: string) => {
const [rawPath, rawQuery] = path.split('?');
const pathname = rawPath || '/';
const search = path.includes('?') ? `?${rawQuery || ''}` : '';
location.pathname = pathname;
location.search = search;
if (stack.length > 0) {
stack[stack.length - 1] = { pathname, search };
} else {
stack.push({ pathname, search });
}
return undefined;
});
const listen = jest.fn();
const go = jest.fn((n: number) => {
if (n < 0 && stack.length > 1) {
stack.pop();
}
const top = stack[stack.length - 1] || { pathname: '/', search: '' };
location.pathname = top.pathname;
location.search = top.search;
});
return {
push,
replace,
listen,
go,
location,
__stack: stack,
};
});
// Mock ResizeObserver for Jest/jsdom
class ResizeObserver {
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
observe() {}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
unobserve() {}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
disconnect() {}
}
(global as any).ResizeObserver = ResizeObserver;
// mock cmdK provider hook (open state + setter)
const mockSetOpen = jest.fn();
jest.mock('providers/cmdKProvider', (): unknown => ({
useCmdK: (): {
open: boolean;
setOpen: jest.Mock;
openCmdK: jest.Mock;
closeCmdK: jest.Mock;
} => ({
open: true,
setOpen: mockSetOpen,
openCmdK: jest.fn(),
closeCmdK: jest.fn(),
}),
}));
// mock notifications hook
jest.mock('hooks/useNotifications', (): unknown => ({
useNotifications: (): { notifications: [] } => ({ notifications: [] }),
}));
// mock theme hook
jest.mock('hooks/useDarkMode', (): unknown => ({
useThemeMode: (): {
setAutoSwitch: jest.Mock;
setTheme: jest.Mock;
theme: string;
} => ({
setAutoSwitch: jest.fn(),
setTheme: jest.fn(),
theme: 'dark',
}),
}));
// mock updateUserPreference API and react-query mutation
jest.mock('api/v1/user/preferences/name/update', (): jest.Mock => jest.fn());
jest.mock('react-query', (): unknown => {
const actual = jest.requireActual('react-query');
return {
...actual,
useMutation: (): { mutate: jest.Mock } => ({ mutate: jest.fn() }),
};
});
// mock other side-effecty modules
jest.mock('api/common/logEvent', () => jest.fn());
jest.mock('api/browser/localstorage/set', () => jest.fn());
jest.mock('utils/error', () => ({ showErrorNotification: jest.fn() }));
// ---- Tests ----
describe('CmdKPalette', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('renders navigation and settings groups and items', () => {
render(<CmdKPalette userRole="ADMIN" />);
expect(screen.getByText('Navigation')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
expect(screen.getByText('Go to Dashboards')).toBeInTheDocument();
expect(screen.getByText('Open Sidebar')).toBeInTheDocument();
expect(screen.getByText('Switch to Dark Mode')).toBeInTheDocument();
});
test('clicking a navigation item calls history.push with correct route', async () => {
render(<CmdKPalette userRole="ADMIN" />);
const homeItem = screen.getByText(HOME_LABEL);
await userEvent.click(homeItem);
expect(history.push).toHaveBeenCalledWith(ROUTES.HOME);
});
test('role-based filtering (basic smoke)', () => {
render(<CmdKPalette userRole="VIEWER" />);
// VIEWER still sees basic navigation items
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
});
test('keyboard shortcut opens palette via setOpen', () => {
render(<CmdKPalette userRole="ADMIN" />);
const event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true });
window.dispatchEvent(event);
expect(mockSetOpen).toHaveBeenCalledWith(true);
});
test('items render with icons when provided', () => {
render(<CmdKPalette userRole="ADMIN" />);
const iconHolders = document.querySelectorAll('.cmd-item-icon');
expect(iconHolders.length).toBeGreaterThan(0);
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
});
test('closing the palette via handleInvoke sets open to false', async () => {
render(<CmdKPalette userRole="ADMIN" />);
const dashItem = screen.getByText('Go to Dashboards');
await userEvent.click(dashItem);
// last call from handleInvoke should set open to false
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});

View File

@@ -0,0 +1,55 @@
/* Overlay stays below content */
[data-slot='dialog-overlay'] {
z-index: 50;
}
/* Dialog content always above overlay */
[data-slot='dialog-content'] {
position: fixed;
z-index: 60;
}
.cmdk-section-heading [cmdk-group-heading] {
text-transform: uppercase;
color: var(--bg-slate-100);
}
/* Hide scrollbar but keep scroll */
.cmdk-list-scroll {
scrollbar-width: none; /* Firefox */
}
.cmdk-list-scroll::-webkit-scrollbar {
display: none; /* Chrome, Safari, Edge */
}
.cmdk-list-scroll {
-webkit-overflow-scrolling: touch;
}
.cmdk-input-wrapper {
margin-left: 8px;
}
.cmdk-item-light:hover {
cursor: pointer;
background-color: var(--bg-vanilla-200) !important;
}
.cmdk-item-light[data-selected='true'] {
background-color: var(--bg-vanilla-300) !important;
color: var(--bg-ink-500);
}
.cmdk-item {
cursor: pointer;
}
[cmdk-item] svg {
width: auto;
height: auto;
}
.cmd-item-icon {
margin-right: 8px;
}

View File

@@ -0,0 +1,336 @@
import './cmdKPalette.scss';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from '@signozhq/command';
import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { AxiosError } from 'axios';
import ROUTES from 'constants/routes';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { useThemeMode } from 'hooks/useDarkMode';
import { THEME_MODE } from 'hooks/useDarkMode/constant';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import {
BellDot,
BugIcon,
DraftingCompass,
Expand,
HardDrive,
Home,
LayoutGrid,
ListMinus,
ScrollText,
Settings,
} from 'lucide-react';
import React, { useEffect } from 'react';
import { useMutation } from 'react-query';
import { UserPreference } from 'types/api/preferences/preference';
import { showErrorNotification } from 'utils/error';
import { useAppContext } from '../../providers/App/App';
import { useCmdK } from '../../providers/cmdKProvider';
type CmdAction = {
id: string;
name: string;
shortcut?: string[];
keywords?: string;
section?: string;
icon?: React.ReactNode;
roles?: UserRole[];
perform: () => void;
};
type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
export function CmdKPalette({
userRole,
}: {
userRole: UserRole;
}): JSX.Element | null {
const { open, setOpen } = useCmdK();
const { updateUserPreferenceInContext } = useAppContext();
const { notifications } = useNotifications();
const { setAutoSwitch, setTheme, theme } = useThemeMode();
const { mutate: updateUserPreferenceMutation } = useMutation(
updateUserPreference,
{
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
// toggle palette with ⌘/Ctrl+K
function handleGlobalCmdK(
e: KeyboardEvent,
setOpen: React.Dispatch<React.SetStateAction<boolean>>,
): void {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setOpen(true);
}
}
const cmdKEffect = (): void | (() => void) => {
const listener = (e: KeyboardEvent): void => {
handleGlobalCmdK(e, setOpen);
};
window.addEventListener('keydown', listener);
return (): void => {
window.removeEventListener('keydown', listener);
setOpen(false);
};
};
useEffect(cmdKEffect, [setOpen]);
function handleThemeChange(value: string): void {
logEvent('Account Settings: Theme Changed', { theme: value });
if (value === 'auto') {
setAutoSwitch(true);
} else {
setAutoSwitch(false);
setTheme(value);
}
}
function onClickHandler(key: string): void {
history.push(key);
}
function handleOpenSidebar(): void {
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'true');
const save = { name: USER_PREFERENCES.SIDENAV_PINNED, value: true };
updateUserPreferenceInContext(save as UserPreference);
updateUserPreferenceMutation({
name: USER_PREFERENCES.SIDENAV_PINNED,
value: true,
});
}
function handleCloseSidebar(): void {
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'false');
const save = { name: USER_PREFERENCES.SIDENAV_PINNED, value: false };
updateUserPreferenceInContext(save as UserPreference);
updateUserPreferenceMutation({
name: USER_PREFERENCES.SIDENAV_PINNED,
value: false,
});
}
const actions: CmdAction[] = [
{
id: 'home',
name: 'Go to Home',
shortcut: ['shift + h'],
keywords: 'home',
section: 'Navigation',
icon: <Home size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.HOME),
},
{
id: 'dashboards',
name: 'Go to Dashboards',
shortcut: ['shift + d'],
keywords: 'dashboards',
section: 'Navigation',
icon: <LayoutGrid size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.ALL_DASHBOARD),
},
{
id: 'services',
name: 'Go to Services',
shortcut: ['shift + s'],
keywords: 'services monitoring',
section: 'Navigation',
icon: <HardDrive size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.APPLICATION),
},
{
id: 'traces',
name: 'Go to Traces',
shortcut: ['shift + t'],
keywords: 'traces',
section: 'Navigation',
icon: <DraftingCompass size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.TRACES_EXPLORER),
},
{
id: 'logs',
name: 'Go to Logs',
shortcut: ['shift + l'],
keywords: 'logs',
section: 'Navigation',
icon: <ScrollText size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.LOGS),
},
{
id: 'alerts',
name: 'Go to Alerts',
shortcut: ['shift + a'],
keywords: 'alerts',
section: 'Navigation',
icon: <BellDot size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.LIST_ALL_ALERT),
},
{
id: 'exceptions',
name: 'Go to Exceptions',
shortcut: ['shift + e'],
keywords: 'exceptions errors',
section: 'Navigation',
icon: <BugIcon size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.ALL_ERROR),
},
{
id: 'messaging-queues',
name: 'Go to Messaging Queues',
shortcut: ['shift + m'],
keywords: 'messaging queues mq',
section: 'Navigation',
icon: <ListMinus size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.MESSAGING_QUEUES_OVERVIEW),
},
{
id: 'my-settings',
name: 'Go to Account Settings',
keywords: 'account settings',
section: 'Navigation',
icon: <Settings size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.MY_SETTINGS),
},
// Settings
{
id: 'open-sidebar',
name: 'Open Sidebar',
keywords: 'sidebar navigation menu expand',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleOpenSidebar(),
},
{
id: 'collapse-sidebar',
name: 'Collapse Sidebar',
keywords: 'sidebar navigation menu collapse',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleCloseSidebar(),
},
{
id: 'dark-mode',
name: 'Switch to Dark Mode',
keywords: 'theme dark mode appearance',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleThemeChange(THEME_MODE.DARK),
},
{
id: 'light-mode',
name: 'Switch to Light Mode [Beta]',
keywords: 'theme light mode appearance',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleThemeChange(THEME_MODE.LIGHT),
},
{
id: 'system-theme',
name: 'Switch to System Theme',
keywords: 'system theme appearance',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleThemeChange(THEME_MODE.SYSTEM),
},
];
// RBAC filter: show action if no roles set OR current user role is included
const permitted = actions.filter(
(a) => !a.roles || a.roles.includes(userRole),
);
// group permitted actions by section
const grouped: [string, CmdAction[]][] = ((): [string, CmdAction[]][] => {
const map = new Map<string, CmdAction[]>();
permitted.forEach((a) => {
const section = a.section ?? 'Other';
const existing = map.get(section);
if (existing) {
existing.push(a);
} else {
map.set(section, [a]);
}
});
return Array.from(map.entries());
})();
const handleInvoke = (action: CmdAction): void => {
try {
action.perform();
} catch (e) {
console.error('Error invoking action', e);
} finally {
setOpen(false);
}
};
return (
<CommandDialog open={open} onOpenChange={setOpen} position="top" offset={110}>
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
<CommandList className="cmdk-list-scroll">
<CommandEmpty>No results</CommandEmpty>
{grouped.map(([section, items]) => (
<CommandGroup
key={section}
heading={section}
className="cmdk-section-heading"
>
{items.map((it) => (
<CommandItem
key={it.id}
onSelect={(): void => handleInvoke(it)}
value={it.name}
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
>
<span className="cmd-item-icon">{it.icon}</span>
{it.name}
{it.shortcut && it.shortcut.length > 0 && (
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
)}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
);
}

View File

@@ -35,7 +35,6 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useNotifications } from 'hooks/useNotifications';
import useTabVisibility from 'hooks/useTabFocus';
import { useKBar } from 'kbar';
import history from 'lib/history';
import { isNull } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
@@ -186,19 +185,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const { query, disabled } = useKBar((state) => ({
disabled: state.disabled,
}));
// disable the kbar command palette when not logged in
useEffect(() => {
if (isLoggedIn) {
query.disable(false);
} else {
query.disable(true);
}
}, [isLoggedIn, query, disabled]);
const changelogForTenant = isCloudUserVal
? DeploymentType.CLOUD_ONLY
: DeploymentType.OSS_ONLY;

View File

@@ -5541,4 +5541,4 @@
],
"link": "https://signoz.io/docs/userguide/envoy-metrics/"
}
]
]

View File

@@ -68,6 +68,7 @@ import { USER_ROLES } from 'types/roles';
import { checkVersionState } from 'utils/app';
import { showErrorNotification } from 'utils/error';
import { useCmdK } from '../../providers/cmdKProvider';
import { routeConfig } from './config';
import { getQueryString } from './helper';
import {
@@ -120,6 +121,7 @@ function SortableFilter({ item }: { item: SidebarItem }): JSX.Element {
// eslint-disable-next-line sonarjs/cognitive-complexity
function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
const { openCmdK } = useCmdK();
const { pathname, search } = useLocation();
const { currentVersion, latestVersion, isCurrentVersionError } = useSelector<
AppState,
@@ -637,6 +639,8 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
} else {
history.push(settingsRoute);
}
} else if (item.key === 'quick-search') {
openCmdK();
} else if (item) {
onClickHandler(item?.key as string, event);
}

View File

@@ -26,6 +26,7 @@ import {
Receipt,
Route,
ScrollText,
Search,
Settings,
Slack,
Unplug,
@@ -188,6 +189,12 @@ export const primaryMenuItems: SidebarItem[] = [
icon: <Home size={16} />,
itemKey: 'home',
},
{
key: 'quick-search',
label: 'Search',
icon: <Search size={16} />,
itemKey: 'quick-search',
},
{
key: ROUTES.LIST_ALL_ALERT,
label: 'Alerts',

View File

@@ -1,229 +0,0 @@
import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { AxiosError } from 'axios';
import ROUTES from 'constants/routes';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { useThemeMode } from 'hooks/useDarkMode';
import { THEME_MODE } from 'hooks/useDarkMode/constant';
import { useNotifications } from 'hooks/useNotifications';
import { KBarProvider } from 'kbar';
import history from 'lib/history';
import { useCallback } from 'react';
import { useMutation } from 'react-query';
import { UserPreference } from 'types/api/preferences/preference';
import { showErrorNotification } from 'utils/error';
import { useAppContext } from './App/App';
export function KBarCommandPaletteProvider({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
const { notifications } = useNotifications();
const { setAutoSwitch, setTheme } = useThemeMode();
const handleThemeChange = (value: string): void => {
logEvent('Account Settings: Theme Changed', {
theme: value,
});
if (value === 'auto') {
setAutoSwitch(true);
} else {
setAutoSwitch(false);
setTheme(value);
}
};
const onClickHandler = useCallback((key: string): void => {
history.push(key);
}, []);
const { updateUserPreferenceInContext } = useAppContext();
const { mutate: updateUserPreferenceMutation } = useMutation(
updateUserPreference,
{
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
const handleOpenSidebar = useCallback((): void => {
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'true');
// Update the context immediately
const save = {
name: USER_PREFERENCES.SIDENAV_PINNED,
value: true,
};
updateUserPreferenceInContext(save as UserPreference);
// Make the API call in the background
updateUserPreferenceMutation({
name: USER_PREFERENCES.SIDENAV_PINNED,
value: true,
});
}, [updateUserPreferenceInContext, updateUserPreferenceMutation]);
const handleCloseSidebar = useCallback((): void => {
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'false');
// Update the context immediately
const save = {
name: USER_PREFERENCES.SIDENAV_PINNED,
value: false,
};
updateUserPreferenceInContext(save as UserPreference);
// Make the API call in the background
updateUserPreferenceMutation({
name: USER_PREFERENCES.SIDENAV_PINNED,
value: false,
});
}, [updateUserPreferenceInContext, updateUserPreferenceMutation]);
const kbarActions = [
{
id: 'home',
name: 'Go to Home',
shortcut: ['shift + h'],
keywords: 'home',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.HOME);
},
},
{
id: 'dashboards',
name: 'Go to Dashboards',
shortcut: ['shift + d'],
keywords: 'dashboards',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.ALL_DASHBOARD);
},
},
{
id: 'services',
name: 'Go to Services',
shortcut: ['shift + s'],
keywords: 'services monitoring',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.APPLICATION);
},
},
{
id: 'traces',
name: 'Go to Traces',
shortcut: ['shift + t'],
keywords: 'traces',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.TRACES_EXPLORER);
},
},
{
id: 'logs',
name: 'Go to Logs',
shortcut: ['shift + l'],
keywords: 'logs',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.LOGS);
},
},
{
id: 'alerts',
name: 'Go to Alerts',
shortcut: ['shift + a'],
keywords: 'alerts',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.LIST_ALL_ALERT);
},
},
{
id: 'exceptions',
name: 'Go to Exceptions',
shortcut: ['shift + e'],
keywords: 'exceptions errors',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.ALL_ERROR);
},
},
{
id: 'messaging-queues',
name: 'Go to Messaging Queues',
shortcut: ['shift + m'],
keywords: 'messaging queues mq',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.MESSAGING_QUEUES_OVERVIEW);
},
},
{
id: 'my-settings',
name: 'Go to Account Settings',
keywords: 'account settings',
section: 'Navigation',
perform: (): void => {
onClickHandler(ROUTES.MY_SETTINGS);
},
},
{
id: 'open-sidebar',
name: 'Open Sidebar',
keywords: 'sidebar navigation menu expand',
section: 'Settings',
perform: (): void => {
handleOpenSidebar();
},
},
{
id: 'collapse-sidebar',
name: 'Collapse Sidebar',
keywords: 'sidebar navigation menu collapse',
section: 'Settings',
perform: (): void => {
handleCloseSidebar();
},
},
{
id: 'dark-mode',
name: 'Switch to Dark Mode',
keywords: 'theme dark mode appearance',
section: 'Settings',
perform: (): void => {
handleThemeChange(THEME_MODE.DARK);
},
},
{
id: 'light-mode',
name: 'Switch to Light Mode [Beta]',
keywords: 'theme light mode appearance',
section: 'Settings',
perform: (): void => {
handleThemeChange(THEME_MODE.LIGHT);
},
},
{
id: 'system-theme',
name: 'Switch to System Theme',
keywords: 'system theme appearance',
section: 'Settings',
perform: (): void => {
handleThemeChange(THEME_MODE.SYSTEM);
},
},
];
return <KBarProvider actions={kbarActions}>{children}</KBarProvider>;
}

View File

@@ -0,0 +1,50 @@
import React, {
createContext,
ReactNode,
useContext,
useMemo,
useState,
} from 'react';
type CmdKContextType = {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
openCmdK: () => void;
closeCmdK: () => void;
};
const CmdKContext = createContext<CmdKContextType | null>(null);
export function CmdKProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const [open, setOpen] = useState<boolean>(false);
function openCmdK(): void {
setOpen(true);
}
function closeCmdK(): void {
setOpen(false);
}
const value = useMemo<CmdKContextType>(
() => ({
open,
setOpen,
openCmdK,
closeCmdK,
}),
[open],
);
return <CmdKContext.Provider value={value}>{children}</CmdKContext.Provider>;
}
export function useCmdK(): CmdKContextType {
const ctx = useContext(CmdKContext);
if (!ctx) throw new Error('useCmdK must be used inside CmdKProvider');
return ctx;
}

View File

@@ -3583,7 +3583,7 @@
dependencies:
"@babel/runtime" "^7.13.10"
"@radix-ui/react-compose-refs@1.1.2":
"@radix-ui/react-compose-refs@1.1.2", "@radix-ui/react-compose-refs@^1.1.1":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz#a2c4c47af6337048ee78ff6dc0d090b390d2bb30"
integrity sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==
@@ -3600,6 +3600,26 @@
resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.2.tgz#61628ef269a433382c364f6f1e3788a6dc213a36"
integrity sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==
"@radix-ui/react-dialog@^1.1.11", "@radix-ui/react-dialog@^1.1.6":
version "1.1.15"
resolved "https://registry.yarnpkg.com/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz#1de3d7a7e9a17a9874d29c07f5940a18a119b632"
integrity sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-dismissable-layer" "1.1.11"
"@radix-ui/react-focus-guards" "1.1.3"
"@radix-ui/react-focus-scope" "1.1.7"
"@radix-ui/react-id" "1.1.1"
"@radix-ui/react-portal" "1.1.9"
"@radix-ui/react-presence" "1.1.5"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-slot" "1.2.3"
"@radix-ui/react-use-controllable-state" "1.2.2"
aria-hidden "^1.2.4"
react-remove-scroll "^2.6.3"
"@radix-ui/react-direction@1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.0.1.tgz#9cb61bf2ccf568f3421422d182637b7f47596c9b"
@@ -3657,7 +3677,7 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-use-layout-effect" "1.0.1"
"@radix-ui/react-id@1.1.1":
"@radix-ui/react-id@1.1.1", "@radix-ui/react-id@^1.1.0":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.1.tgz#1404002e79a03fe062b7e3864aa01e24bd1471f7"
integrity sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==
@@ -3726,7 +3746,7 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3"
"@radix-ui/react-portal@1.1.9", "@radix-ui/react-portal@^1.0.1":
"@radix-ui/react-portal@1.1.9":
version "1.1.9"
resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.9.tgz#14c3649fe48ec474ac51ed9f2b9f5da4d91c4472"
integrity sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==
@@ -3766,6 +3786,13 @@
dependencies:
"@radix-ui/react-slot" "1.2.3"
"@radix-ui/react-primitive@^2.0.2":
version "2.1.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz#2626ea309ebd63bf5767d3e7fc4081f81b993df0"
integrity sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==
dependencies:
"@radix-ui/react-slot" "1.2.4"
"@radix-ui/react-roving-focus@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz#e90c4a6a5f6ac09d3b8c1f5b5e81aab2f0db1974"
@@ -3797,6 +3824,13 @@
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-slot@1.2.4":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.4.tgz#63c0ba05fdf90cc49076b94029c852d7bac1fb83"
integrity sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-tabs@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz#993608eec55a5d1deddd446fa9978d2bc1053da2"
@@ -4048,11 +4082,6 @@
rc-resize-observer "^1.3.1"
rc-util "^5.38.0"
"@reach/observe-rect@^1.1.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.2.0.tgz#d7a6013b8aafcc64c778a0ccb83355a11204d3b2"
integrity sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ==
"@react-dnd/asap@^5.0.1":
version "5.0.2"
resolved "https://registry.yarnpkg.com/@react-dnd/asap/-/asap-5.0.2.tgz#1f81f124c1cd6f39511c11a881cfb0f715343488"
@@ -4306,6 +4335,21 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/command@0.0.0":
version "0.0.0"
resolved "https://registry.yarnpkg.com/@signozhq/command/-/command-0.0.0.tgz#bd1e1cac7346e862dd61df64b756302e89e1a322"
integrity sha512-AwRYxZTi4o8SBOL4hmgcgbhCKXl2Qb/TUSLbSYEMFdiQSl5VYA8XZJv5fSYVMJkAIlOaHzFzR04XNEU7lZcBpw==
dependencies:
"@radix-ui/react-dialog" "^1.1.11"
"@radix-ui/react-icons" "^1.3.0"
"@radix-ui/react-slot" "^1.1.0"
class-variance-authority "^0.7.0"
clsx "^2.1.1"
cmdk "^1.1.1"
lucide-react "^0.445.0"
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/design-tokens@1.1.4":
version "1.1.4"
resolved "https://registry.yarnpkg.com/@signozhq/design-tokens/-/design-tokens-1.1.4.tgz#5d5de5bd9d19b6a3631383db015cc4b70c3f7661"
@@ -7397,6 +7441,16 @@ clsx@^2.1.1:
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
cmdk@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/cmdk/-/cmdk-1.1.1.tgz#b8524272699ccaa37aaf07f36850b376bf3d58e5"
integrity sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==
dependencies:
"@radix-ui/react-compose-refs" "^1.1.1"
"@radix-ui/react-dialog" "^1.1.6"
"@radix-ui/react-id" "^1.1.0"
"@radix-ui/react-primitive" "^2.0.2"
co@^4.6.0:
version "4.6.0"
resolved "https://registry.npmjs.org/co/-/co-4.6.0.tgz"
@@ -9439,11 +9493,6 @@ fast-diff@^1.1.2:
resolved "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz"
integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
fast-equals@^2.0.3:
version "2.0.4"
resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-2.0.4.tgz#3add9410585e2d7364c2deeb6a707beadb24b927"
integrity sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w==
fast-glob@^3.2.11, fast-glob@^3.2.7, fast-glob@^3.3.0:
version "3.3.3"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818"
@@ -9797,11 +9846,6 @@ functions-have-names@^1.2.2, functions-have-names@^1.2.3:
resolved "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz"
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
fuse.js@^6.6.2:
version "6.6.2"
resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.6.2.tgz#fe463fed4b98c0226ac3da2856a415576dc9a111"
integrity sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==
gensync@^1.0.0-beta.2:
version "1.0.0-beta.2"
resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz"
@@ -12018,17 +12062,6 @@ kapsule@1, kapsule@^1.14:
dependencies:
lodash-es "4"
kbar@0.1.0-beta.48:
version "0.1.0-beta.48"
resolved "https://registry.yarnpkg.com/kbar/-/kbar-0.1.0-beta.48.tgz#2db254cb2943f14200c5a5f47064135737527983"
integrity sha512-HD5A1dqfK6XGeoH4fRWTmRt4y76sDbtGxY4Dh2xNa5MYtvtKsqfz+nRZ0tKgcrjjGYN4rf5TLXMJuiE7Pb8rXg==
dependencies:
"@radix-ui/react-portal" "^1.0.1"
fast-equals "^2.0.3"
fuse.js "^6.6.2"
react-virtual "^2.8.2"
tiny-invariant "^1.2.0"
keyv@^4.0.0:
version "4.5.4"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
@@ -15596,13 +15629,6 @@ react-use@^17.3.2:
ts-easing "^0.2.0"
tslib "^2.1.0"
react-virtual@^2.8.2:
version "2.10.4"
resolved "https://registry.yarnpkg.com/react-virtual/-/react-virtual-2.10.4.tgz#08712f0acd79d7d6f7c4726f05651a13b24d8704"
integrity sha512-Ir6+oPQZTVHfa6+JL9M7cvMILstFZH/H3jqeYeKI4MSUX+rIruVwFC6nGVXw9wqAw8L0Kg2KvfXxI85OvYQdpQ==
dependencies:
"@reach/observe-rect" "^1.1.0"
react-virtuoso@4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.0.3.tgz#0dc8b10978095852d985b064157639b9fb9d9b1e"
@@ -17317,11 +17343,6 @@ tiny-invariant@^1.0.2, tiny-invariant@^1.0.6:
resolved "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz"
integrity sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==
tiny-invariant@^1.2.0:
version "1.3.3"
resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127"
integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==
tiny-warning@^1.0.0:
version "1.0.3"
resolved "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz"

View File

@@ -0,0 +1,35 @@
package metricsexplorer
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
)
type Config struct {
// TelemetryStore is the telemetrystore configuration
TelemetryStore TelemetryStoreConfig `mapstructure:"telemetrystore"`
}
type TelemetryStoreConfig struct {
// Threads is the number of threads to use for ClickHouse queries
Threads int `mapstructure:"threads"`
}
func NewConfigFactory() factory.ConfigFactory {
return factory.NewConfigFactory(factory.MustNewName("metricsexplorer"), newConfig)
}
func newConfig() factory.Config {
return Config{
TelemetryStore: TelemetryStoreConfig{
Threads: 8, // Default value
},
}
}
func (c Config) Validate() error {
if c.TelemetryStore.Threads <= 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metricsexplorer.telemetrystore.threads must be positive, got %d", c.TelemetryStore.Threads)
}
return nil
}

View File

@@ -21,7 +21,7 @@ func generateMetricMetadataCacheKey(metricName string) string {
func getStatsOrderByColumn(order *qbtypes.OrderBy) (string, string, error) {
if order == nil {
return sqlColumnTimeSeries, qbtypes.OrderDirectionDesc.StringValue(), nil
return sqlColumnSamples, qbtypes.OrderDirectionDesc.StringValue(), nil
}
var columnName string

View File

@@ -15,6 +15,7 @@ import (
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/metricsexplorertypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
@@ -31,10 +32,11 @@ type module struct {
condBuilder qbtypes.ConditionBuilder
logger *slog.Logger
cache cache.Cache
config metricsexplorer.Config
}
// NewModule constructs the metrics module with the provided dependencies.
func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetrytypes.MetadataStore, cache cache.Cache, providerSettings factory.ProviderSettings) metricsexplorer.Module {
func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetrytypes.MetadataStore, cache cache.Cache, providerSettings factory.ProviderSettings, cfg metricsexplorer.Config) metricsexplorer.Module {
fieldMapper := telemetrymetrics.NewFieldMapper()
condBuilder := telemetrymetrics.NewConditionBuilder(fieldMapper)
return &module{
@@ -44,6 +46,7 @@ func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetr
logger: providerSettings.Logger,
telemetryMetadataStore: telemetryMetadataStore,
cache: cache,
config: cfg,
}
}
@@ -96,7 +99,6 @@ func (m *module) GetStats(ctx context.Context, orgID valuer.UUID, req *metricsex
}, nil
}
// GetTreemap will return metrics treemap information once implemented.
func (m *module) GetTreemap(ctx context.Context, orgID valuer.UUID, req *metricsexplorertypes.TreemapRequest) (*metricsexplorertypes.TreemapResponse, error) {
if err := req.Validate(); err != nil {
return nil, err
@@ -108,7 +110,7 @@ func (m *module) GetTreemap(ctx context.Context, orgID valuer.UUID, req *metrics
}
resp := &metricsexplorertypes.TreemapResponse{}
switch req.Treemap {
switch req.Mode {
case metricsexplorertypes.TreemapModeSamples:
entries, err := m.computeSamplesTreemap(ctx, req, filterWhereClause)
if err != nil {
@@ -306,8 +308,9 @@ func (m *module) fetchUpdatedMetadata(ctx context.Context, orgID valuer.UUID, me
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(ctx, query, args...)
rows, err := db.Query(valueCtx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch updated metrics metadata")
}
@@ -351,11 +354,11 @@ func (m *module) fetchTimeseriesMetadata(ctx context.Context, orgID valuer.UUID,
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"metric_name",
"ANY_VALUE(description) AS description",
"ANY_VALUE(type) AS metric_type",
"ANY_VALUE(unit) AS metric_unit",
"ANY_VALUE(temporality) AS temporality",
"ANY_VALUE(is_monotonic) AS is_monotonic",
"anyLast(description) AS description",
"anyLast(type) AS metric_type",
"anyLast(unit) AS metric_unit",
"anyLast(temporality) AS temporality",
"anyLast(is_monotonic) AS is_monotonic",
)
sb.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, telemetrymetrics.TimeseriesV4TableName))
sb.Where(sb.In("metric_name", args...))
@@ -363,8 +366,9 @@ func (m *module) fetchTimeseriesMetadata(ctx context.Context, orgID valuer.UUID,
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(ctx, query, args...)
rows, err := db.Query(valueCtx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch metrics metadata from timeseries table")
}
@@ -448,7 +452,7 @@ func (m *module) validateMetricLabels(ctx context.Context, req *metricsexplorert
return err
}
if !hasLabel {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metric '%s' cannot be set as histogram type", req.MetricName)
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metric '%s' cannot be set as histogram type: histogram metrics require the 'le' (less than or equal) label for bucket boundaries", req.MetricName)
}
}
@@ -458,7 +462,7 @@ func (m *module) validateMetricLabels(ctx context.Context, req *metricsexplorert
return err
}
if !hasLabel {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metric '%s' cannot be set as summary type", req.MetricName)
return errors.NewInvalidInputf(errors.CodeInvalidInput, "metric '%s' cannot be set as summary type: summary metrics require the 'quantile' label for quantile values", req.MetricName)
}
}
@@ -475,9 +479,10 @@ func (m *module) checkForLabelInMetric(ctx context.Context, metricName string, l
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
var hasLabel bool
db := m.telemetryStore.ClickhouseDB()
err := db.QueryRow(ctx, query, args...).Scan(&hasLabel)
err := db.QueryRow(valueCtx, query, args...).Scan(&hasLabel)
if err != nil {
return false, errors.WrapInternalf(err, errors.CodeInternal, "error checking metric label %q", label)
}
@@ -503,8 +508,9 @@ func (m *module) insertMetricsMetadata(ctx context.Context, orgID valuer.UUID, r
query, args := ib.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
if err := db.Exec(ctx, query, args...); err != nil {
if err := db.Exec(valueCtx, query, args...); err != nil {
return errors.WrapInternalf(err, errors.CodeInternal, "failed to insert metrics metadata")
}
@@ -533,7 +539,6 @@ func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter,
return sqlbuilder.NewWhereClause(), nil
}
// TODO(nikhilmantri0902, srikanthccv): if this is the right way of dealing with whereClauseSelectors
whereClauseSelectors := querybuilder.QueryStringToKeysSelectors(expression)
for idx := range whereClauseSelectors {
whereClauseSelectors[idx].Signal = telemetrytypes.SignalMetrics
@@ -558,8 +563,8 @@ func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter,
FieldKeys: keys,
}
startNs := uint64(startMillis * 1_000_000)
endNs := uint64(endMillis * 1_000_000)
startNs := querybuilder.ToNanoSecs(uint64(startMillis))
endNs := querybuilder.ToNanoSecs(uint64(endMillis))
whereClause, err := querybuilder.PrepareWhereClause(expression, opts, startNs, endNs)
if err != nil {
@@ -656,8 +661,9 @@ func (m *module) fetchMetricsStatsWithSamples(
query, args := finalSB.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(ctx, query, args...)
rows, err := db.Query(valueCtx, query, args...)
if err != nil {
return nil, 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to execute metrics stats with samples query")
}
@@ -725,8 +731,9 @@ func (m *module) computeTimeseriesTreemap(ctx context.Context, req *metricsexplo
query, args := finalSB.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(ctx, query, args...)
rows, err := db.Query(valueCtx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to execute timeseries treemap query")
}
@@ -784,7 +791,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorer
)
sampleCountsSB.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, samplesTable))
sampleCountsSB.Where(sampleCountsSB.Between("unix_milli", req.Start, req.End))
sampleCountsSB.Where("metric_name IN (SELECT metric_name FROM __metric_candidates)")
sampleCountsSB.Where("metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates)")
if filterWhereClause != nil {
fingerprintSB := sqlbuilder.NewSelectBuilder()
@@ -794,7 +801,7 @@ func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorer
fingerprintSB.Where("NOT startsWith(metric_name, 'signoz')")
fingerprintSB.Where(fingerprintSB.E("__normalized", false))
fingerprintSB.AddWhereClause(sqlbuilder.CopyWhereClause(filterWhereClause))
fingerprintSB.Where("metric_name IN (SELECT metric_name FROM __metric_candidates)")
fingerprintSB.Where("metric_name GLOBAL IN (SELECT metric_name FROM __metric_candidates)")
fingerprintSB.GroupBy("fingerprint")
sampleCountsSB.Where("fingerprint IN (SELECT fingerprint FROM __filtered_fingerprints)")
@@ -824,8 +831,9 @@ func (m *module) computeSamplesTreemap(ctx context.Context, req *metricsexplorer
query, args := finalSB.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(ctx, query, args...)
rows, err := db.Query(valueCtx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to execute samples treemap query")
}
@@ -858,7 +866,8 @@ func (m *module) getMetricDataPoints(ctx context.Context, metricName string) (ui
db := m.telemetryStore.ClickhouseDB()
var dataPoints uint64
err := db.QueryRow(ctx, query, args...).Scan(&dataPoints)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
err := db.QueryRow(valueCtx, query, args...).Scan(&dataPoints)
if err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to get metrics data points")
}
@@ -876,7 +885,8 @@ func (m *module) getMetricLastReceived(ctx context.Context, metricName string) (
db := m.telemetryStore.ClickhouseDB()
var lastReceived sql.NullInt64
err := db.QueryRow(ctx, query, args...).Scan(&lastReceived)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
err := db.QueryRow(valueCtx, query, args...).Scan(&lastReceived)
if err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to get last received timestamp")
}
@@ -899,7 +909,8 @@ func (m *module) getTotalTimeSeriesForMetricName(ctx context.Context, metricName
db := m.telemetryStore.ClickhouseDB()
var timeSeriesCount uint64
err := db.QueryRow(ctx, query, args...).Scan(&timeSeriesCount)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
err := db.QueryRow(valueCtx, query, args...).Scan(&timeSeriesCount)
if err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to get total time series count")
}
@@ -919,8 +930,9 @@ func (m *module) getActiveTimeSeriesForMetricName(ctx context.Context, metricNam
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
db := m.telemetryStore.ClickhouseDB()
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
var activeTimeSeries uint64
err := db.QueryRow(ctx, query, args...).Scan(&activeTimeSeries)
err := db.QueryRow(valueCtx, query, args...).Scan(&activeTimeSeries)
if err != nil {
return 0, errors.WrapInternalf(err, errors.CodeInternal, "failed to get active time series count")
}
@@ -953,8 +965,10 @@ func (m *module) fetchMetricAttributes(ctx context.Context, metricName string, s
sb.GroupBy("attr_name")
sb.OrderBy("valueCount DESC")
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
valueCtx := ctxtypes.SetClickhouseMaxThreads(ctx, m.config.TelemetryStore.Threads)
db := m.telemetryStore.ClickhouseDB()
rows, err := db.Query(ctx, query, args...)
rows, err := db.Query(valueCtx, query, args...)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch metric attributes")
}

View File

@@ -94,9 +94,21 @@
"unit": "Count",
"type": "Gauge"
},
{ "name": "aws_ApiGateway_Count_max", "unit": "Count", "type": "Gauge" },
{ "name": "aws_ApiGateway_Count_min", "unit": "Count", "type": "Gauge" },
{ "name": "aws_ApiGateway_Count_sum", "unit": "Count", "type": "Gauge" },
{
"name": "aws_ApiGateway_Count_max",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_Count_min",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_Count_sum",
"unit": "Count",
"type": "Gauge"
},
{
"name": "aws_ApiGateway_IntegrationLatency_count",
"unit": "Milliseconds",

View File

@@ -672,7 +672,7 @@ func (ah *APIHandler) MetricExplorerRoutes(router *mux.Router, am *middleware.Au
router.HandleFunc("/api/v2/metrics/treemap", am.ViewAccess(ah.Signoz.Handlers.MetricsExplorer.GetTreemap)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/metrics/attributes", am.ViewAccess(ah.Signoz.Handlers.MetricsExplorer.GetMetricAttributes)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/metrics/metadata", am.ViewAccess(ah.Signoz.Handlers.MetricsExplorer.GetMetricMetadata)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/metrics/{metric_name}/metadata", am.ViewAccess(ah.Signoz.Handlers.MetricsExplorer.UpdateMetricMetadata)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/metrics/{metric_name}/metadata", am.EditAccess(ah.Signoz.Handlers.MetricsExplorer.UpdateMetricMetadata)).Methods(http.MethodPost)
router.HandleFunc("/api/v2/metric/highlights", am.ViewAccess(ah.Signoz.Handlers.MetricsExplorer.GetMetricHighlights)).Methods(http.MethodGet)
}

View File

@@ -191,6 +191,13 @@ func (r *BaseRule) currentAlerts() []*ruletypes.Alert {
return alerts
}
// ShouldSendUnmatched returns true if the rule should send unmatched samples
// during alert evaluation, even if they don't match the rule condition.
// This is useful in testing the rule.
func (r *BaseRule) ShouldSendUnmatched() bool {
return r.sendUnmatched
}
// ActiveAlertsLabelFP returns a map of active alert labels fingerprint and
// the fingerprint is computed using the QueryResultLables.Hash() method.
// We use the QueryResultLables instead of labels as these labels are raw labels

View File

@@ -138,7 +138,8 @@ func (r *PromRule) buildAndRunQuery(ctx context.Context, ts time.Time) (ruletype
var resultVector ruletypes.Vector
for _, series := range res {
resultSeries, err := r.Threshold.Eval(toCommonSeries(series), r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
})
if err != nil {
return nil, err

View File

@@ -489,7 +489,8 @@ func (r *ThresholdRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID,
}
}
resultSeries, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
})
if err != nil {
return nil, err
@@ -568,7 +569,8 @@ func (r *ThresholdRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUI
}
}
resultSeries, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
ActiveAlerts: r.ActiveAlertsLabelFP(),
ActiveAlerts: r.ActiveAlertsLabelFP(),
SendUnmatched: r.ShouldSendUnmatched(),
})
if err != nil {
return nil, err

View File

@@ -2,6 +2,7 @@ package rules
import (
"context"
"math"
"strings"
"testing"
"time"
@@ -1519,6 +1520,283 @@ func TestThresholdRuleEval_MatchPlusCompareOps(t *testing.T) {
}
// TestThresholdRuleEval_SendUnmatchedBypassesRecovery tests the case where the sendUnmatched is true and the recovery target is met.
// In this case, the rule should return the first sample as sendUnmatched is supposed to be used in tests and in case of tests
// recovery target is expected to be present. This test make sure this behavior is working as expected.
func TestThresholdRuleEval_SendUnmatchedBypassesRecovery(t *testing.T) {
target := 10.0
recovery := 4.0
postableRule := ruletypes.PostableRule{
AlertName: "Send unmatched bypass recovery",
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{
"A": {
QueryName: "A",
StepInterval: 60,
AggregateAttribute: v3.AttributeKey{
Key: "probe_success",
},
AggregateOperator: v3.AggregateOperatorNoOp,
DataSource: v3.DataSourceMetrics,
Expression: "A",
},
},
},
},
}
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
Kind: ruletypes.BasicThresholdKind,
Spec: ruletypes.BasicRuleThresholds{
{
Name: "primary",
TargetValue: &target,
RecoveryTarget: &recovery,
MatchType: ruletypes.AtleastOnce,
CompareOp: ruletypes.ValueIsAbove,
},
},
}
logger := instrumentationtest.New().Logger()
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, nil, nil, logger, WithEvalDelay(2*time.Minute))
require.NoError(t, err)
now := time.Now()
series := v3.Series{
Points: []v3.Point{
{Timestamp: now.UnixMilli(), Value: 3},
{Timestamp: now.Add(time.Minute).UnixMilli(), Value: 4},
{Timestamp: now.Add(2 * time.Minute).UnixMilli(), Value: 5},
},
Labels: map[string]string{
"service.name": "frontend",
},
LabelsArray: []map[string]string{
{
"service.name": "frontend",
},
},
}
alertLabels := ruletypes.PrepareSampleLabelsForRule(series.Labels, "primary")
activeAlerts := map[uint64]struct{}{alertLabels.Hash(): {}}
resultVectors, err := rule.Threshold.Eval(series, rule.Unit(), ruletypes.EvalData{
ActiveAlerts: activeAlerts,
SendUnmatched: true,
})
require.NoError(t, err)
require.Len(t, resultVectors, 1, "expected unmatched sample to be returned")
smpl := resultVectors[0]
assert.Equal(t, float64(3), smpl.V)
assert.False(t, smpl.IsRecovering, "unmatched path should not mark sample as recovering")
assert.Equal(t, float64(4), *smpl.RecoveryTarget, "unmatched path should set recovery target")
assert.InDelta(t, target, smpl.Target, 0.01)
assert.Equal(t, "primary", smpl.Metric.Get(ruletypes.LabelThresholdName))
}
func intPtr(v int) *int {
return &v
}
// TestThresholdRuleEval_SendUnmatchedVariants tests the different variants of sendUnmatched behavior.
// It tests the case where sendUnmatched is true, false.
func TestThresholdRuleEval_SendUnmatchedVariants(t *testing.T) {
target := 10.0
recovery := 5.0
postableRule := ruletypes.PostableRule{
AlertName: "Send unmatched variants",
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{
"A": {
QueryName: "A",
StepInterval: 60,
AggregateAttribute: v3.AttributeKey{
Key: "probe_success",
},
AggregateOperator: v3.AggregateOperatorNoOp,
DataSource: v3.DataSourceMetrics,
Expression: "A",
},
},
},
},
}
now := time.Now()
tests := []recoveryTestCase{
{
description: "sendUnmatched returns first valid point",
values: v3.Series{
Points: []v3.Point{
{Timestamp: now.UnixMilli(), Value: 3},
{Timestamp: now.Add(time.Minute).UnixMilli(), Value: 4},
},
Labels: map[string]string{
"service.name": "frontend",
},
LabelsArray: []map[string]string{
{
"service.name": "frontend",
},
},
},
compareOp: string(ruletypes.ValueIsAbove),
matchType: string(ruletypes.AtleastOnce),
target: target,
recoveryTarget: &recovery,
thresholdName: "primary",
// Since sendUnmatched is true, the rule should return the first valid point
// even if it doesn't match the rule condition with current target value of 10.0
sendUnmatched: true,
expectSamples: intPtr(1),
expectedSampleValue: 3,
},
{
description: "sendUnmatched false suppresses unmatched",
values: v3.Series{
Points: []v3.Point{
{Timestamp: now.UnixMilli(), Value: 3},
{Timestamp: now.Add(time.Minute).UnixMilli(), Value: 4},
},
Labels: map[string]string{
"service.name": "frontend",
},
LabelsArray: []map[string]string{
{
"service.name": "frontend",
},
},
},
compareOp: string(ruletypes.ValueIsAbove),
matchType: string(ruletypes.AtleastOnce),
target: target,
recoveryTarget: &recovery,
thresholdName: "primary",
// Since sendUnmatched is false, the rule should not return any samples
sendUnmatched: false,
expectSamples: intPtr(0),
},
{
description: "sendUnmatched skips NaN and uses next point",
values: v3.Series{
Points: []v3.Point{
{Timestamp: now.UnixMilli(), Value: math.NaN()},
{Timestamp: now.Add(time.Minute).UnixMilli(), Value: math.Inf(1)},
{Timestamp: now.Add(2 * time.Minute).UnixMilli(), Value: 7},
},
Labels: map[string]string{
"service.name": "frontend",
},
LabelsArray: []map[string]string{
{
"service.name": "frontend",
},
},
},
compareOp: string(ruletypes.ValueIsAbove),
matchType: string(ruletypes.AtleastOnce),
target: target,
recoveryTarget: &recovery,
thresholdName: "primary",
// Since sendUnmatched is true, the rule should return the first valid point
// even if it doesn't match the rule condition with current target value of 10.0
sendUnmatched: true,
expectSamples: intPtr(1),
expectedSampleValue: 7,
},
}
for _, tc := range tests {
runEvalTests(t, postableRule, []recoveryTestCase{tc})
}
}
// TestThresholdRuleEval_RecoveryNotMetSendUnmatchedFalse tests the case where the recovery target is not met and sendUnmatched is false.
// In this case, the rule should not return any samples as no alert is active plus the recovery target is not met.
func TestThresholdRuleEval_RecoveryNotMetSendUnmatchedFalse(t *testing.T) {
target := 10.0
recovery := 5.0
now := time.Now()
postableRule := ruletypes.PostableRule{
AlertName: "Recovery not met sendUnmatched false",
AlertType: ruletypes.AlertTypeMetric,
RuleType: ruletypes.RuleTypeThreshold,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: ruletypes.Duration(5 * time.Minute),
Frequency: ruletypes.Duration(1 * time.Minute),
}},
RuleCondition: &ruletypes.RuleCondition{
CompositeQuery: &v3.CompositeQuery{
QueryType: v3.QueryTypeBuilder,
BuilderQueries: map[string]*v3.BuilderQuery{
"A": {
QueryName: "A",
StepInterval: 60,
AggregateAttribute: v3.AttributeKey{
Key: "probe_success",
},
AggregateOperator: v3.AggregateOperatorNoOp,
DataSource: v3.DataSourceMetrics,
Expression: "A",
},
},
},
},
}
tc := recoveryTestCase{
description: "recovery target present but not met, sendUnmatched false",
values: v3.Series{
Points: []v3.Point{
{Timestamp: now.UnixMilli(), Value: 3},
{Timestamp: now.Add(time.Minute).UnixMilli(), Value: 4},
},
Labels: map[string]string{
"service.name": "frontend",
},
LabelsArray: []map[string]string{
{
"service.name": "frontend",
},
},
},
compareOp: string(ruletypes.ValueIsAbove),
matchType: string(ruletypes.AtleastOnce),
target: target,
recoveryTarget: &recovery,
thresholdName: "primary",
sendUnmatched: false,
expectSamples: intPtr(0),
activeAlerts: nil, // will auto-calc
expectedTarget: target,
expectedRecoveryTarget: recovery,
}
runEvalTests(t, postableRule, []recoveryTestCase{tc})
}
func runEvalTests(t *testing.T, postableRule ruletypes.PostableRule, testCases []recoveryTestCase) {
logger := instrumentationtest.New().Logger()
for _, c := range testCases {
@@ -1577,12 +1855,21 @@ func runEvalTests(t *testing.T, postableRule ruletypes.PostableRule, testCases [
}
evalData := ruletypes.EvalData{
ActiveAlerts: activeAlerts,
ActiveAlerts: activeAlerts,
SendUnmatched: c.sendUnmatched,
}
resultVectors, err := rule.Threshold.Eval(values, rule.Unit(), evalData)
assert.NoError(t, err)
if c.expectSamples != nil {
assert.Equal(t, *c.expectSamples, len(resultVectors), "sample count mismatch")
if *c.expectSamples > 0 {
assert.InDelta(t, c.expectedSampleValue, resultVectors[0].V, 0.01, "sample value mismatch")
}
return
}
// Verify results
if c.expectAlert || c.expectRecovery {
// Either a new alert fires or recovery happens - both return result vectors

View File

@@ -27,6 +27,10 @@ type recoveryTestCase struct {
expectedTarget float64
expectedRecoveryTarget float64
thresholdName string // for hash calculation
// Optional fields for SendUnmatched scenarios
sendUnmatched bool // whether to set EvalData.SendUnmatched
expectSamples *int // if set, assert exact sample count
expectedSampleValue float64 // used when expectSamples is set
}
// thresholdExpectation defines expected behavior for a single threshold in multi-threshold tests

View File

@@ -19,6 +19,7 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/modules/metricsexplorer"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/ruler"
@@ -97,6 +98,9 @@ type Config struct {
// Tokenizer config
Tokenizer tokenizer.Config `mapstructure:"tokenizer"`
// MetricsExplorer config
MetricsExplorer metricsexplorer.Config `mapstructure:"metricsexplorer"`
}
// DeprecatedFlags are the flags that are deprecated and scheduled for removal.
@@ -156,6 +160,7 @@ func NewConfig(ctx context.Context, logger *slog.Logger, resolverConfig config.R
statsreporter.NewConfigFactory(),
gateway.NewConfigFactory(),
tokenizer.NewConfigFactory(),
metricsexplorer.NewConfigFactory(),
}
conf, err := config.New(ctx, resolverConfig, configFactories)
@@ -336,12 +341,12 @@ func mergeAndEnsureBackwardCompatibility(ctx context.Context, logger *slog.Logge
}
}
func (config Config)Collect(_ context.Context, _ valuer.UUID) (map[string]any, error){
func (config Config) Collect(_ context.Context, _ valuer.UUID) (map[string]any, error) {
stats := make(map[string]any)
// SQL Store Config Stats
stats["config.sqlstore.provider"] = config.SQLStore.Provider
// Tokenizer Config Stats
stats["config.tokenizer.provider"] = config.Tokenizer.Provider

View File

@@ -36,7 +36,7 @@ func TestNewHandlers(t *testing.T) {
tokenizer := tokenizertest.New()
emailing := emailingtest.New()
require.NoError(t, err)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, Config{})
handlers := NewHandlers(modules, providerSettings, nil, nil)

View File

@@ -79,6 +79,7 @@ func NewModules(
authNs map[authtypes.AuthNProvider]authn.AuthN,
authz authz.AuthZ,
cache cache.Cache,
config Config,
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
@@ -101,6 +102,6 @@ func NewModules(
Session: implsession.NewModule(providerSettings, authNs, user, userGetter, implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs), tokenizer, orgGetter),
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
Services: implservices.NewModule(querier, telemetryStore),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, providerSettings),
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, providerSettings, config.MetricsExplorer),
}
}

View File

@@ -36,7 +36,7 @@ func TestNewModules(t *testing.T) {
tokenizer := tokenizertest.New()
emailing := emailingtest.New()
require.NoError(t, err)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, Config{})
reflectVal := reflect.ValueOf(modules)
for i := 0; i < reflectVal.NumField(); i++ {

View File

@@ -344,7 +344,7 @@ func New(
)
// Initialize all modules
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, config)
// Initialize all handlers for the modules
handlers := NewHandlers(modules, providerSettings, querier, licensing)

View File

@@ -140,11 +140,11 @@ type UpdateMetricMetadataRequest struct {
// TreemapRequest represents the payload for the metrics treemap endpoint.
type TreemapRequest struct {
Filter *qbtypes.Filter `json:"filter,omitempty"`
Start int64 `json:"start"`
End int64 `json:"end"`
Limit int `json:"limit"`
Treemap TreemapMode `json:"treemap"`
Filter *qbtypes.Filter `json:"filter,omitempty"`
Start int64 `json:"start"`
End int64 `json:"end"`
Limit int `json:"limit"`
Mode TreemapMode `json:"mode"`
}
// Validate enforces basic constraints on TreemapRequest.
@@ -182,11 +182,11 @@ func (req *TreemapRequest) Validate() error {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be between 1 and 5000")
}
if req.Treemap != TreemapModeSamples && req.Treemap != TreemapModeTimeSeries {
if req.Mode != TreemapModeSamples && req.Mode != TreemapModeTimeSeries {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid treemap mode %q: supported values are %q or %q",
req.Treemap,
req.Mode,
TreemapModeSamples,
TreemapModeTimeSeries,
)

View File

@@ -63,6 +63,11 @@ type EvalData struct {
// used to check if a sample is part of an active alert
// when evaluating the recovery threshold.
ActiveAlerts map[uint64]struct{}
// SendUnmatched is a flag to return samples
// even if they don't match the rule condition.
// This is useful in testing the rule.
SendUnmatched bool
}
// HasActiveAlert checks if the given sample figerprint is active
@@ -131,6 +136,24 @@ func (r BasicRuleThresholds) Eval(series v3.Series, unit string, evalData EvalDa
smpl.TargetUnit = threshold.TargetUnit
resultVector = append(resultVector, smpl)
continue
} else if evalData.SendUnmatched {
// Sanitise the series points to remove any NaN or Inf values
series.Points = removeGroupinSetPoints(series)
if len(series.Points) == 0 {
continue
}
// prepare the sample with the first point of the series
smpl := Sample{
Point: Point{T: series.Points[0].Timestamp, V: series.Points[0].Value},
Metric: PrepareSampleLabelsForRule(series.Labels, threshold.Name),
Target: *threshold.TargetValue,
TargetUnit: threshold.TargetUnit,
}
if threshold.RecoveryTarget != nil {
smpl.RecoveryTarget = threshold.RecoveryTarget
}
resultVector = append(resultVector, smpl)
continue
}
// Prepare alert hash from series labels and threshold name if recovery target option was provided