mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-29 21:30:29 +01:00
Compare commits
13 Commits
refactor/c
...
feat/add-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da268fa694 | ||
|
|
5d07fcb181 | ||
|
|
edc1278769 | ||
|
|
da1b09c479 | ||
|
|
b02e17a0d4 | ||
|
|
637e194d1c | ||
|
|
9eb8d6466c | ||
|
|
0aec7a9fe8 | ||
|
|
bebe4ebb89 | ||
|
|
2209708caa | ||
|
|
1f813ce21f | ||
|
|
f082821ac2 | ||
|
|
3c5bd81421 |
12
frontend/src/components/RouteTab/RouteTab.styles.scss
Normal file
12
frontend/src/components/RouteTab/RouteTab.styles.scss
Normal file
@@ -0,0 +1,12 @@
|
||||
.route-tab-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.route-tab-extra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -70,7 +70,7 @@ describe('RouteTab component', () => {
|
||||
</Router>,
|
||||
);
|
||||
expect(history.location.pathname).toBe('/');
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Tab2' }));
|
||||
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Tab2' }));
|
||||
expect(history.location.pathname).toBe('/tab2');
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('RouteTab component', () => {
|
||||
/>
|
||||
</Router>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Tab2' }));
|
||||
fireEvent.mouseDown(screen.getByRole('tab', { name: 'Tab2' }));
|
||||
expect(onChangeHandler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import './RouteTab.styles.scss';
|
||||
|
||||
import {
|
||||
generatePath,
|
||||
matchPath,
|
||||
useLocation,
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import { Tabs, TabsProps } from 'antd';
|
||||
import {
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsRoot,
|
||||
TabsTrigger,
|
||||
} from '@signozhq/ui/tabs';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
|
||||
import { RouteTabProps } from './types';
|
||||
@@ -16,11 +23,13 @@ interface Params {
|
||||
function RouteTab({
|
||||
routes,
|
||||
activeKey,
|
||||
defaultActiveKey,
|
||||
onChangeHandler,
|
||||
history,
|
||||
showRightSection,
|
||||
...rest
|
||||
}: RouteTabProps & TabsProps): JSX.Element {
|
||||
showRightSection = true,
|
||||
tabBarExtraContent,
|
||||
hideTabBar = false,
|
||||
}: RouteTabProps): JSX.Element {
|
||||
const params = useParams<Params>();
|
||||
const location = useLocation();
|
||||
|
||||
@@ -46,38 +55,38 @@ function RouteTab({
|
||||
}
|
||||
};
|
||||
|
||||
const items = routes.map(({ Component, name, route, key }) => ({
|
||||
label: name,
|
||||
key,
|
||||
tabKey: route,
|
||||
children: <Component />,
|
||||
}));
|
||||
const resolvedActiveKey = currentRoute?.key || activeKey;
|
||||
const extraContent =
|
||||
tabBarExtraContent ??
|
||||
(showRightSection && (
|
||||
<HeaderRightSection enableAnnouncements={false} enableShare enableFeedback />
|
||||
));
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
onChange={onChange}
|
||||
destroyInactiveTabPane
|
||||
activeKey={currentRoute?.key || activeKey}
|
||||
defaultActiveKey={currentRoute?.key || activeKey}
|
||||
animated
|
||||
items={items}
|
||||
tabBarExtraContent={
|
||||
showRightSection && (
|
||||
<HeaderRightSection
|
||||
enableAnnouncements={false}
|
||||
enableShare
|
||||
enableFeedback
|
||||
/>
|
||||
)
|
||||
}
|
||||
{...rest}
|
||||
/>
|
||||
<TabsRoot
|
||||
value={resolvedActiveKey}
|
||||
defaultValue={defaultActiveKey ?? resolvedActiveKey}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
{!hideTabBar && (
|
||||
<div className="route-tab-header">
|
||||
<TabsList>
|
||||
{routes.map(({ name, key }) => (
|
||||
<TabsTrigger key={key} value={key}>
|
||||
{name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{extraContent && <div className="route-tab-extra">{extraContent}</div>}
|
||||
</div>
|
||||
)}
|
||||
{routes.map(({ key, Component }) => (
|
||||
<TabsContent key={key} value={key}>
|
||||
<Component />
|
||||
</TabsContent>
|
||||
))}
|
||||
</TabsRoot>
|
||||
);
|
||||
}
|
||||
|
||||
RouteTab.defaultProps = {
|
||||
onChangeHandler: undefined,
|
||||
showRightSection: true,
|
||||
};
|
||||
|
||||
export default RouteTab;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TabsProps } from 'antd';
|
||||
import { History } from 'history';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export type TabRoutes = {
|
||||
name: React.ReactNode;
|
||||
@@ -10,8 +10,11 @@ export type TabRoutes = {
|
||||
|
||||
export interface RouteTabProps {
|
||||
routes: TabRoutes[];
|
||||
activeKey: TabsProps['activeKey'];
|
||||
activeKey: string | undefined;
|
||||
defaultActiveKey?: string;
|
||||
onChangeHandler?: (key: string) => void;
|
||||
history: History<unknown>;
|
||||
showRightSection: boolean;
|
||||
showRightSection?: boolean;
|
||||
tabBarExtraContent?: ReactNode;
|
||||
hideTabBar?: boolean;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ export function ShiftHoldOverlayController({
|
||||
|
||||
const actions = createShortcutActions({
|
||||
navigate: noop,
|
||||
handleThemeChange: noop,
|
||||
// Overlay is read-only — actions never fire — so we only need a no-op
|
||||
// that satisfies the typed signature.
|
||||
handleThemeChange: (): void => undefined,
|
||||
});
|
||||
|
||||
const visible = useShiftHoldOverlay({
|
||||
|
||||
@@ -116,17 +116,35 @@ jest.mock('hooks/useNotifications', (): unknown => ({
|
||||
}));
|
||||
|
||||
// mock theme hook
|
||||
jest.mock('hooks/useDarkMode', (): unknown => ({
|
||||
useThemeMode: (): {
|
||||
//
|
||||
// We spread jest.requireActual so additions to hooks/useDarkMode (new hooks,
|
||||
// re-exports, contexts) keep working in this test without needing the mock to
|
||||
// re-enumerate every export. We only override the hooks the palette actually
|
||||
// calls.
|
||||
jest.mock('hooks/useDarkMode', (): unknown => {
|
||||
const actual = jest.requireActual('hooks/useDarkMode');
|
||||
const useThemeModeMock = (): {
|
||||
setAutoSwitch: jest.Mock;
|
||||
setTheme: jest.Mock;
|
||||
toggleTheme: jest.Mock;
|
||||
theme: string;
|
||||
autoSwitch: boolean;
|
||||
} => ({
|
||||
setAutoSwitch: jest.fn(),
|
||||
setTheme: jest.fn(),
|
||||
toggleTheme: jest.fn(),
|
||||
theme: 'dark',
|
||||
}),
|
||||
}));
|
||||
autoSwitch: false,
|
||||
});
|
||||
return {
|
||||
...actual,
|
||||
__esModule: true,
|
||||
default: useThemeModeMock,
|
||||
useThemeMode: useThemeModeMock,
|
||||
useIsDarkMode: (): boolean => true,
|
||||
useSystemTheme: (): 'dark' | 'light' => 'dark',
|
||||
};
|
||||
});
|
||||
|
||||
// mock updateUserPreference API and react-query mutation
|
||||
jest.mock('api/v1/user/preferences/name/update', (): jest.Mock => jest.fn());
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
useAIAssistantStore,
|
||||
} from 'container/AIAssistant/store/useAIAssistantStore';
|
||||
import { useThemeMode } from 'hooks/useDarkMode';
|
||||
import { ThemeMode } from 'hooks/useDarkMode/constant';
|
||||
import { useThemeSelection } from 'hooks/useDarkMode/useThemeSelection';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import history from 'lib/history';
|
||||
import { ROLES as UserRole } from 'types/roles';
|
||||
@@ -47,7 +49,8 @@ export function CmdKPalette({
|
||||
}): JSX.Element | null {
|
||||
const { open, setOpen } = useCmdK();
|
||||
|
||||
const { setAutoSwitch, setTheme, theme } = useThemeMode();
|
||||
const { theme } = useThemeMode();
|
||||
const selectTheme = useThemeSelection();
|
||||
const location = useLocation();
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
const startNewConversation = useAIAssistantStore(
|
||||
@@ -80,14 +83,12 @@ export function CmdKPalette({
|
||||
|
||||
useEffect(cmdKEffect, [setOpen]);
|
||||
|
||||
function handleThemeChange(value: string): void {
|
||||
function handleThemeChange(value: ThemeMode): void {
|
||||
logEvent('Account Settings: Theme Changed', { theme: value });
|
||||
if (value === 'auto') {
|
||||
setAutoSwitch(true);
|
||||
} else {
|
||||
setAutoSwitch(false);
|
||||
setTheme(value);
|
||||
}
|
||||
// Close the palette inside the same flushSync batch as the theme change
|
||||
// so its dismissal is part of the captured "new" frame of the wipe;
|
||||
// otherwise the dialog would be visible in both snapshots and flicker.
|
||||
selectTheme(value, () => setOpen(false));
|
||||
}
|
||||
|
||||
function onClickHandler(key: string): void {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { GlobalShortcutsName } from 'constants/shortcuts/globalShortcuts';
|
||||
import { THEME_MODE } from 'hooks/useDarkMode/constant';
|
||||
import { THEME_MODE, ThemeMode } from 'hooks/useDarkMode/constant';
|
||||
import {
|
||||
BarChart,
|
||||
BellDot,
|
||||
@@ -34,7 +34,7 @@ export type CmdAction = {
|
||||
|
||||
type ActionDeps = {
|
||||
navigate: (path: string) => void;
|
||||
handleThemeChange: (mode: string) => void;
|
||||
handleThemeChange: (mode: ThemeMode) => void;
|
||||
/**
|
||||
* Provided only when the AI Assistant feature is available for the current
|
||||
* tenant. When present, the palette surfaces an "Open AI Assistant" entry
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
.settings-tabs {
|
||||
.ant-tabs-nav-list {
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
transition: opacity 0.1s !important;
|
||||
|
||||
.ant-tabs-tab + .ant-tabs-tab {
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.ant-tabs-tab:not(:last-child) {
|
||||
border-right: 1px solid var(--l1-border) !important;
|
||||
}
|
||||
|
||||
.overview-btn {
|
||||
width: 114px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.variables-btn {
|
||||
width: 114px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.public-dashboard-btn {
|
||||
width: 150px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.disabled-btn {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-ink-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-tabs-tab-active {
|
||||
.overview-btn {
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.variables-btn {
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.public-dashboard-btn {
|
||||
border-radius: 2px 0px 0px 2px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-nav::before {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Tabs, Tooltip } from 'antd';
|
||||
import { Tabs, TabItemProps } from '@signozhq/ui/tabs';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { Braces, Globe, Table } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
@@ -9,8 +9,6 @@ import DashboardVariableSettings from './DashboardVariableSettings';
|
||||
import GeneralDashboardSettings from './General';
|
||||
import PublicDashboardSetting from './PublicDashboard';
|
||||
|
||||
import './DashboardSettingsContent.styles.scss';
|
||||
|
||||
function DashboardSettings({
|
||||
variablesSettingsTabHandle,
|
||||
}: {
|
||||
@@ -21,49 +19,26 @@ function DashboardSettings({
|
||||
|
||||
const enablePublicDashboard = isCloudUser || isEnterpriseSelfHostedUser;
|
||||
|
||||
const publicDashboardItem = {
|
||||
label: (
|
||||
<Tooltip
|
||||
title={
|
||||
user?.role !== USER_ROLES.ADMIN
|
||||
? 'Only admins can publish / manage public dashboards'
|
||||
: ''
|
||||
}
|
||||
placement="right"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<Globe size={14} />}
|
||||
className={`public-dashboard-btn ${
|
||||
user?.role !== USER_ROLES.ADMIN ? 'disabled-btn' : ''
|
||||
}`}
|
||||
>
|
||||
Publish
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
const publicDashboardItem: TabItemProps = {
|
||||
key: 'public-dashboard',
|
||||
label: 'Publish',
|
||||
prefixIcon: <Globe size={14} />,
|
||||
children: <PublicDashboardSetting />,
|
||||
disabled: user?.role !== USER_ROLES.ADMIN,
|
||||
disabledReason: 'Only admins can publish / manage public dashboards',
|
||||
};
|
||||
|
||||
const items = [
|
||||
const items: TabItemProps[] = [
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Table size={14} />} className="overview-btn">
|
||||
Overview
|
||||
</Button>
|
||||
),
|
||||
key: 'general',
|
||||
label: 'Overview',
|
||||
prefixIcon: <Table size={14} />,
|
||||
children: <GeneralDashboardSettings />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Button type="text" icon={<Braces size={14} />} className="variables-btn">
|
||||
Variables
|
||||
</Button>
|
||||
),
|
||||
key: 'variables',
|
||||
label: 'Variables',
|
||||
prefixIcon: <Braces size={14} />,
|
||||
children: (
|
||||
<DashboardVariableSettings
|
||||
variablesSettingsTabHandle={variablesSettingsTabHandle}
|
||||
@@ -73,7 +48,7 @@ function DashboardSettings({
|
||||
...(enablePublicDashboard ? [publicDashboardItem] : []),
|
||||
];
|
||||
|
||||
return <Tabs items={items} animated className="settings-tabs" />;
|
||||
return <Tabs items={items} />;
|
||||
}
|
||||
|
||||
export default DashboardSettings;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Button, Tabs, TabsProps } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Tabs, TabItemProps } from '@signozhq/ui/tabs';
|
||||
import ConfigureIcon from 'assets/Integrations/ConfigureIcon';
|
||||
import { CableCar, Group } from '@signozhq/icons';
|
||||
import { IntegrationDetailedProps } from 'types/api/integrations/types';
|
||||
@@ -22,18 +21,11 @@ function IntegrationDetailContent(
|
||||
): JSX.Element {
|
||||
const { activeDetailTab, integrationData, integrationId, setActiveDetailTab } =
|
||||
props;
|
||||
const items: TabsProps['items'] = [
|
||||
const items: TabItemProps[] = [
|
||||
{
|
||||
key: 'overview',
|
||||
label: (
|
||||
<Button
|
||||
type="text"
|
||||
className="integration-tab-btns"
|
||||
icon={<CableCar size={14} />}
|
||||
>
|
||||
<Typography.Text className="typography">Overview</Typography.Text>
|
||||
</Button>
|
||||
),
|
||||
label: 'Overview',
|
||||
prefixIcon: <CableCar size={14} />,
|
||||
children: (
|
||||
<Overview
|
||||
categories={integrationData.categories}
|
||||
@@ -44,15 +36,8 @@ function IntegrationDetailContent(
|
||||
},
|
||||
{
|
||||
key: 'configuration',
|
||||
label: (
|
||||
<Button
|
||||
type="text"
|
||||
className="integration-tab-btns"
|
||||
icon={<ConfigureIcon />}
|
||||
>
|
||||
<Typography.Text className="typography">Configure</Typography.Text>
|
||||
</Button>
|
||||
),
|
||||
label: 'Configure',
|
||||
prefixIcon: <ConfigureIcon />,
|
||||
children: (
|
||||
<Configure
|
||||
configuration={integrationData.configuration}
|
||||
@@ -62,15 +47,8 @@ function IntegrationDetailContent(
|
||||
},
|
||||
{
|
||||
key: 'dataCollected',
|
||||
label: (
|
||||
<Button
|
||||
type="text"
|
||||
className="integration-tab-btns"
|
||||
icon={<Group size={14} />}
|
||||
>
|
||||
<Typography.Text className="typography">Data Collected</Typography.Text>
|
||||
</Button>
|
||||
),
|
||||
label: 'Data Collected',
|
||||
prefixIcon: <Group size={14} />,
|
||||
children: (
|
||||
<DataCollected
|
||||
logsData={integrationData.data_collected.logs}
|
||||
@@ -81,11 +59,7 @@ function IntegrationDetailContent(
|
||||
];
|
||||
return (
|
||||
<div className="integration-detail-container">
|
||||
<Tabs
|
||||
activeKey={activeDetailTab}
|
||||
items={items}
|
||||
onChange={setActiveDetailTab}
|
||||
/>
|
||||
<Tabs value={activeDetailTab} items={items} onChange={setActiveDetailTab} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,45 +168,6 @@
|
||||
padding: 10px 16px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
|
||||
.integration-tab-btns {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 8px 18px 8px !important;
|
||||
|
||||
.typography {
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.integration-tab-btns:hover {
|
||||
&.ant-btn-text {
|
||||
background-color: unset !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-nav-list {
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
.ant-tabs-tab {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-tabs-tab + .ant-tabs-tab {
|
||||
margin: 0px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.uninstall-integration-bar {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,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';
|
||||
@@ -24,9 +26,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);
|
||||
|
||||
@@ -59,7 +62,7 @@ function MySettings(): JSX.Element {
|
||||
<Moon data-testid="dark-theme-icon" size={12} /> Dark{' '}
|
||||
</div>
|
||||
),
|
||||
value: 'dark',
|
||||
value: THEME_MODE.DARK,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
@@ -68,7 +71,7 @@ function MySettings(): JSX.Element {
|
||||
<Badge color="robin">Beta</Badge>
|
||||
</div>
|
||||
),
|
||||
value: 'light',
|
||||
value: THEME_MODE.LIGHT,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
@@ -76,46 +79,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 = (value: string): 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();
|
||||
}
|
||||
}
|
||||
// ToggleGroupSimple items above are all THEME_MODE values, so narrowing
|
||||
// the string here is safe.
|
||||
const mode = value as ThemeMode;
|
||||
logEvent('Account Settings: Theme Changed', { theme: mode });
|
||||
selectTheme(mode);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (autoSwitch) {
|
||||
setTheme('auto');
|
||||
setTheme(THEME_MODE.SYSTEM);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDarkMode) {
|
||||
setTheme('dark');
|
||||
} else {
|
||||
setTheme('light');
|
||||
}
|
||||
setTheme(isDarkMode ? THEME_MODE.DARK : THEME_MODE.LIGHT);
|
||||
}, [autoSwitch, isDarkMode]);
|
||||
|
||||
const handleSideNavPinnedChange = (checked: boolean): void => {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import { useThemeSelection } from '../useThemeSelection';
|
||||
|
||||
const setThemeMock = jest.fn();
|
||||
const setAutoSwitchMock = jest.fn();
|
||||
let themeValue = 'dark';
|
||||
let systemThemeValue: 'dark' | 'light' = 'light';
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
__esModule: true,
|
||||
default: (): {
|
||||
theme: string;
|
||||
setTheme: jest.Mock;
|
||||
setAutoSwitch: jest.Mock;
|
||||
toggleTheme: jest.Mock;
|
||||
autoSwitch: boolean;
|
||||
} => ({
|
||||
theme: themeValue,
|
||||
setTheme: setThemeMock,
|
||||
setAutoSwitch: setAutoSwitchMock,
|
||||
toggleTheme: jest.fn(),
|
||||
autoSwitch: false,
|
||||
}),
|
||||
useThemeMode: (): {
|
||||
theme: string;
|
||||
setTheme: jest.Mock;
|
||||
setAutoSwitch: jest.Mock;
|
||||
toggleTheme: jest.Mock;
|
||||
autoSwitch: boolean;
|
||||
} => ({
|
||||
theme: themeValue,
|
||||
setTheme: setThemeMock,
|
||||
setAutoSwitch: setAutoSwitchMock,
|
||||
toggleTheme: jest.fn(),
|
||||
autoSwitch: false,
|
||||
}),
|
||||
useSystemTheme: (): 'dark' | 'light' => systemThemeValue,
|
||||
useIsDarkMode: (): boolean => themeValue === 'dark',
|
||||
}));
|
||||
|
||||
const canAnimateMock = jest.fn();
|
||||
const runTransitionMock = jest.fn();
|
||||
|
||||
jest.mock('utils/themeTransition', () => ({
|
||||
__esModule: true,
|
||||
canAnimateThemeTransition: (): boolean => canAnimateMock(),
|
||||
runThemeTransition: (cb: () => void): void => runTransitionMock(cb),
|
||||
}));
|
||||
|
||||
describe('useThemeSelection', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
themeValue = 'dark';
|
||||
systemThemeValue = 'light';
|
||||
canAnimateMock.mockReturnValue(false);
|
||||
// Default behaviour: invoke the applyChange callback synchronously.
|
||||
runTransitionMock.mockImplementation((cb: () => void) => cb());
|
||||
});
|
||||
|
||||
it('applies an explicit light theme without auto-switch', () => {
|
||||
themeValue = 'dark';
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('light'));
|
||||
|
||||
expect(setAutoSwitchMock).toHaveBeenCalledWith(false);
|
||||
expect(setThemeMock).toHaveBeenCalledWith('light');
|
||||
});
|
||||
|
||||
it('applies an explicit dark theme without auto-switch', () => {
|
||||
themeValue = 'light';
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('dark'));
|
||||
|
||||
expect(setAutoSwitchMock).toHaveBeenCalledWith(false);
|
||||
expect(setThemeMock).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
it('SYSTEM with a light system preference resolves to setTheme("light") + auto on', () => {
|
||||
themeValue = 'dark';
|
||||
systemThemeValue = 'light';
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('auto'));
|
||||
|
||||
expect(setAutoSwitchMock).toHaveBeenCalledWith(true);
|
||||
// Explicit resolved value is what keeps the wipe snapshot accurate;
|
||||
// see the comment in useThemeSelection for the failure mode.
|
||||
expect(setThemeMock).toHaveBeenCalledWith('light');
|
||||
});
|
||||
|
||||
it('SYSTEM with a dark system preference resolves to setTheme("dark") + auto on', () => {
|
||||
themeValue = 'light';
|
||||
systemThemeValue = 'dark';
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('auto'));
|
||||
|
||||
expect(setAutoSwitchMock).toHaveBeenCalledWith(true);
|
||||
expect(setThemeMock).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
it('invokes onApplied inside the same batch, after the state mutations', () => {
|
||||
themeValue = 'dark';
|
||||
const onApplied = jest.fn();
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('light', onApplied));
|
||||
|
||||
expect(onApplied).toHaveBeenCalledTimes(1);
|
||||
expect(setThemeMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
onApplied.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('routes through runThemeTransition when the dark↔light state actually flips', () => {
|
||||
themeValue = 'dark';
|
||||
canAnimateMock.mockReturnValue(true);
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('light'));
|
||||
|
||||
expect(runTransitionMock).toHaveBeenCalledTimes(1);
|
||||
expect(setThemeMock).toHaveBeenCalledWith('light');
|
||||
});
|
||||
|
||||
it('skips runThemeTransition when no dark↔light flip happens', () => {
|
||||
themeValue = 'dark';
|
||||
canAnimateMock.mockReturnValue(true);
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('dark'));
|
||||
|
||||
expect(runTransitionMock).not.toHaveBeenCalled();
|
||||
// applyChange still ran inline.
|
||||
expect(setThemeMock).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
it('skips runThemeTransition when SYSTEM resolves to the currently-rendered theme', () => {
|
||||
themeValue = 'dark';
|
||||
systemThemeValue = 'dark';
|
||||
canAnimateMock.mockReturnValue(true);
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('auto'));
|
||||
|
||||
expect(runTransitionMock).not.toHaveBeenCalled();
|
||||
expect(setAutoSwitchMock).toHaveBeenCalledWith(true);
|
||||
expect(setThemeMock).toHaveBeenCalledWith('dark');
|
||||
});
|
||||
|
||||
it('skips runThemeTransition when capability check is false even if the theme flips', () => {
|
||||
themeValue = 'dark';
|
||||
canAnimateMock.mockReturnValue(false);
|
||||
const { result } = renderHook(() => useThemeSelection());
|
||||
|
||||
act(() => result.current('light'));
|
||||
|
||||
expect(runTransitionMock).not.toHaveBeenCalled();
|
||||
expect(setThemeMock).toHaveBeenCalledWith('light');
|
||||
});
|
||||
});
|
||||
@@ -2,4 +2,6 @@ export const THEME_MODE = {
|
||||
LIGHT: 'light',
|
||||
DARK: 'dark',
|
||||
SYSTEM: 'auto',
|
||||
};
|
||||
} as const;
|
||||
|
||||
export type ThemeMode = (typeof THEME_MODE)[keyof typeof THEME_MODE];
|
||||
|
||||
@@ -18,7 +18,13 @@ import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
|
||||
import { THEME_MODE } from './constant';
|
||||
|
||||
export const ThemeContext = createContext({
|
||||
export const ThemeContext = createContext<{
|
||||
theme: string;
|
||||
toggleTheme: () => void;
|
||||
autoSwitch: boolean;
|
||||
setAutoSwitch: Dispatch<SetStateAction<boolean>>;
|
||||
setTheme: Dispatch<SetStateAction<string>>;
|
||||
}>({
|
||||
theme: THEME_MODE.DARK,
|
||||
toggleTheme: (): void => {},
|
||||
autoSwitch: false,
|
||||
|
||||
67
frontend/src/hooks/useDarkMode/useThemeSelection.ts
Normal file
67
frontend/src/hooks/useDarkMode/useThemeSelection.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
canAnimateThemeTransition,
|
||||
runThemeTransition,
|
||||
} from 'utils/themeTransition';
|
||||
|
||||
import useThemeMode, { useSystemTheme } from './index';
|
||||
import { THEME_MODE, ThemeMode } from './constant';
|
||||
|
||||
type SelectTheme = (value: ThemeMode, onApplied?: () => void) => void;
|
||||
|
||||
// Centralises the "apply a theme selection" flow used by MySettings and the
|
||||
// command palette: figures out whether the visible (dark↔light) theme is
|
||||
// actually flipping, applies the state change, and — when capable — wraps the
|
||||
// change in a left→right view-transition wipe.
|
||||
//
|
||||
// `value` is one of THEME_MODE.{LIGHT,DARK,SYSTEM}; `onApplied` runs inside the
|
||||
// same flushSync batch as the theme change (useful for, e.g., closing the
|
||||
// command palette so its dismissal is part of the captured "new" snapshot).
|
||||
export function useThemeSelection(): SelectTheme {
|
||||
const { theme, setTheme, setAutoSwitch } = useThemeMode();
|
||||
const systemTheme = useSystemTheme();
|
||||
|
||||
return useCallback<SelectTheme>(
|
||||
(value, onApplied) => {
|
||||
const currentIsDark = theme === THEME_MODE.DARK;
|
||||
|
||||
// When switching to SYSTEM, the visible theme flips iff the OS preference
|
||||
// differs from what we're currently rendering. For explicit LIGHT/DARK,
|
||||
// resolvedTargetIsDark is just (value === DARK).
|
||||
const resolvedTargetIsDark =
|
||||
value === THEME_MODE.SYSTEM
|
||||
? systemTheme === THEME_MODE.DARK
|
||||
: value === THEME_MODE.DARK;
|
||||
const isSystem = value === THEME_MODE.SYSTEM;
|
||||
|
||||
// Always push the resolved LIGHT/DARK through setTheme synchronously so
|
||||
// the View Transition snapshot reflects the new theme. If we relied on
|
||||
// ThemeProvider's effect (setAutoSwitch → re-render → effect →
|
||||
// setThemeState), the flip wouldn't be guaranteed to run inside this
|
||||
// flushSync batch and the wipe would capture old → old, then snap.
|
||||
const resolvedTheme = resolvedTargetIsDark
|
||||
? THEME_MODE.DARK
|
||||
: THEME_MODE.LIGHT;
|
||||
|
||||
// runThemeTransition needs a zero-arg callback, so this closure is
|
||||
// unavoidable. It allocates once per selection — cheap enough that
|
||||
// micro-optimising it would just obscure the flow.
|
||||
const apply = (): void => {
|
||||
setAutoSwitch(isSystem);
|
||||
setTheme(resolvedTheme);
|
||||
onApplied?.();
|
||||
};
|
||||
|
||||
const willFlipDarkMode = resolvedTargetIsDark !== currentIsDark;
|
||||
if (!willFlipDarkMode || !canAnimateThemeTransition()) {
|
||||
apply();
|
||||
return;
|
||||
}
|
||||
|
||||
runThemeTransition(apply);
|
||||
},
|
||||
[theme, systemTheme, setTheme, setAutoSwitch],
|
||||
);
|
||||
}
|
||||
|
||||
export default useThemeSelection;
|
||||
@@ -1,26 +1,3 @@
|
||||
.service-route-tab {
|
||||
margin-bottom: 64px;
|
||||
.ant-tabs-nav {
|
||||
&::before {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
.ant-tabs-nav-wrap {
|
||||
.ant-tabs-nav-list {
|
||||
.ant-tabs-ink-bar {
|
||||
background-color: var(--primary-background) !important;
|
||||
}
|
||||
.ant-tabs-tab {
|
||||
font-size: 13px;
|
||||
font-family: 'Inter';
|
||||
color: var(--l1-foreground);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
gap: 10px;
|
||||
}
|
||||
.ant-tabs-tab.ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Tabs, TabsProps } from 'antd';
|
||||
import { Tabs, TabItemProps } from '@signozhq/ui/tabs';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import DBCall from 'container/MetricsApplication/Tabs/DBCall';
|
||||
import External from 'container/MetricsApplication/Tabs/External';
|
||||
@@ -24,7 +24,7 @@ function MetricsApplication(): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
const items: TabsProps['items'] = [
|
||||
const items: TabItemProps[] = [
|
||||
{
|
||||
label: TAB_KEY_VS_LABEL[MetricsApplicationTab.OVER_METRICS],
|
||||
key: MetricsApplicationTab.OVER_METRICS,
|
||||
@@ -53,9 +53,8 @@ function MetricsApplication(): JSX.Element {
|
||||
<ApDexApplication />
|
||||
<Tabs
|
||||
items={items}
|
||||
activeKey={activeKey}
|
||||
value={activeKey}
|
||||
className="service-route-tab"
|
||||
destroyInactiveTabPane
|
||||
onChange={onTabChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
.pipeline-tabs {
|
||||
.ant-tabs-content {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.ant-tabs-tabpane-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,7 @@ import { useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from 'react-query';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import type { TabsProps } from 'antd';
|
||||
import { Tabs } from 'antd';
|
||||
import { Tabs, TabItemProps } from '@signozhq/ui/tabs';
|
||||
import getPipeline from 'api/pipeline/get';
|
||||
import Spinner from 'components/Spinner';
|
||||
import ChangeHistory from 'container/PipelinePage/Layouts/ChangeHistory';
|
||||
@@ -13,8 +12,6 @@ import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFall
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { Pipeline } from 'types/api/pipeline/def';
|
||||
|
||||
import './Pipelines.styles.scss';
|
||||
|
||||
const pipelineRefetchInterval = (
|
||||
pipelineResponse: SuccessResponse<Pipeline> | undefined,
|
||||
): number | false => {
|
||||
@@ -46,7 +43,7 @@ function Pipelines(): JSX.Element {
|
||||
refetchInterval: pipelineRefetchInterval,
|
||||
});
|
||||
|
||||
const tabItems: TabsProps['items'] = useMemo(
|
||||
const tabItems: TabItemProps[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'pipelines',
|
||||
@@ -83,11 +80,7 @@ function Pipelines(): JSX.Element {
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<Tabs
|
||||
className="pipeline-tabs"
|
||||
defaultActiveKey="pipelines"
|
||||
items={tabItems}
|
||||
/>
|
||||
<Tabs defaultValue="pipelines" items={tabItems} />
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@ function SettingsPage(): JSX.Element {
|
||||
routes={routes}
|
||||
activeKey={pathname}
|
||||
history={history}
|
||||
tabBarStyle={{ display: 'none' }}
|
||||
hideTabBar
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,88 +71,6 @@
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
padding-top: 16px;
|
||||
|
||||
.flamegraph-waterfall-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 31px;
|
||||
color: var(--l2-foreground);
|
||||
padding: 5px 20px;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.span-list-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 31px;
|
||||
padding: 5px 20px;
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
.ant-btn-icon {
|
||||
margin-inline-end: 0px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-visualisation-tabs {
|
||||
.ant-tabs-tab {
|
||||
border-radius: 2px 0px 0px 0px;
|
||||
background: var(--l2-background);
|
||||
border-radius: 2px 2px 0px 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
height: 31px;
|
||||
}
|
||||
|
||||
.ant-tabs-tab-active {
|
||||
background-color: var(--l1-background);
|
||||
|
||||
.ant-btn {
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-tab + .ant-tabs-tab {
|
||||
margin: 0px;
|
||||
border-left: 0px;
|
||||
}
|
||||
|
||||
.ant-tabs-ink-bar {
|
||||
height: 1px !important;
|
||||
background: var(--l1-background) !important;
|
||||
}
|
||||
|
||||
.ant-tabs-nav-list {
|
||||
transform: translate(15px, 0px) !important;
|
||||
}
|
||||
|
||||
.ant-tabs-nav::before {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.ant-tabs-nav {
|
||||
margin: 0px;
|
||||
padding: 0px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from '@signozhq/resizable';
|
||||
import { Button, Tabs } from 'antd';
|
||||
import { Tabs, TabItemProps } from '@signozhq/ui/tabs';
|
||||
import FlamegraphImg from 'assets/TraceDetail/Flamegraph';
|
||||
import cx from 'classnames';
|
||||
import TraceFlamegraph from 'container/PaginatedTraceFlamegraph/PaginatedTraceFlamegraph';
|
||||
@@ -86,18 +86,11 @@ function TraceDetailsV2(): JSX.Element {
|
||||
}
|
||||
}, [noData]);
|
||||
|
||||
const items = [
|
||||
const items: TabItemProps[] = [
|
||||
{
|
||||
label: (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<FlamegraphImg />}
|
||||
className="flamegraph-waterfall-toggle"
|
||||
>
|
||||
Flamegraph
|
||||
</Button>
|
||||
),
|
||||
key: 'flamegraph',
|
||||
label: 'Flamegraph',
|
||||
prefixIcon: <FlamegraphImg />,
|
||||
children: (
|
||||
<>
|
||||
<TraceFlamegraph
|
||||
@@ -145,11 +138,7 @@ function TraceDetailsV2(): JSX.Element {
|
||||
totalSpans={traceData?.payload?.totalSpansCount || 0}
|
||||
notFound={noData}
|
||||
/>
|
||||
{!noData ? (
|
||||
<Tabs items={items} animated className="trace-visualisation-tabs" />
|
||||
) : (
|
||||
<NoData />
|
||||
)}
|
||||
{!noData ? <Tabs items={items} /> : <NoData />}
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle className="resizable-handle" />
|
||||
|
||||
@@ -22,21 +22,6 @@ $dark-theme: 'darkMode';
|
||||
|
||||
&__tabs {
|
||||
margin-top: 148px;
|
||||
|
||||
.ant-tabs {
|
||||
&-nav {
|
||||
&::before {
|
||||
border-color: var(--l1-border);
|
||||
|
||||
.#{$light-theme} & {
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
&-nav-wrap {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__modal {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from 'react-query';
|
||||
import type { TabsProps } from 'antd';
|
||||
import type { TabItemProps } from '@signozhq/ui/tabs';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
Row,
|
||||
Skeleton,
|
||||
Space,
|
||||
Tabs,
|
||||
} from 'antd';
|
||||
import { Tabs } from '@signozhq/ui/tabs';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import updateCreditCardApi from 'api/v1/checkout/create';
|
||||
@@ -154,7 +154,7 @@ export default function WorkspaceBlocked(): JSX.Element {
|
||||
/>
|
||||
));
|
||||
|
||||
const tabItems: TabsProps['items'] = [
|
||||
const tabItems: TabItemProps[] = [
|
||||
{
|
||||
key: 'whyChooseSignoz',
|
||||
label: t('whyChooseSignoz'),
|
||||
@@ -398,8 +398,8 @@ export default function WorkspaceBlocked(): JSX.Element {
|
||||
<div className="workspace-locked__tabs">
|
||||
<Tabs
|
||||
items={tabItems}
|
||||
defaultActiveKey="youAreInGoodCompany"
|
||||
onTabClick={handleTabClick}
|
||||
defaultValue="youAreInGoodCompany"
|
||||
onChange={handleTabClick}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -825,5 +825,25 @@ body.ai-assistant-panel-open {
|
||||
// overrides
|
||||
:root {
|
||||
--input-focus-outline-width: 0;
|
||||
--tab-list-primary-gap: 12px;
|
||||
--radius-2: 4px;
|
||||
}
|
||||
|
||||
// Scoped to .theme-wipe-active (toggled on <html> in runThemeTransition) so
|
||||
// these overrides don't leak into any unrelated view transitions added later.
|
||||
// We disable the default UA crossfade so the JS-driven clip-path wipe is the
|
||||
// only visible effect, and stack the new snapshot above the old.
|
||||
html.theme-wipe-active {
|
||||
&::view-transition-old(root),
|
||||
&::view-transition-new(root) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
&::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&::view-transition-new(root) {
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
193
frontend/src/utils/__tests__/themeTransition.test.ts
Normal file
193
frontend/src/utils/__tests__/themeTransition.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
canAnimateThemeTransition,
|
||||
runThemeTransition,
|
||||
THEME_WIPE_ACTIVE_CLASS,
|
||||
} from '../themeTransition';
|
||||
|
||||
type StartVT = (cb: () => void) => {
|
||||
ready: Promise<void>;
|
||||
finished: Promise<void>;
|
||||
};
|
||||
|
||||
const installStartViewTransition = (impl?: StartVT): jest.Mock => {
|
||||
const defaultImpl: StartVT = (cb) => {
|
||||
cb();
|
||||
return { ready: Promise.resolve(), finished: Promise.resolve() };
|
||||
};
|
||||
const fn = jest.fn(impl ?? defaultImpl);
|
||||
Object.defineProperty(document, 'startViewTransition', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: fn,
|
||||
});
|
||||
return fn;
|
||||
};
|
||||
|
||||
const removeStartViewTransition = (): void => {
|
||||
Object.defineProperty(document, 'startViewTransition', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const setReducedMotion = (matches: boolean): void => {
|
||||
(window.matchMedia as jest.Mock) = jest
|
||||
.fn()
|
||||
.mockImplementation((query: string) => ({
|
||||
matches: query === '(prefers-reduced-motion: reduce)' ? matches : false,
|
||||
media: query,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
};
|
||||
|
||||
describe('canAnimateThemeTransition', () => {
|
||||
afterEach(() => {
|
||||
removeStartViewTransition();
|
||||
});
|
||||
|
||||
it('returns false when document.startViewTransition is unavailable', () => {
|
||||
removeStartViewTransition();
|
||||
setReducedMotion(false);
|
||||
expect(canAnimateThemeTransition()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when prefers-reduced-motion is reduce', () => {
|
||||
installStartViewTransition();
|
||||
setReducedMotion(true);
|
||||
expect(canAnimateThemeTransition()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when API is supported and motion is allowed', () => {
|
||||
installStartViewTransition();
|
||||
setReducedMotion(false);
|
||||
expect(canAnimateThemeTransition()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runThemeTransition', () => {
|
||||
afterEach(() => {
|
||||
removeStartViewTransition();
|
||||
document.documentElement.classList.remove(THEME_WIPE_ACTIVE_CLASS);
|
||||
});
|
||||
|
||||
it('falls back to running applyChange directly when API is missing', () => {
|
||||
removeStartViewTransition();
|
||||
const applyChange = jest.fn();
|
||||
runThemeTransition(applyChange);
|
||||
expect(applyChange).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('invokes startViewTransition and runs applyChange inside its callback', () => {
|
||||
const startVT = installStartViewTransition();
|
||||
const applyChange = jest.fn();
|
||||
runThemeTransition(applyChange);
|
||||
expect(startVT).toHaveBeenCalledTimes(1);
|
||||
expect(applyChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('toggles the wipe-active class on <html> for the lifetime of the transition', async () => {
|
||||
let resolveFinished: () => void = (): void => {};
|
||||
installStartViewTransition((cb) => {
|
||||
cb();
|
||||
return {
|
||||
ready: Promise.resolve(),
|
||||
finished: new Promise<void>((resolve) => {
|
||||
resolveFinished = resolve;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
runThemeTransition(() => undefined);
|
||||
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(true);
|
||||
|
||||
resolveFinished();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the wipe-active class through overlapping transitions', async () => {
|
||||
let resolveA: () => void = (): void => {};
|
||||
let resolveB: () => void = (): void => {};
|
||||
let callIndex = 0;
|
||||
installStartViewTransition((cb) => {
|
||||
cb();
|
||||
callIndex += 1;
|
||||
if (callIndex === 1) {
|
||||
return {
|
||||
ready: Promise.resolve(),
|
||||
finished: new Promise<void>((resolve) => {
|
||||
resolveA = resolve;
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ready: Promise.resolve(),
|
||||
finished: new Promise<void>((resolve) => {
|
||||
resolveB = resolve;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
runThemeTransition(() => undefined);
|
||||
runThemeTransition(() => undefined);
|
||||
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(true);
|
||||
|
||||
// First transition finishes — class must stay because B is still in flight.
|
||||
resolveA();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(true);
|
||||
|
||||
resolveB();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to applyChange and releases the class when startViewTransition throws before its callback runs', () => {
|
||||
installStartViewTransition(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const applyChange = jest.fn();
|
||||
runThemeTransition(applyChange);
|
||||
expect(applyChange).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not double-invoke applyChange when startViewTransition throws after its callback runs', () => {
|
||||
installStartViewTransition((cb) => {
|
||||
cb();
|
||||
throw new Error('post-cb');
|
||||
});
|
||||
const applyChange = jest.fn();
|
||||
runThemeTransition(applyChange);
|
||||
expect(applyChange).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
document.documentElement.classList.contains(THEME_WIPE_ACTIVE_CLASS),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
115
frontend/src/utils/themeTransition.ts
Normal file
115
frontend/src/utils/themeTransition.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { flushSync } from 'react-dom';
|
||||
|
||||
const WIPE_DURATION_MS = 400;
|
||||
const WIPE_EASING = 'ease-out';
|
||||
|
||||
// Toggled on <html> for the duration of the wipe so the CSS overrides
|
||||
// (animation: none on ::view-transition-{old,new}(root)) don't leak into
|
||||
// any future, unrelated view transitions in the app.
|
||||
export const THEME_WIPE_ACTIVE_CLASS = 'theme-wipe-active';
|
||||
|
||||
type ViewTransition = {
|
||||
ready: Promise<void>;
|
||||
finished: Promise<void>;
|
||||
};
|
||||
type DocumentWithVT = Document & {
|
||||
startViewTransition?: (callback: () => void) => ViewTransition;
|
||||
};
|
||||
|
||||
// Rapid theme switches cancel the in-flight transition and immediately start a
|
||||
// new one; if we removed the class on the first transition's settled promise,
|
||||
// we'd strip the CSS override mid-way through the next wipe and the user
|
||||
// would briefly see the UA crossfade. Refcount so the class only comes off
|
||||
// once every transition we started has settled.
|
||||
let wipeActiveRefCount = 0;
|
||||
const acquireWipeClass = (root: HTMLElement): void => {
|
||||
wipeActiveRefCount += 1;
|
||||
root.classList.add(THEME_WIPE_ACTIVE_CLASS);
|
||||
};
|
||||
const releaseWipeClass = (root: HTMLElement): void => {
|
||||
wipeActiveRefCount = Math.max(0, wipeActiveRefCount - 1);
|
||||
if (wipeActiveRefCount === 0) {
|
||||
root.classList.remove(THEME_WIPE_ACTIVE_CLASS);
|
||||
}
|
||||
};
|
||||
|
||||
// Identity of the transition we most recently started. Used to skip the
|
||||
// .animate() call on a stale transition whose .ready resolved after a newer
|
||||
// transition has already taken over the ::view-transition-new pseudo-element.
|
||||
let currentTransition: ViewTransition | null = null;
|
||||
|
||||
export function canAnimateThemeTransition(): boolean {
|
||||
const doc = document as DocumentWithVT;
|
||||
if (typeof doc.startViewTransition !== 'function') {
|
||||
return false;
|
||||
}
|
||||
return !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
|
||||
// Runs `applyChange` inside a View Transition and wipes the new theme in from
|
||||
// left to right via a polygon clip-path on ::view-transition-new(root).
|
||||
// Callers should gate on canAnimateThemeTransition() first; this is a safe
|
||||
// no-animation fallback otherwise.
|
||||
export function runThemeTransition(applyChange: () => void): void {
|
||||
const doc = document as DocumentWithVT;
|
||||
if (!doc.startViewTransition) {
|
||||
applyChange();
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
acquireWipeClass(root);
|
||||
|
||||
// Some Chromium versions throw if startViewTransition is called while
|
||||
// another transition is in setup. Track whether the callback ran so we
|
||||
// don't double-apply if the throw happens mid-callback.
|
||||
let applied = false;
|
||||
let transition: ViewTransition;
|
||||
try {
|
||||
transition = doc.startViewTransition(() => {
|
||||
applied = true;
|
||||
flushSync(applyChange);
|
||||
});
|
||||
} catch {
|
||||
releaseWipeClass(root);
|
||||
if (!applied) {
|
||||
applyChange();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
currentTransition = transition;
|
||||
|
||||
const from = 'polygon(0 0, 0 0, 0 100%, 0 100%)';
|
||||
const to = 'polygon(0 0, 100% 0, 100% 100%, 0 100%)';
|
||||
|
||||
transition.ready
|
||||
.then(() => {
|
||||
// If a newer transition has superseded this one between
|
||||
// startViewTransition() and `ready` resolving, the browser has
|
||||
// already cancelled our pseudo-element. Calling .animate() on it now
|
||||
// would race with the newer transition's own animation.
|
||||
if (currentTransition !== transition) {
|
||||
return;
|
||||
}
|
||||
root.animate(
|
||||
{ clipPath: [from, to] },
|
||||
{
|
||||
duration: WIPE_DURATION_MS,
|
||||
easing: WIPE_EASING,
|
||||
pseudoElement: '::view-transition-new(root)',
|
||||
},
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
// Transition cancelled — applyChange has already run.
|
||||
});
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (currentTransition === transition) {
|
||||
currentTransition = null;
|
||||
}
|
||||
releaseWipeClass(root);
|
||||
};
|
||||
transition.finished.then(cleanup).catch(cleanup);
|
||||
}
|
||||
@@ -403,7 +403,7 @@ func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if whereClause == nil || whereClause.WhereClause == nil {
|
||||
if whereClause.IsEmpty() {
|
||||
return sqlbuilder.NewWhereClause(), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -964,7 +964,7 @@ func (m *module) buildFilterClause(ctx context.Context, filter *qbtypes.Filter,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if whereClause == nil || whereClause.WhereClause == nil {
|
||||
if whereClause.IsEmpty() {
|
||||
return sqlbuilder.NewWhereClause(), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -510,9 +510,7 @@ func (s *store) buildFilterClause(ctx context.Context, filter qbtypes.Filter, st
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if prepared == nil || prepared.WhereClause == nil {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
|
||||
return prepared.WhereClause, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,6 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
|
||||
dataType = telemetrytypes.FieldDataTypeFloat64
|
||||
}
|
||||
|
||||
//
|
||||
bodyJSONEnabled := v.flagger.BooleanOrEmpty(v.ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
|
||||
|
||||
// Handle *If functions with predicate + values
|
||||
@@ -231,8 +230,8 @@ func (v *exprVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// not possible for whereClause to be nil here but still adding a check.
|
||||
if whereClause == nil {
|
||||
// not possible for whereClause to be empty here but still adding a check.
|
||||
if whereClause.IsEmpty() {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid predicate argument for %q: %q", name, origPred)
|
||||
}
|
||||
|
||||
|
||||
@@ -96,8 +96,12 @@ type PreparedWhereClause struct {
|
||||
WarningsDocURL string
|
||||
}
|
||||
|
||||
func (p PreparedWhereClause) IsEmpty() bool {
|
||||
return p.WhereClause == nil
|
||||
}
|
||||
|
||||
// PrepareWhereClause generates a ClickHouse compatible WHERE clause from the filter query.
|
||||
func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (*PreparedWhereClause, error) {
|
||||
func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (PreparedWhereClause, error) {
|
||||
|
||||
// Setup the ANTLR parsing pipeline
|
||||
input := antlr.NewInputStream(query)
|
||||
@@ -148,7 +152,7 @@ func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (*PreparedWher
|
||||
}
|
||||
}
|
||||
|
||||
return nil, combinedErrors.WithAdditional(additionals...).WithUrl(searchTroubleshootingGuideURL)
|
||||
return PreparedWhereClause{}, combinedErrors.WithAdditional(additionals...).WithUrl(searchTroubleshootingGuideURL)
|
||||
}
|
||||
|
||||
// Visit the parse tree with our ClickHouse visitor
|
||||
@@ -166,18 +170,17 @@ func PrepareWhereClause(query string, opts FilterExprVisitorOpts) (*PreparedWher
|
||||
if url == "" {
|
||||
url = searchTroubleshootingGuideURL
|
||||
}
|
||||
return nil, combinedErrors.WithAdditional(visitor.errors...).WithUrl(url)
|
||||
return PreparedWhereClause{}, combinedErrors.WithAdditional(visitor.errors...).WithUrl(url)
|
||||
}
|
||||
|
||||
// Return nil so callers can skip the
|
||||
// entire CTE/subquery rather than emitting WHERE clause that select all the rows
|
||||
// Return empty where clause so callers can skip the WHERE clause
|
||||
if cond == "" || cond == SkipConditionLiteral {
|
||||
return nil, nil //nolint:nilnil
|
||||
return PreparedWhereClause{WhereClause: nil, Warnings: visitor.warnings, WarningsDocURL: visitor.mainWarnURL}, nil
|
||||
}
|
||||
|
||||
whereClause := sqlbuilder.NewWhereClause().AddWhereExpr(visitor.builder.Args, cond)
|
||||
|
||||
return &PreparedWhereClause{WhereClause: whereClause, Warnings: visitor.warnings, WarningsDocURL: visitor.mainWarnURL}, nil
|
||||
return PreparedWhereClause{WhereClause: whereClause, Warnings: visitor.warnings, WarningsDocURL: visitor.mainWarnURL}, nil
|
||||
}
|
||||
|
||||
// Visit dispatches to the specific visit method based on node type.
|
||||
|
||||
@@ -874,7 +874,7 @@ func TestVisitComparison_AND(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
||||
@@ -883,7 +883,7 @@ func TestVisitComparison_AND(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
||||
@@ -968,7 +968,7 @@ func TestVisitComparison_NOT(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
||||
@@ -977,7 +977,7 @@ func TestVisitComparison_NOT(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
||||
@@ -1070,7 +1070,7 @@ func TestVisitComparison_OR(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
||||
@@ -1079,7 +1079,7 @@ func TestVisitComparison_OR(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
||||
@@ -1151,7 +1151,7 @@ func TestVisitComparison_Precedence(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
||||
@@ -1160,7 +1160,7 @@ func TestVisitComparison_Precedence(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
||||
@@ -1254,7 +1254,7 @@ func TestVisitComparison_Parens(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
||||
@@ -1263,7 +1263,7 @@ func TestVisitComparison_Parens(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
||||
@@ -1409,7 +1409,7 @@ func TestVisitComparison_FullText(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
||||
@@ -1418,7 +1418,7 @@ func TestVisitComparison_FullText(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
||||
@@ -1518,7 +1518,7 @@ func TestVisitComparison_AllVariable(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
||||
@@ -1527,7 +1527,7 @@ func TestVisitComparison_AllVariable(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
||||
@@ -1597,7 +1597,7 @@ func TestVisitComparison_FunctionCalls(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
||||
@@ -1606,7 +1606,7 @@ func TestVisitComparison_FunctionCalls(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
||||
@@ -1666,7 +1666,7 @@ func TestVisitComparison_UnknownKeys(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
||||
@@ -1675,7 +1675,7 @@ func TestVisitComparison_UnknownKeys(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
||||
@@ -1758,7 +1758,7 @@ func TestVisitComparison_SkippableLiteralValues(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrRSB, err != nil, "resourceConditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantRSB, expr, "resourceConditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantRSB, expr)
|
||||
@@ -1767,7 +1767,7 @@ func TestVisitComparison_SkippableLiteralValues(t *testing.T) {
|
||||
assert.Equal(t, tt.wantErrSB, err != nil, "conditionBuilder: error expectation mismatch")
|
||||
if err == nil {
|
||||
var expr string
|
||||
if result != nil {
|
||||
if !result.IsEmpty() {
|
||||
expr, _ = result.WhereClause.Build()
|
||||
}
|
||||
assert.Equal(t, tt.wantSB, expr, "conditionBuilder SQL mismatch:\n want: %s\n got: %s", tt.wantSB, expr)
|
||||
|
||||
@@ -282,12 +282,10 @@ func (b *auditQueryStatementBuilder) buildListQuery(
|
||||
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
}
|
||||
if preparedWhereClause != nil {
|
||||
stmt.Warnings = preparedWhereClause.Warnings
|
||||
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
@@ -422,12 +420,10 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
}
|
||||
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
}
|
||||
if preparedWhereClause != nil {
|
||||
stmt.Warnings = preparedWhereClause.Warnings
|
||||
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
@@ -526,12 +522,10 @@ func (b *auditQueryStatementBuilder) buildScalarQuery(
|
||||
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
}
|
||||
if preparedWhereClause != nil {
|
||||
stmt.Warnings = preparedWhereClause.Warnings
|
||||
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
@@ -544,8 +538,8 @@ func (b *auditQueryStatementBuilder) addFilterCondition(
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*querybuilder.PreparedWhereClause, error) {
|
||||
var preparedWhereClause *querybuilder.PreparedWhereClause
|
||||
) (querybuilder.PreparedWhereClause, error) {
|
||||
var preparedWhereClause querybuilder.PreparedWhereClause
|
||||
var err error
|
||||
|
||||
if query.Filter != nil && query.Filter.Expression != "" {
|
||||
@@ -564,11 +558,11 @@ func (b *auditQueryStatementBuilder) addFilterCondition(
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return preparedWhereClause, err
|
||||
}
|
||||
}
|
||||
|
||||
if preparedWhereClause != nil {
|
||||
if !preparedWhereClause.IsEmpty() {
|
||||
sb.AddWhereClause(preparedWhereClause.WhereClause)
|
||||
}
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ func TestFilterExprLogsBodyJSON(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
if clause == nil {
|
||||
if clause.IsEmpty() {
|
||||
t.Errorf("Expected clause for query: %s\n", tc.query)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2403,7 +2403,7 @@ func TestFilterExprLogs(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
if clause == nil {
|
||||
if clause.IsEmpty() {
|
||||
t.Errorf("Expected clause for query: %s\n", tc.query)
|
||||
return
|
||||
}
|
||||
@@ -2524,7 +2524,7 @@ func TestFilterExprLogsConflictNegation(t *testing.T) {
|
||||
return
|
||||
}
|
||||
|
||||
if clause == nil {
|
||||
if clause.IsEmpty() {
|
||||
t.Errorf("Expected clause for query: %s\n", tc.query)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -348,12 +348,10 @@ func (b *logQueryStatementBuilder) buildListQuery(
|
||||
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
}
|
||||
if preparedWhereClause != nil {
|
||||
stmt.Warnings = preparedWhereClause.Warnings
|
||||
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
@@ -506,12 +504,10 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
}
|
||||
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
}
|
||||
if preparedWhereClause != nil {
|
||||
stmt.Warnings = preparedWhereClause.Warnings
|
||||
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
@@ -627,12 +623,10 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
|
||||
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
}
|
||||
if preparedWhereClause != nil {
|
||||
stmt.Warnings = preparedWhereClause.Warnings
|
||||
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
@@ -646,9 +640,9 @@ func (b *logQueryStatementBuilder) addFilterCondition(
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation],
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*querybuilder.PreparedWhereClause, error) {
|
||||
) (querybuilder.PreparedWhereClause, error) {
|
||||
|
||||
var preparedWhereClause *querybuilder.PreparedWhereClause
|
||||
var preparedWhereClause querybuilder.PreparedWhereClause
|
||||
var err error
|
||||
// TODO(Tushar): thread orgID here to evaluate correctly
|
||||
bodyJSONEnabled := b.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(valuer.UUID{}))
|
||||
@@ -671,11 +665,11 @@ func (b *logQueryStatementBuilder) addFilterCondition(
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return preparedWhereClause, err
|
||||
}
|
||||
}
|
||||
|
||||
if preparedWhereClause != nil {
|
||||
if !preparedWhereClause.IsEmpty() {
|
||||
sb.AddWhereClause(preparedWhereClause.WhereClause)
|
||||
}
|
||||
|
||||
|
||||
@@ -237,8 +237,10 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
name string
|
||||
requestType qbtypes.RequestType
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]
|
||||
variables map[string]qbtypes.VariableItem
|
||||
expected qbtypes.Statement
|
||||
expectedErr error
|
||||
expectWarn bool
|
||||
}{
|
||||
{
|
||||
name: "default list",
|
||||
@@ -312,6 +314,22 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "filter skips entirely but emits LIKE-without-wildcards warning",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "message LIKE 'plain' OR message IN $env",
|
||||
},
|
||||
Limit: 10,
|
||||
},
|
||||
variables: map[string]qbtypes.VariableItem{
|
||||
"env": {Type: qbtypes.DynamicVariableType, Value: "__all__"},
|
||||
},
|
||||
expectedErr: nil,
|
||||
expectWarn: true,
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -340,16 +358,20 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
|
||||
q, err := statementBuilder.Build(ctx, 1747947419000, 1747983448000, c.requestType, c.query, nil)
|
||||
q, err := statementBuilder.Build(ctx, 1747947419000, 1747983448000, c.requestType, c.query, c.variables)
|
||||
|
||||
if c.expectedErr != nil {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), c.expectedErr.Error())
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.expected.Query, q.Query)
|
||||
require.Equal(t, c.expected.Args, q.Args)
|
||||
require.Equal(t, c.expected.Warnings, q.Warnings)
|
||||
if c.expectWarn {
|
||||
require.NotEmpty(t, q.Warnings)
|
||||
} else {
|
||||
require.Equal(t, c.expected.Query, q.Query)
|
||||
require.Equal(t, c.expected.Args, q.Args)
|
||||
require.Equal(t, c.expected.Warnings, q.Warnings)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1424,7 +1424,7 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, fieldValueSel
|
||||
if err != nil {
|
||||
t.logger.WarnContext(ctx, "error parsing existing query for related values", errors.Attr(err))
|
||||
}
|
||||
if whereClause != nil {
|
||||
if !whereClause.IsEmpty() {
|
||||
sb.AddWhereClause(whereClause.WhereClause)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDeltaFastPath(
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (string, []any, error) {
|
||||
var filterWhere *querybuilder.PreparedWhereClause
|
||||
var filterWhere querybuilder.PreparedWhereClause
|
||||
var err error
|
||||
stepSec := int64(query.StepInterval.Seconds())
|
||||
|
||||
@@ -161,7 +161,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDeltaFastPath(
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
if filterWhere != nil {
|
||||
if !filterWhere.IsEmpty() {
|
||||
sb.AddWhereClause(filterWhere.WhereClause)
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDelta(
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (string, []any, error) {
|
||||
var filterWhere *querybuilder.PreparedWhereClause
|
||||
var filterWhere querybuilder.PreparedWhereClause
|
||||
var err error
|
||||
|
||||
stepSec := int64(query.StepInterval.Seconds())
|
||||
@@ -250,7 +250,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggDelta(
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
if filterWhere != nil {
|
||||
if !filterWhere.IsEmpty() {
|
||||
sb.AddWhereClause(filterWhere.WhereClause)
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggCumulativeOrUnspecified(
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (string, []any, error) {
|
||||
var filterWhere *querybuilder.PreparedWhereClause
|
||||
var filterWhere querybuilder.PreparedWhereClause
|
||||
var err error
|
||||
stepSec := int64(query.StepInterval.Seconds())
|
||||
|
||||
@@ -320,7 +320,7 @@ func (b *meterQueryStatementBuilder) buildTemporalAggCumulativeOrUnspecified(
|
||||
return "", nil, err
|
||||
}
|
||||
}
|
||||
if filterWhere != nil {
|
||||
if !filterWhere.IsEmpty() {
|
||||
baseSb.AddWhereClause(filterWhere.WhereClause)
|
||||
}
|
||||
|
||||
|
||||
@@ -264,7 +264,7 @@ func (b *MetricQueryStatementBuilder) buildTimeSeriesCTE(
|
||||
) (string, []any, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
var preparedWhereClause *querybuilder.PreparedWhereClause
|
||||
var preparedWhereClause querybuilder.PreparedWhereClause
|
||||
var err error
|
||||
|
||||
if query.Filter != nil && query.Filter.Expression != "" {
|
||||
@@ -311,7 +311,7 @@ func (b *MetricQueryStatementBuilder) buildTimeSeriesCTE(
|
||||
sb.EQ("__normalized", false),
|
||||
)
|
||||
|
||||
if preparedWhereClause != nil {
|
||||
if !preparedWhereClause.IsEmpty() {
|
||||
sb.AddWhereClause(preparedWhereClause.WhereClause)
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ func (b *resourceFilterStatementBuilder[T]) addConditions(
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if filterWhereClause == nil {
|
||||
if filterWhereClause.IsEmpty() {
|
||||
// this means all conditions evaluated to no-op (non-resource fields, unknown keys, skipped full-text/functions)
|
||||
// the CTE would select all fingerprints, so skip it entirely
|
||||
return true, nil
|
||||
|
||||
@@ -353,12 +353,10 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
}
|
||||
if preparedWhereClause != nil {
|
||||
stmt.Warnings = preparedWhereClause.Warnings
|
||||
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
@@ -471,12 +469,10 @@ func (b *traceQueryStatementBuilder) buildTraceQuery(
|
||||
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
}
|
||||
if preparedWhereClause != nil {
|
||||
stmt.Warnings = preparedWhereClause.Warnings
|
||||
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
@@ -622,12 +618,10 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
}
|
||||
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
}
|
||||
if preparedWhereClause != nil {
|
||||
stmt.Warnings = preparedWhereClause.Warnings
|
||||
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
@@ -740,12 +734,10 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
|
||||
stmt := &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
}
|
||||
if preparedWhereClause != nil {
|
||||
stmt.Warnings = preparedWhereClause.Warnings
|
||||
stmt.WarningsDocURL = preparedWhereClause.WarningsDocURL
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: preparedWhereClause.Warnings,
|
||||
WarningsDocURL: preparedWhereClause.WarningsDocURL,
|
||||
}
|
||||
|
||||
return stmt, nil
|
||||
@@ -759,9 +751,9 @@ func (b *traceQueryStatementBuilder) addFilterCondition(
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*querybuilder.PreparedWhereClause, error) {
|
||||
) (querybuilder.PreparedWhereClause, error) {
|
||||
|
||||
var preparedWhereClause *querybuilder.PreparedWhereClause
|
||||
var preparedWhereClause querybuilder.PreparedWhereClause
|
||||
var err error
|
||||
|
||||
if query.Filter != nil && query.Filter.Expression != "" {
|
||||
@@ -779,11 +771,11 @@ func (b *traceQueryStatementBuilder) addFilterCondition(
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return preparedWhereClause, err
|
||||
}
|
||||
}
|
||||
|
||||
if preparedWhereClause != nil {
|
||||
if !preparedWhereClause.IsEmpty() {
|
||||
sb.AddWhereClause(preparedWhereClause.WhereClause)
|
||||
}
|
||||
|
||||
|
||||
@@ -267,7 +267,7 @@ func (b *traceOperatorCTEBuilder) buildQueryCTE(ctx context.Context, queryName s
|
||||
b.stmtBuilder.logger.ErrorContext(ctx, "Failed to prepare where clause", errors.Attr(err), slog.String("filter", query.Filter.Expression))
|
||||
return "", err
|
||||
}
|
||||
if filterWhereClause != nil {
|
||||
if !filterWhereClause.IsEmpty() {
|
||||
b.stmtBuilder.logger.DebugContext(ctx, "Adding where clause", slog.Any("where_clause", filterWhereClause.WhereClause))
|
||||
sb.AddWhereClause(filterWhereClause.WhereClause)
|
||||
} else {
|
||||
|
||||
@@ -15,6 +15,7 @@ from fixtures.querier import (
|
||||
build_group_by_field,
|
||||
build_logs_aggregation,
|
||||
build_order_by,
|
||||
build_raw_query,
|
||||
build_scalar_query,
|
||||
find_named_result,
|
||||
index_series_by_label,
|
||||
@@ -2625,3 +2626,43 @@ def test_logs_aggregation_filter_by_trace_id(
|
||||
orphan_count, orphan_warnings = _count(narrow_start_ms, now_ms, orphan_trace_id)
|
||||
assert orphan_count == 1, f"Expected count=1 for orphan trace_id aggregation, got {orphan_count} — query may have been incorrectly short-circuited"
|
||||
assert not any(outside_range_msg in m for m in orphan_warnings), f"Did not expect outside-range warning for orphan trace_id, got {orphan_warnings}"
|
||||
|
||||
|
||||
def test_logs_list_ambigous_warnings(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
insert_logs(
|
||||
[
|
||||
Logs(
|
||||
timestamp=datetime.now(tz=UTC) - timedelta(seconds=1),
|
||||
resources={
|
||||
"service.name": "java",
|
||||
},
|
||||
attributes={
|
||||
"service.name": "java",
|
||||
},
|
||||
body="This is a log message, coming from a java application",
|
||||
severity_text="DEBUG",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD),
|
||||
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=1)).timestamp() * 1000),
|
||||
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
|
||||
request_type="raw",
|
||||
queries=[build_raw_query(name="A", signal="logs", filter_expression='service.name = "java"')],
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response.json()["status"] == "success"
|
||||
warning = response.json()["data"].get("warning", None)
|
||||
assert warning is not None
|
||||
assert warning["message"] == "Encountered warnings"
|
||||
assert len(warning.get("warnings")) > 0
|
||||
assert any(["ambiguous" in w["message"] for w in warning.get("warnings")])
|
||||
|
||||
Reference in New Issue
Block a user