mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-25 19:30:33 +01:00
Compare commits
20 Commits
emdash/tra
...
refactor/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f02a515c68 | ||
|
|
ffa33f71e1 | ||
|
|
51820ee87c | ||
|
|
36f29ccced | ||
|
|
c1e81a57a3 | ||
|
|
c30994f966 | ||
|
|
9aef69a186 | ||
|
|
66bc4b4398 | ||
|
|
04c58a1572 | ||
|
|
9bbdd00858 | ||
|
|
316e9c7361 | ||
|
|
634166860b | ||
|
|
af8f2fa95a | ||
|
|
f81fd78ff6 | ||
|
|
b589a7b2e9 | ||
|
|
716dbc7847 | ||
|
|
3a92c7577f | ||
|
|
ba043a5741 | ||
|
|
6d2b99eb8d | ||
|
|
3765ca3d42 |
@@ -129,8 +129,6 @@ components:
|
||||
type: string
|
||||
schedule:
|
||||
$ref: '#/components/schemas/AlertmanagertypesSchedule'
|
||||
scope:
|
||||
type: string
|
||||
status:
|
||||
$ref: '#/components/schemas/AlertmanagertypesMaintenanceStatus'
|
||||
updatedAt:
|
||||
@@ -274,8 +272,6 @@ components:
|
||||
type: string
|
||||
schedule:
|
||||
$ref: '#/components/schemas/AlertmanagertypesSchedule'
|
||||
scope:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- schedule
|
||||
@@ -18948,77 +18944,6 @@ paths:
|
||||
summary: Get waterfall view for a trace
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v4/traces/{traceID}/waterfall:
|
||||
post:
|
||||
deprecated: false
|
||||
description: 'Two-step fetch: minimal fields for all spans to build the tree,
|
||||
full fields only for the visible window. Aggregations are not included in
|
||||
the response.'
|
||||
operationId: GetWaterfallV4
|
||||
parameters:
|
||||
- in: path
|
||||
name: traceID
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SpantypesPostableWaterfall'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/SpantypesGettableWaterfallTrace'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: Get waterfall view for a trace (OOM-safe)
|
||||
tags:
|
||||
- tracedetail
|
||||
/api/v5/query_range:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -89,15 +89,6 @@ func (ah *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
useDashboardV2 := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureUseDashboardV2, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureUseDashboardV2.String()),
|
||||
Active: useDashboardV2,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
@@ -94,19 +94,6 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="application/json" id="signoz-boot-settings">
|
||||
[[.Settings]]
|
||||
</script>
|
||||
<script>
|
||||
try {
|
||||
var _el = document.getElementById('signoz-boot-settings');
|
||||
window.signozBootData = {
|
||||
settings: _el ? JSON.parse(_el.textContent) : null,
|
||||
};
|
||||
} catch (e) {
|
||||
window.signozBootData = { settings: null };
|
||||
}
|
||||
</script>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -148,10 +135,7 @@
|
||||
</script>
|
||||
<script>
|
||||
var APPCUES_APP_ID = '<%- APPCUES_APP_ID %>';
|
||||
var appcuesSettings =
|
||||
((window.signozBootData || {}).settings || {}).appcues || {};
|
||||
var appcuesEnabled = appcuesSettings.enabled !== false;
|
||||
if (APPCUES_APP_ID && appcuesEnabled) {
|
||||
if (APPCUES_APP_ID) {
|
||||
(function (d, t) {
|
||||
var a = d.createElement(t);
|
||||
a.async = 1;
|
||||
|
||||
@@ -17,6 +17,8 @@ const BANNED_COMPONENTS = {
|
||||
Typography:
|
||||
'Use @signozhq/ui/typography Typography instead of antd Typography.',
|
||||
Switch: 'Use @signozhq/ui/switch Switch instead of antd Switch.',
|
||||
Dropdown:
|
||||
'Use @signozhq/ui DropdownMenuSimple (or the composable DropdownMenu primitives) from @signozhq/ui/dropdown-menu instead of antd Dropdown.',
|
||||
Badge: 'Use @signozhq/ui/badge instead of antd Badge.',
|
||||
Progress: 'Use @signozhq/ui/progress instead of antd Progress.',
|
||||
};
|
||||
|
||||
@@ -35,7 +35,6 @@ import { PreferenceContextProvider } from 'providers/preferences/context/Prefere
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
import { extractDomain } from 'utils/app';
|
||||
import { bootSettings } from 'utils/bootData';
|
||||
|
||||
import { Home } from './pageComponents';
|
||||
import PrivateRoute from './Private';
|
||||
@@ -333,7 +332,7 @@ function App(): JSX.Element {
|
||||
|
||||
useEffect(() => {
|
||||
if (isCloudUser || isEnterpriseSelfHostedUser) {
|
||||
if (bootSettings.posthog.enabled && process.env.POSTHOG_KEY) {
|
||||
if (process.env.POSTHOG_KEY) {
|
||||
posthog.init(process.env.POSTHOG_KEY, {
|
||||
api_host: 'https://us.i.posthog.com',
|
||||
person_profiles: 'identified_only', // or 'always' to create profiles for anonymous users as well
|
||||
|
||||
@@ -225,10 +225,6 @@ export interface AlertmanagertypesPlannedMaintenanceDTO {
|
||||
*/
|
||||
name: string;
|
||||
schedule: AlertmanagertypesScheduleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
scope?: string;
|
||||
status: AlertmanagertypesMaintenanceStatusDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -1718,10 +1714,6 @@ export interface AlertmanagertypesPostablePlannedMaintenanceDTO {
|
||||
*/
|
||||
name: string;
|
||||
schedule: AlertmanagertypesScheduleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesPostableRoutePolicyDTO {
|
||||
@@ -9232,17 +9224,6 @@ export type GetWaterfall200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetWaterfallV4PathParameters = {
|
||||
traceID: string;
|
||||
};
|
||||
export type GetWaterfallV4200 = {
|
||||
data: SpantypesGettableWaterfallTraceDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type QueryRangeV5200 = {
|
||||
data: Querybuildertypesv5QueryRangeResponseDTO;
|
||||
/**
|
||||
|
||||
@@ -14,8 +14,6 @@ import type {
|
||||
import type {
|
||||
GetWaterfall200,
|
||||
GetWaterfallPathParameters,
|
||||
GetWaterfallV4200,
|
||||
GetWaterfallV4PathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
SpantypesPostableWaterfallDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -122,102 +120,3 @@ export const useGetWaterfall = <
|
||||
> => {
|
||||
return useMutation(getGetWaterfallMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Two-step fetch: minimal fields for all spans to build the tree, full fields only for the visible window. Aggregations are not included in the response.
|
||||
* @summary Get waterfall view for a trace (OOM-safe)
|
||||
*/
|
||||
export const getWaterfallV4 = (
|
||||
{ traceID }: GetWaterfallV4PathParameters,
|
||||
spantypesPostableWaterfallDTO?: BodyType<SpantypesPostableWaterfallDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetWaterfallV4200>({
|
||||
url: `/api/v4/traces/${traceID}/waterfall`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: spantypesPostableWaterfallDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetWaterfallV4MutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['getWaterfallV4'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return getWaterfallV4(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type GetWaterfallV4MutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>
|
||||
>;
|
||||
export type GetWaterfallV4MutationBody =
|
||||
| BodyType<SpantypesPostableWaterfallDTO>
|
||||
| undefined;
|
||||
export type GetWaterfallV4MutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get waterfall view for a trace (OOM-safe)
|
||||
*/
|
||||
export const useGetWaterfallV4 = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof getWaterfallV4>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: GetWaterfallV4PathParameters;
|
||||
data?: BodyType<SpantypesPostableWaterfallDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getGetWaterfallV4MutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
.dropdown-button {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
import { Button, Dropdown, MenuProps } from 'antd';
|
||||
|
||||
import './DropDown.styles.scss';
|
||||
|
||||
function DropDown({
|
||||
element,
|
||||
onDropDownItemClick,
|
||||
}: {
|
||||
element: JSX.Element[];
|
||||
onDropDownItemClick?: MenuProps['onClick'];
|
||||
}): JSX.Element {
|
||||
const items: MenuProps['items'] = element.map(
|
||||
(e: JSX.Element, index: number) => ({
|
||||
label: e,
|
||||
key: index,
|
||||
}),
|
||||
);
|
||||
|
||||
const [isDdOpen, setDdOpen] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items,
|
||||
onMouseEnter: (): void => setDdOpen(true),
|
||||
onMouseLeave: (): void => setDdOpen(false),
|
||||
onClick: (item): void => onDropDownItemClick?.(item),
|
||||
}}
|
||||
open={isDdOpen}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
className={`dropdown-button`}
|
||||
onClick={(e): void => {
|
||||
e.preventDefault();
|
||||
setDdOpen(true);
|
||||
}}
|
||||
>
|
||||
<Ellipsis className="dropdown-icon" size={16} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
DropDown.defaultProps = {
|
||||
onDropDownItemClick: (): void => {},
|
||||
};
|
||||
|
||||
export default DropDown;
|
||||
@@ -1,15 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Dropdown,
|
||||
MenuProps,
|
||||
Popover,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
} from 'antd';
|
||||
import { Button, Col, Popover, Row, Select, Space } from 'antd';
|
||||
import { DropdownMenuSimple, type MenuProps } from '@signozhq/ui/dropdown-menu';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import axios from 'axios';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
@@ -241,9 +233,9 @@ function ExplorerCard({
|
||||
</Popover>
|
||||
<Share2 onClick={onCopyUrlHandler} size="md" />
|
||||
{viewKey && (
|
||||
<Dropdown trigger={['click']} menu={moreOptionMenu}>
|
||||
<Ellipsis size="md" />
|
||||
</Dropdown>
|
||||
<DropdownMenuSimple menu={moreOptionMenu}>
|
||||
<Button type="text" size="small" icon={<Ellipsis size="md" />} />
|
||||
</DropdownMenuSimple>
|
||||
)}
|
||||
</Space>
|
||||
</OffSetCol>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Dropdown } from 'antd';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import cx from 'classnames';
|
||||
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
@@ -195,7 +195,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
)}
|
||||
|
||||
{isMultiQueryAllowed && (
|
||||
<Dropdown
|
||||
<DropdownMenuSimple
|
||||
className="query-actions-dropdown"
|
||||
menu={{
|
||||
items: [
|
||||
@@ -217,10 +217,10 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
: []),
|
||||
],
|
||||
}}
|
||||
placement="bottomRight"
|
||||
align="end"
|
||||
>
|
||||
<Ellipsis size={16} />
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,14 @@ import type {
|
||||
TableColumnsType as ColumnsType,
|
||||
TableColumnType as ColumnType,
|
||||
} from 'antd';
|
||||
import { Button, Dropdown, Flex, MenuProps } from 'antd';
|
||||
import { Button, Flex } from 'antd';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { SlidersHorizontal } from '@signozhq/icons';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import ResizeTable from './ResizeTable';
|
||||
import { DynamicColumnTableProps } from './types';
|
||||
@@ -84,8 +84,9 @@ function DynamicColumnTable({
|
||||
);
|
||||
};
|
||||
|
||||
const items: MenuProps['items'] =
|
||||
const items: MenuItem[] =
|
||||
dynamicColumns?.map((column, index) => ({
|
||||
key: String(index),
|
||||
label: (
|
||||
<div
|
||||
className="dynamicColumnsTable-items"
|
||||
@@ -99,8 +100,6 @@ function DynamicColumnTable({
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
key: index,
|
||||
type: 'checkbox',
|
||||
})) || [];
|
||||
|
||||
// Get current page from URL or default to 1
|
||||
@@ -129,18 +128,14 @@ function DynamicColumnTable({
|
||||
<Flex justify="flex-end" align="center" gap={8}>
|
||||
{facingIssueBtn && <LaunchChatSupport {...facingIssueBtn} />}
|
||||
{dynamicColumns && (
|
||||
<Dropdown
|
||||
getPopupContainer={popupContainer}
|
||||
menu={{ items }}
|
||||
trigger={['click']}
|
||||
>
|
||||
<DropdownMenuSimple menu={{ items }}>
|
||||
<Button
|
||||
className="dynamicColumnTable-button filter-btn"
|
||||
size="middle"
|
||||
icon={<SlidersHorizontal size={14} />}
|
||||
data-testid="additional-filters-button"
|
||||
/>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Dispatch, SetStateAction, useCallback, useMemo } from 'react';
|
||||
import { ChevronDown, Globe } from '@signozhq/icons';
|
||||
import { Button, Dropdown } from 'antd';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import { Button } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import TimeItems, {
|
||||
timePreferance,
|
||||
@@ -27,20 +28,17 @@ function TimePreference({
|
||||
|
||||
const menu = useMemo(
|
||||
() => ({
|
||||
items: menuItems,
|
||||
onClick: timeMenuItemOnChangeHandler,
|
||||
items: menuItems.map((item) => ({
|
||||
...item,
|
||||
onClick: timeMenuItemOnChangeHandler,
|
||||
})),
|
||||
}),
|
||||
[timeMenuItemOnChangeHandler],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={menu}
|
||||
rootClassName="time-selection-menu"
|
||||
className="time-selection-target"
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button>
|
||||
<DropdownMenuSimple menu={menu} className="time-selection-menu">
|
||||
<Button className="time-selection-target">
|
||||
<div className="button-selected-text">
|
||||
<Globe size={14} />
|
||||
<Typography.Text className="selected-value">
|
||||
@@ -49,7 +47,7 @@ function TimePreference({
|
||||
</div>
|
||||
<ChevronDown size="md" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,5 +11,4 @@ export enum FeatureKeys {
|
||||
DOT_METRICS_ENABLED = 'dot_metrics_enabled',
|
||||
USE_JSON_BODY = 'use_json_body',
|
||||
USE_FINE_GRAINED_AUTHZ = 'use_fine_grained_authz',
|
||||
USE_DASHBOARD_V2 = 'use_dashboard_v2',
|
||||
}
|
||||
|
||||
@@ -11,8 +11,13 @@ import {
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Callout } from '@signozhq/ui/callout';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@signozhq/ui/dropdown-menu';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { Dropdown, Skeleton } from 'antd';
|
||||
import { Skeleton } from 'antd';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
ZeustypesHostDTO,
|
||||
@@ -200,10 +205,15 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
!workspaceName ? 'workspace-name-hidden' : ''
|
||||
}`}
|
||||
>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
disabled={isFetchingHosts}
|
||||
dropdownRender={(): JSX.Element => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="link" color="none" disabled={isFetchingHosts}>
|
||||
<Link2 size={12} />
|
||||
<span>{stripProtocol(activeHost?.url ?? '')}</span>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<div className="workspace-url-dropdown">
|
||||
<span className="workspace-url-dropdown-header">
|
||||
All Workspace URLs
|
||||
@@ -236,14 +246,8 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Button variant="link" color="none">
|
||||
<Link2 size={12} />
|
||||
<span>{stripProtocol(activeHost?.url ?? '')}</span>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<span className="custom-domain-card-meta-timezone">
|
||||
<Clock size={11} />
|
||||
{timezone.offset}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GetHosts200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
@@ -142,12 +143,13 @@ describe('CustomDomainSettings', () => {
|
||||
});
|
||||
|
||||
it('shows all workspace URLs as links in the dropdown', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
|
||||
// Open the URL dropdown
|
||||
fireEvent.click(
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /custom-host\.test\.cloud/i }),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { CloudDownload } from '@signozhq/icons';
|
||||
import { Button, Dropdown, MenuProps, Flex } from 'antd';
|
||||
import { DropdownMenuSimple, type MenuProps } from '@signozhq/ui/dropdown-menu';
|
||||
import { Button, Flex } from 'antd';
|
||||
import { unparse } from 'papaparse';
|
||||
|
||||
import { DownloadProps } from './Download.types';
|
||||
@@ -67,7 +68,7 @@ function Download({ data, isLoading, fileName }: DownloadProps): JSX.Element {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown menu={menu} trigger={['click']}>
|
||||
<DropdownMenuSimple menu={menu}>
|
||||
<Button
|
||||
className="download-button"
|
||||
loading={isLoading || isDownloading}
|
||||
@@ -79,7 +80,7 @@ function Download({ data, isLoading, fileName }: DownloadProps): JSX.Element {
|
||||
Download
|
||||
</Flex>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
Col,
|
||||
Dropdown as DropDownComponent,
|
||||
Input as InputComponent,
|
||||
} from 'antd';
|
||||
import { Col, Input as InputComponent } from 'antd';
|
||||
import { Typography as TypographyComponent } from '@signozhq/ui/typography';
|
||||
import styled from 'styled-components';
|
||||
|
||||
@@ -34,16 +30,6 @@ export const ButtonContainer = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
export const Dropdown = styled(DropDownComponent)`
|
||||
&&& {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
max-width: 150px;
|
||||
min-width: 150px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TextContainer = styled.div`
|
||||
&&& {
|
||||
min-width: 100px;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
@@ -176,6 +177,7 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
|
||||
describe('WidgetGraphComponent', () => {
|
||||
it('should show correct menu items when hovering over more options while loading', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const { getByTestId, findByRole, getByText, container } = render(
|
||||
<MockQueryClientProvider>
|
||||
<ErrorModalProvider>
|
||||
@@ -208,7 +210,7 @@ describe('WidgetGraphComponent', () => {
|
||||
expect(skeleton).toBeInTheDocument();
|
||||
|
||||
const moreOptionsButton = getByTestId('widget-header-options');
|
||||
fireEvent.mouseEnter(moreOptionsButton);
|
||||
await user.click(moreOptionsButton);
|
||||
|
||||
const menu = await findByRole('menu');
|
||||
expect(menu).toBeInTheDocument();
|
||||
|
||||
@@ -54,6 +54,17 @@
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
// currently the width of the dropdown menu is set to 100% of the parent container,
|
||||
// which is not desired. This is a workaround to unset that width and allow the dropdown menu to size based on its content.
|
||||
// This is necessary because the dropdown menu can contain items with varying widths, and setting it to 100% can cause layout issues and make the menu look unbalanced.
|
||||
// we should idealy fix this in the dropdown menu component itself, but for now this is a quick fix to ensure the dropdown menu looks correct in the widget header.
|
||||
|
||||
[data-radix-popper-content-wrapper]
|
||||
[data-slot='dropdown-menu-content'].widget-header-dropdown
|
||||
[data-slot='dropdown-menu-item'] {
|
||||
width: unset !important;
|
||||
}
|
||||
|
||||
.widget-api-actions {
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -467,6 +467,7 @@ describe('WidgetHeader', () => {
|
||||
|
||||
describe('Create Alerts Menu Item', () => {
|
||||
it('renders Create Alerts menu item with external link icon when included in headerMenuList', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<WidgetHeader
|
||||
title={TEST_WIDGET_TITLE}
|
||||
@@ -483,7 +484,7 @@ describe('WidgetHeader', () => {
|
||||
|
||||
const moreOptionsIcon = await screen.findByTestId(WIDGET_HEADER_OPTIONS_ID);
|
||||
expect(moreOptionsIcon).toBeInTheDocument();
|
||||
await userEvent.hover(moreOptionsIcon);
|
||||
await user.click(moreOptionsIcon);
|
||||
|
||||
await screen.findByText(CREATE_ALERTS_TEXT);
|
||||
|
||||
@@ -494,6 +495,7 @@ describe('WidgetHeader', () => {
|
||||
});
|
||||
|
||||
it('Create Alerts menu item is enabled and clickable', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockCreateAlertsHandler = jest.fn();
|
||||
const useCreateAlerts = jest.requireMock(
|
||||
'hooks/queryBuilder/useCreateAlerts',
|
||||
@@ -517,12 +519,12 @@ describe('WidgetHeader', () => {
|
||||
expect(useCreateAlerts).toHaveBeenCalledWith(mockWidget, 'dashboardView');
|
||||
|
||||
const moreOptionsIcon = await screen.findByTestId(WIDGET_HEADER_OPTIONS_ID);
|
||||
await userEvent.hover(moreOptionsIcon);
|
||||
await user.click(moreOptionsIcon);
|
||||
|
||||
const createAlertsMenuItem = await screen.findByText(CREATE_ALERTS_TEXT);
|
||||
|
||||
// Verify the menu item is clickable by actually clicking it
|
||||
await userEvent.click(createAlertsMenuItem);
|
||||
await user.click(createAlertsMenuItem);
|
||||
expect(mockCreateAlertsHandler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Dropdown, Input, MenuProps, Tooltip } from 'antd';
|
||||
import { Button, Input, Tooltip } from 'antd';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import ErrorPopover from 'components/ErrorPopover/ErrorPopover';
|
||||
@@ -128,7 +129,7 @@ function WidgetHeader({
|
||||
],
|
||||
);
|
||||
|
||||
const onMenuItemSelectHandler: MenuProps['onClick'] = useCallback(
|
||||
const onMenuItemSelectHandler = useCallback(
|
||||
({ key }: { key: string }): void => {
|
||||
if (isTWidgetOptions(key)) {
|
||||
const functionToCall = keyMethodMapping[key];
|
||||
@@ -188,18 +189,8 @@ function WidgetHeader({
|
||||
{
|
||||
key: MenuItemKeys.CreateAlerts,
|
||||
icon: <Bell size="md" />,
|
||||
label: (
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
{MENUITEM_KEYS_VS_LABELS[MenuItemKeys.CreateAlerts]}
|
||||
<SquareArrowOutUpRight size={10} />
|
||||
</span>
|
||||
),
|
||||
label: MENUITEM_KEYS_VS_LABELS[MenuItemKeys.CreateAlerts],
|
||||
rightIcon: <SquareArrowOutUpRight size="lg" />,
|
||||
isVisible: headerMenuList?.includes(MenuItemKeys.CreateAlerts) || false,
|
||||
disabled: false,
|
||||
},
|
||||
@@ -221,8 +212,10 @@ function WidgetHeader({
|
||||
|
||||
const menu = useMemo(
|
||||
() => ({
|
||||
items: updatedMenuList,
|
||||
onClick: onMenuItemSelectHandler,
|
||||
items: updatedMenuList.map((item) => ({
|
||||
...item,
|
||||
onClick: onMenuItemSelectHandler,
|
||||
})),
|
||||
}),
|
||||
[updatedMenuList, onMenuItemSelectHandler],
|
||||
);
|
||||
@@ -321,7 +314,12 @@ function WidgetHeader({
|
||||
/>
|
||||
)}
|
||||
{menu && Array.isArray(menu.items) && menu.items.length > 0 && (
|
||||
<Dropdown menu={menu} trigger={['hover']} placement="bottomRight">
|
||||
<DropdownMenuSimple
|
||||
menu={menu}
|
||||
side="bottom"
|
||||
align="end"
|
||||
className="widget-header-dropdown"
|
||||
>
|
||||
<Button
|
||||
data-testid="widget-header-options"
|
||||
className={`widget-header-more-options ${
|
||||
@@ -329,7 +327,7 @@ function WidgetHeader({
|
||||
}`}
|
||||
icon={<EllipsisVertical size="md" />}
|
||||
/>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface MenuItem {
|
||||
key: MenuItemKeys;
|
||||
icon: ReactNode;
|
||||
label: ReactNode;
|
||||
rightIcon?: ReactNode;
|
||||
isVisible: boolean;
|
||||
disabled: boolean;
|
||||
danger?: boolean;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { MenuItemType } from 'antd/es/menu/hooks/useItems';
|
||||
import type { MenuItem as DropdownMenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import { MenuItemKeys } from './contants';
|
||||
import { MenuItem } from './types';
|
||||
|
||||
export const generateMenuList = (actions: MenuItem[]): MenuItemType[] =>
|
||||
export const generateMenuList = (actions: MenuItem[]): DropdownMenuItem[] =>
|
||||
actions
|
||||
.filter((action: MenuItem) => action.isVisible)
|
||||
.map(({ key, icon: Icon, label, disabled, ...rest }) => ({
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Button, Flex, Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Ellipsis, Plus } from '@signozhq/icons';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import type { ColumnsType } from 'antd/es/table/interface';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
@@ -15,7 +16,6 @@ import type {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { AxiosError } from 'axios';
|
||||
import DropDown from 'components/DropDown/DropDown';
|
||||
import {
|
||||
DynamicColumnsKey,
|
||||
TableDataSource,
|
||||
@@ -323,55 +323,67 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
|
||||
dataIndex: 'id',
|
||||
key: 'action',
|
||||
width: 10,
|
||||
render: (id: RuletypesRuleDTO['id'], record): JSX.Element => (
|
||||
<div data-testid="alert-actions">
|
||||
<DropDown
|
||||
onDropDownItemClick={(item): void =>
|
||||
alertActionLogEvent(item.key, record)
|
||||
render: (id: RuletypesRuleDTO['id'], record): JSX.Element => {
|
||||
const actionItems = [
|
||||
<ToggleAlertState
|
||||
key="1"
|
||||
disabled={record.disabled ?? false}
|
||||
setData={setData}
|
||||
id={id ?? ''}
|
||||
/>,
|
||||
<ColumnButton
|
||||
key="2"
|
||||
onClick={(e: React.MouseEvent): void =>
|
||||
onEditHandler(record, { newTab: isModifierKeyPressed(e) })
|
||||
}
|
||||
element={[
|
||||
<ToggleAlertState
|
||||
key="1"
|
||||
disabled={record.disabled ?? false}
|
||||
setData={setData}
|
||||
id={id ?? ''}
|
||||
/>,
|
||||
<ColumnButton
|
||||
key="2"
|
||||
onClick={(e: React.MouseEvent): void =>
|
||||
onEditHandler(record, { newTab: isModifierKeyPressed(e) })
|
||||
}
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3-new-tab"
|
||||
onClick={(): void => onEditHandler(record, { newTab: true })}
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit in New Tab
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3-clone"
|
||||
onClick={onCloneHandler(record)}
|
||||
type="link"
|
||||
loading={cloneLoader}
|
||||
>
|
||||
Clone
|
||||
</ColumnButton>,
|
||||
<DeleteAlert
|
||||
key="4"
|
||||
notifications={notificationsApi}
|
||||
setData={setData}
|
||||
id={id ?? ''}
|
||||
/>,
|
||||
];
|
||||
return (
|
||||
<div data-testid="alert-actions">
|
||||
<DropdownMenuSimple
|
||||
menu={{
|
||||
items: actionItems.map((element, index) => ({
|
||||
key: String(index),
|
||||
label: element,
|
||||
onClick: ({ key }): void => alertActionLogEvent(key, record),
|
||||
})),
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3"
|
||||
onClick={(): void => onEditHandler(record, { newTab: true })}
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit in New Tab
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3"
|
||||
onClick={onCloneHandler(record)}
|
||||
type="link"
|
||||
loading={cloneLoader}
|
||||
>
|
||||
Clone
|
||||
</ColumnButton>,
|
||||
<DeleteAlert
|
||||
key="4"
|
||||
notifications={notificationsApi}
|
||||
setData={setData}
|
||||
id={id ?? ''}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
style={{ color: 'var(--l1-foreground)' }}
|
||||
icon={<Ellipsis size={16} />}
|
||||
/>
|
||||
</DropdownMenuSimple>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,12 +12,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Flex,
|
||||
Input,
|
||||
MenuProps,
|
||||
Modal,
|
||||
Popover,
|
||||
Skeleton,
|
||||
@@ -553,7 +552,7 @@ function DashboardsList(): JSX.Element {
|
||||
];
|
||||
|
||||
const getCreateDashboardItems = useMemo(() => {
|
||||
const menuItems: MenuProps['items'] = [
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
label: (
|
||||
<div
|
||||
@@ -711,11 +710,11 @@ function DashboardsList(): JSX.Element {
|
||||
|
||||
{createNewDashboard && (
|
||||
<section className="actions">
|
||||
<Dropdown
|
||||
overlayClassName="new-dashboard-menu"
|
||||
<DropdownMenuSimple
|
||||
className="new-dashboard-menu"
|
||||
menu={{ items: getCreateDashboardItems }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
side="bottom"
|
||||
align="end"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
@@ -727,7 +726,7 @@ function DashboardsList(): JSX.Element {
|
||||
>
|
||||
New Dashboard
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
<Button
|
||||
type="text"
|
||||
className="learn-more"
|
||||
@@ -756,11 +755,11 @@ function DashboardsList(): JSX.Element {
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
{createNewDashboard && (
|
||||
<Dropdown
|
||||
overlayClassName="new-dashboard-menu"
|
||||
<DropdownMenuSimple
|
||||
className="new-dashboard-menu"
|
||||
menu={{ items: getCreateDashboardItems }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
side="bottom"
|
||||
align="end"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -773,7 +772,7 @@ function DashboardsList(): JSX.Element {
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,13 @@ import { useCallback } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { orange } from '@ant-design/colors';
|
||||
import { Settings } from '@signozhq/icons';
|
||||
import { Dropdown, MenuProps } from 'antd';
|
||||
import {
|
||||
type BaseMenuItem,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@signozhq/ui/dropdown-menu';
|
||||
import {
|
||||
negateOperator,
|
||||
OPERATORS,
|
||||
@@ -135,41 +141,38 @@ function BodyTitleRenderer({
|
||||
viewName,
|
||||
]);
|
||||
|
||||
const onClickHandler: MenuProps['onClick'] = (props): void => {
|
||||
const onClickHandler = (key: string): void => {
|
||||
const mapper = {
|
||||
[DROPDOWN_KEY.FILTER_IN]: filterHandler(true),
|
||||
[DROPDOWN_KEY.FILTER_OUT]: filterHandler(false),
|
||||
[DROPDOWN_KEY.GROUP_BY]: groupByHandler,
|
||||
};
|
||||
|
||||
const handler = mapper[props.key];
|
||||
const handler = mapper[key];
|
||||
|
||||
if (handler) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
const menu: MenuProps = {
|
||||
items: [
|
||||
{
|
||||
key: DROPDOWN_KEY.FILTER_IN,
|
||||
label: `Filter for ${value}`,
|
||||
},
|
||||
{
|
||||
key: DROPDOWN_KEY.FILTER_OUT,
|
||||
label: `Filter out ${value}`,
|
||||
},
|
||||
...(isGroupBySupported
|
||||
? [
|
||||
{
|
||||
key: DROPDOWN_KEY.GROUP_BY,
|
||||
label: `Group by ${nodeKey}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
onClick: onClickHandler,
|
||||
};
|
||||
const menuItems: BaseMenuItem[] = [
|
||||
{
|
||||
key: DROPDOWN_KEY.FILTER_IN,
|
||||
label: `Filter for ${value}`,
|
||||
},
|
||||
{
|
||||
key: DROPDOWN_KEY.FILTER_OUT,
|
||||
label: `Filter out ${value}`,
|
||||
},
|
||||
...(isGroupBySupported
|
||||
? [
|
||||
{
|
||||
key: DROPDOWN_KEY.GROUP_BY,
|
||||
label: `Group by ${nodeKey}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(e: React.MouseEvent): void => {
|
||||
@@ -218,15 +221,23 @@ function BodyTitleRenderer({
|
||||
}}
|
||||
onMouseDown={(e): void => e.preventDefault()}
|
||||
>
|
||||
<Dropdown
|
||||
menu={menu}
|
||||
trigger={['click']}
|
||||
dropdownRender={(originNode): React.ReactNode => (
|
||||
<div data-log-detail-ignore="true">{originNode}</div>
|
||||
)}
|
||||
>
|
||||
<Settings style={{ marginRight: 8 }} className="hover-reveal" />
|
||||
</Dropdown>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Settings style={{ marginRight: 8 }} className="hover-reveal" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<div data-log-detail-ignore="true">
|
||||
{menuItems.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.key}
|
||||
onSelect={(): void => onClickHandler(item.key as string)}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</span>
|
||||
)}
|
||||
{title.toString()}{' '}
|
||||
|
||||
@@ -2,9 +2,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Check, ChevronDown, Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { Dropdown } from 'antd';
|
||||
import { useListUsers } from 'api/generated/services/users';
|
||||
import EditMemberDrawer from 'components/EditMemberDrawer/EditMemberDrawer';
|
||||
import InviteMembersModal from 'components/InviteMembersModal/InviteMembersModal';
|
||||
@@ -95,7 +94,7 @@ function MembersSettings(): JSX.Element {
|
||||
).length;
|
||||
const totalCount = allMembers.length;
|
||||
|
||||
const filterMenuItems: MenuProps['items'] = [
|
||||
const filterMenuItems: MenuItem[] = [
|
||||
{
|
||||
key: FilterMode.All,
|
||||
label: (
|
||||
@@ -171,10 +170,9 @@ function MembersSettings(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<div className="members-settings__controls">
|
||||
<Dropdown
|
||||
<DropdownMenuSimple
|
||||
menu={{ items: filterMenuItems }}
|
||||
trigger={['click']}
|
||||
overlayClassName="members-filter-dropdown"
|
||||
className="members-filter-dropdown"
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
@@ -184,7 +182,7 @@ function MembersSettings(): JSX.Element {
|
||||
<span>{filterLabel}</span>
|
||||
<ChevronDown size={12} className="members-filter-trigger__chevron" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
|
||||
<div className="members-settings__search">
|
||||
<Input
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TypesUserDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
@@ -76,14 +77,15 @@ describe('MembersSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('filters to pending invites via the filter dropdown', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<MembersSettings />);
|
||||
|
||||
await screen.findByText('Alice Smith');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /all members/i }));
|
||||
await user.click(screen.getByRole('button', { name: /all members/i }));
|
||||
|
||||
const pendingOption = await screen.findByText(/pending invites/i);
|
||||
fireEvent.click(pendingOption);
|
||||
await user.click(pendingOption);
|
||||
|
||||
await screen.findByText('charlie@signoz.io');
|
||||
expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { generatePath } from 'react-router-dom';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Dropdown, Skeleton } from 'antd';
|
||||
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
|
||||
import { Skeleton } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import {
|
||||
useGetMetricAlerts,
|
||||
@@ -126,12 +127,11 @@ function DashboardsAndAlertsPopover({
|
||||
return (
|
||||
<div className="dashboards-and-alerts-popover-container">
|
||||
{dashboardsPopoverContent && (
|
||||
<Dropdown
|
||||
<DropdownMenuSimple
|
||||
menu={{
|
||||
items: dashboardsPopoverContent,
|
||||
}}
|
||||
placement="bottomLeft"
|
||||
trigger={['click']}
|
||||
align="start"
|
||||
>
|
||||
<div
|
||||
className="dashboards-and-alerts-popover dashboards-popover"
|
||||
@@ -142,15 +142,14 @@ function DashboardsAndAlertsPopover({
|
||||
{pluralize(dashboards.length, 'dashboard')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
)}
|
||||
{alertsPopoverContent && (
|
||||
<Dropdown
|
||||
<DropdownMenuSimple
|
||||
menu={{
|
||||
items: alertsPopoverContent,
|
||||
}}
|
||||
placement="bottomLeft"
|
||||
trigger={['click']}
|
||||
align="start"
|
||||
>
|
||||
<div
|
||||
className="dashboards-and-alerts-popover alerts-popover"
|
||||
@@ -161,7 +160,7 @@ function DashboardsAndAlertsPopover({
|
||||
{pluralize(alerts.length, 'alert rule')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -104,38 +104,33 @@
|
||||
height: 200px;
|
||||
background-color: var(--l1-border);
|
||||
overflow: hidden !important;
|
||||
.ant-dropdown-menu {
|
||||
padding: 0;
|
||||
|
||||
.ant-dropdown-menu-item {
|
||||
padding: 4px;
|
||||
.ant-checkbox-wrapper {
|
||||
padding: 2px 8px !important;
|
||||
}
|
||||
padding: 4px;
|
||||
.ant-checkbox-wrapper {
|
||||
padding: 2px 8px !important;
|
||||
}
|
||||
|
||||
.attribute-columns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 160px;
|
||||
overflow: scroll;
|
||||
}
|
||||
.attribute-columns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 160px;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.attribute-columns::-webkit-scrollbar {
|
||||
width: 3px; /* Width of the scrollbar */
|
||||
}
|
||||
.attribute-columns::-webkit-scrollbar {
|
||||
width: 3px; /* Width of the scrollbar */
|
||||
}
|
||||
|
||||
.attribute-columns::-webkit-scrollbar-track {
|
||||
background: var(--l1-border); /* Color of the track */
|
||||
}
|
||||
.attribute-columns::-webkit-scrollbar-track {
|
||||
background: var(--l1-border); /* Color of the track */
|
||||
}
|
||||
|
||||
.attribute-columns::-webkit-scrollbar-thumb {
|
||||
background: var(--l2-foreground); /* Color of the thumb */
|
||||
border-radius: 4px; /* Roundness of the thumb */
|
||||
}
|
||||
.attribute-columns::-webkit-scrollbar-thumb {
|
||||
background: var(--l2-foreground); /* Color of the thumb */
|
||||
border-radius: 4px; /* Roundness of the thumb */
|
||||
}
|
||||
|
||||
.attribute-columns::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--l1-border); /* Color of the thumb on hover */
|
||||
}
|
||||
}
|
||||
.attribute-columns::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--l1-border); /* Color of the thumb on hover */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
DropResult,
|
||||
} from 'react-beautiful-dnd';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Divider, Dropdown, Input, MenuProps, Tooltip } from 'antd';
|
||||
import { Button, Divider, Input, Tooltip } from 'antd';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@signozhq/ui/dropdown-menu';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { FieldDataType } from 'api/v5/v5';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
@@ -159,34 +164,12 @@ function ExplorerColumnsRenderer({
|
||||
debouncedSetQuerySearchText(e.target.value);
|
||||
};
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: 'search',
|
||||
label: (
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
className="explorer-columns-search"
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
prefix={<Search size={16} style={{ padding: '6px' }} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'columns',
|
||||
label: (
|
||||
<ExplorerAttributeColumns
|
||||
isLoading={isLoading}
|
||||
data={data}
|
||||
searchText={searchText}
|
||||
isAttributeKeySelected={isAttributeKeySelected}
|
||||
handleCheckboxChange={handleCheckboxChange}
|
||||
dataSource={initialDataSource}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
const handleOpenChange = (nextOpen: boolean): void => {
|
||||
setOpen(nextOpen);
|
||||
if (nextOpen) {
|
||||
setSearchText('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeSelectedLogField = (name: string): void => {
|
||||
if (
|
||||
@@ -238,13 +221,6 @@ function ExplorerColumnsRenderer({
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDropdown = (): void => {
|
||||
setOpen(!open);
|
||||
if (!open) {
|
||||
setSearchText('');
|
||||
}
|
||||
};
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
return (
|
||||
@@ -327,25 +303,38 @@ function ExplorerColumnsRenderer({
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
<div>
|
||||
<Dropdown
|
||||
menu={{ items }}
|
||||
arrow
|
||||
placement="top"
|
||||
open={open}
|
||||
overlayClassName="explorer-columns-dropdown"
|
||||
>
|
||||
<Button
|
||||
className="action-btn"
|
||||
data-testid="add-columns-button"
|
||||
icon={
|
||||
<CirclePlus
|
||||
size={16}
|
||||
color={isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100}
|
||||
/>
|
||||
}
|
||||
onClick={toggleDropdown}
|
||||
/>
|
||||
</Dropdown>
|
||||
<DropdownMenu open={open} onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="action-btn"
|
||||
data-testid="add-columns-button"
|
||||
icon={
|
||||
<CirclePlus
|
||||
size={16}
|
||||
color={isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="top" className="explorer-columns-dropdown">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
className="explorer-columns-search"
|
||||
value={searchText}
|
||||
onChange={handleSearchChange}
|
||||
prefix={<Search size={16} style={{ padding: '6px' }} />}
|
||||
/>
|
||||
<ExplorerAttributeColumns
|
||||
isLoading={isLoading}
|
||||
data={data}
|
||||
searchText={searchText}
|
||||
isAttributeKeySelected={isAttributeKeySelected}
|
||||
handleCheckboxChange={handleCheckboxChange}
|
||||
dataSource={initialDataSource}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -146,6 +146,7 @@ describe('ExplorerColumnsRenderer', () => {
|
||||
});
|
||||
|
||||
it('opens and closes the dropdown', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<Wrapper>
|
||||
<ExplorerColumnsRenderer
|
||||
@@ -158,12 +159,12 @@ describe('ExplorerColumnsRenderer', () => {
|
||||
);
|
||||
|
||||
const addButton = screen.getByTestId('add-columns-button');
|
||||
await userEvent.click(addButton);
|
||||
await user.click(addButton);
|
||||
|
||||
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
|
||||
expect(screen.getByText('attribute1')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(addButton);
|
||||
await user.click(addButton);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Plus, Trash2 } from '@signozhq/icons';
|
||||
import { ContextLinkProps, Widgets } from 'types/api/dashboard/getAll';
|
||||
import { getBaseUrl } from 'utils/basePath';
|
||||
|
||||
import VariablesDropdown from './VariablesDropdown';
|
||||
import VariablesPopover from './VariablesPopover';
|
||||
|
||||
import './UpdateContextLinks.styles.scss';
|
||||
|
||||
@@ -71,7 +71,7 @@ function UpdateContextLinks({
|
||||
customVariables: fieldVariables,
|
||||
});
|
||||
|
||||
// Transform variables into the format expected by VariablesDropdown
|
||||
// Transform variables into the format expected by VariablesPopover
|
||||
const transformedVariables = useMemo(
|
||||
() => transformContextVariables(variables),
|
||||
[variables],
|
||||
@@ -224,7 +224,9 @@ function UpdateContextLinks({
|
||||
},
|
||||
]}
|
||||
>
|
||||
<VariablesDropdown
|
||||
{/* TODO: replace with AutoComplete with options for variables and
|
||||
previously used URLs for better UX */}
|
||||
<VariablesPopover
|
||||
onVariableSelect={handleVariableSelect}
|
||||
variables={transformedVariables}
|
||||
>
|
||||
@@ -252,7 +254,7 @@ function UpdateContextLinks({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</VariablesDropdown>
|
||||
</VariablesPopover>
|
||||
</Form.Item>
|
||||
|
||||
{/* Remove the separate variables section */}
|
||||
@@ -282,7 +284,7 @@ function UpdateContextLinks({
|
||||
/>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<VariablesDropdown
|
||||
<VariablesPopover
|
||||
onVariableSelect={(variableName, cursorPosition): void =>
|
||||
handleParamVariableSelect(index, variableName, cursorPosition)
|
||||
}
|
||||
@@ -311,7 +313,7 @@ function UpdateContextLinks({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</VariablesDropdown>
|
||||
</VariablesPopover>
|
||||
</Col>
|
||||
<Col span={2}>
|
||||
<Button
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
.variables-dropdown-container {
|
||||
.url-input-trigger {
|
||||
width: 100%;
|
||||
|
||||
.url-input-field {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// Override Ant Design dropdown styles
|
||||
.ant-dropdown-menu {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.variable-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.variable-source {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Dropdown } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import './VariablesDropdown.styles.scss';
|
||||
|
||||
interface VariablesDropdownProps {
|
||||
onVariableSelect: (variableName: string, cursorPosition?: number) => void;
|
||||
variables: VariableItem[];
|
||||
children: (props: {
|
||||
onVariableSelect: (variableName: string, cursorPosition?: number) => void;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
cursorPosition: number | null;
|
||||
setCursorPosition: (position: number | null) => void;
|
||||
}) => ReactNode;
|
||||
}
|
||||
|
||||
interface VariableItem {
|
||||
name: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
function VariablesDropdown({
|
||||
onVariableSelect,
|
||||
variables,
|
||||
children,
|
||||
}: VariablesDropdownProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [cursorPosition, setCursorPosition] = useState<number | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Click outside handler
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent): void {
|
||||
if (
|
||||
wrapperRef.current &&
|
||||
!wrapperRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return (): void => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const dropdownItems = useMemo(
|
||||
() =>
|
||||
variables.map((v) => ({
|
||||
key: v.name,
|
||||
label: (
|
||||
<div className="variable-row">
|
||||
<Typography.Text className="variable-name">{`{{${v.name}}}`}</Typography.Text>
|
||||
<Typography.Text className="variable-source">{v.source}</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
})),
|
||||
[variables],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="variables-dropdown-container" ref={wrapperRef}>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: dropdownItems,
|
||||
onClick: ({ key }): void => {
|
||||
const variableName = key as string;
|
||||
onVariableSelect(`{{${variableName}}}`, cursorPosition || undefined);
|
||||
setIsOpen(false);
|
||||
},
|
||||
}}
|
||||
open={isOpen}
|
||||
placement="bottomLeft"
|
||||
trigger={['click']}
|
||||
getPopupContainer={(): HTMLElement => wrapperRef.current || document.body}
|
||||
>
|
||||
{children({
|
||||
onVariableSelect,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
cursorPosition,
|
||||
setCursorPosition,
|
||||
})}
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesDropdown;
|
||||
@@ -0,0 +1,74 @@
|
||||
.variables-popover-container {
|
||||
.url-input-trigger {
|
||||
width: 100%;
|
||||
|
||||
.url-input-field {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.variables-popover-anchor-wrap {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.variables-popover-content {
|
||||
// antd Modal uses z-index ~1000; popover must sit above it.
|
||||
z-index: 1100;
|
||||
padding: 4px 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-width: var(--radix-popover-trigger-width);
|
||||
}
|
||||
|
||||
.variables-popover-empty {
|
||||
padding: 8px 12px;
|
||||
color: var(--l3-foreground, #999);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.variables-popover-item {
|
||||
all: unset;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--l1-foreground);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--l1-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.variable-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
|
||||
.variable-name,
|
||||
.variable-source {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.variable-name {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.variable-source {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Uses Popover (not DropdownMenu like the rest of the antd-dropdown migration):
|
||||
// DropdownMenuTrigger preventDefaults pointerdown, breaking input focus and
|
||||
// dismissing on every keystroke. PopoverAnchor is a passive positioning element.
|
||||
import { ReactNode, useRef, useState } from 'react';
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@signozhq/ui/popover';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import './VariablesPopover.styles.scss';
|
||||
|
||||
interface VariablesPopoverProps {
|
||||
onVariableSelect: (variableName: string, cursorPosition?: number) => void;
|
||||
variables: VariableItem[];
|
||||
children: (props: {
|
||||
onVariableSelect: (variableName: string, cursorPosition?: number) => void;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
cursorPosition: number | null;
|
||||
setCursorPosition: (position: number | null) => void;
|
||||
}) => ReactNode;
|
||||
}
|
||||
|
||||
interface VariableItem {
|
||||
name: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
function VariablesPopover({
|
||||
onVariableSelect,
|
||||
variables,
|
||||
children,
|
||||
}: VariablesPopoverProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [cursorPosition, setCursorPosition] = useState<number | null>(null);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleOpenChange = (open: boolean): void => {
|
||||
// Accept "close" events from the popover (outside-click, Esc) but ignore
|
||||
// opens — opening is driven by the input's onFocus in the consumer.
|
||||
if (!open) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="variables-popover-container">
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange} modal={false}>
|
||||
<PopoverAnchor asChild>
|
||||
<div className="variables-popover-anchor-wrap" ref={anchorRef}>
|
||||
{children({
|
||||
onVariableSelect,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
cursorPosition,
|
||||
setCursorPosition,
|
||||
})}
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className="variables-popover-content"
|
||||
onOpenAutoFocus={(e): void => e.preventDefault()}
|
||||
onCloseAutoFocus={(e): void => e.preventDefault()}
|
||||
onInteractOutside={(e): void => {
|
||||
// Keep the popover open while interacting with the anchor (the input),
|
||||
// otherwise typing/clicking the input would close it immediately.
|
||||
const target = e.target as Node | null;
|
||||
if (target && anchorRef.current?.contains(target)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onFocusOutside={(e): void => {
|
||||
const target = e.target as Node | null;
|
||||
if (target && anchorRef.current?.contains(target)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{variables.length === 0 ? (
|
||||
<div className="variables-popover-empty">No variables available</div>
|
||||
) : (
|
||||
variables.map((v) => (
|
||||
<button
|
||||
key={v.name}
|
||||
type="button"
|
||||
className="variables-popover-item"
|
||||
onMouseDown={(e): void => {
|
||||
// Prevent the input from losing focus when clicking an item.
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={(): void => {
|
||||
onVariableSelect(`{{${v.name}}}`, cursorPosition || undefined);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="variable-row">
|
||||
<Typography.Text className="variable-name">{`{{${v.name}}}`}</Typography.Text>
|
||||
<Typography.Text className="variable-source">
|
||||
{v.source}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesPopover;
|
||||
@@ -204,7 +204,7 @@ const processContextLinks = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Transforms context variables into the format expected by VariablesDropdown
|
||||
* Transforms context variables into the format expected by VariablesPopover
|
||||
* @param variables - Array of context variables from useContextVariables
|
||||
* @returns Array of transformed variables with proper source descriptions
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
||||
import { ChevronDown } from '@signozhq/icons';
|
||||
import { Button, ColorPicker, Dropdown, MenuProps, Space } from 'antd';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Button, ColorPicker, Space } from 'antd';
|
||||
import type { Color } from 'antd/es/color-picker';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
|
||||
@@ -26,7 +27,7 @@ function ColorSelector({
|
||||
setColorFromPicker(hex);
|
||||
};
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
key: 'Red',
|
||||
label: <CustomColor color="Red" />,
|
||||
@@ -62,7 +63,7 @@ function ColorSelector({
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items }} trigger={['click']}>
|
||||
<DropdownMenuSimple menu={{ items }}>
|
||||
<Button
|
||||
onClick={(e): void => e.preventDefault()}
|
||||
className="color-selector-button"
|
||||
@@ -72,7 +73,7 @@ function ColorSelector({
|
||||
<ChevronDown size="md" />
|
||||
</Space>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Info } from '@signozhq/icons';
|
||||
import { Check } from '@signozhq/icons';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Select,
|
||||
SelectProps,
|
||||
Spin,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
@@ -79,7 +78,6 @@ interface PlannedDowntimeFormData {
|
||||
alertRules: DefaultOptionType[];
|
||||
recurrenceSelect?: AlertmanagertypesRecurrenceDTO;
|
||||
timezone?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
const customFormat = DATE_TIME_FORMATS.ORDINAL_DATETIME;
|
||||
@@ -146,7 +144,6 @@ export function PlannedDowntimeForm(
|
||||
.map((alert) => alert.value)
|
||||
.filter((alert) => alert !== undefined) as string[],
|
||||
name: values.name,
|
||||
scope: values.scope,
|
||||
schedule: {
|
||||
startTime: values.startTime?.format(),
|
||||
endTime: values.endTime?.format(),
|
||||
@@ -281,7 +278,6 @@ export function PlannedDowntimeForm(
|
||||
duration: getDurationInfo(schedule?.recurrence?.duration)?.value ?? '',
|
||||
} as AlertmanagertypesRecurrenceDTO,
|
||||
timezone: schedule?.timezone as string,
|
||||
scope: initialValues.scope || '',
|
||||
};
|
||||
}, [initialValues, alertOptions]);
|
||||
|
||||
@@ -315,7 +311,7 @@ export function PlannedDowntimeForm(
|
||||
default:
|
||||
return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`;
|
||||
}
|
||||
}, [formData, recurrenceType]);
|
||||
}, [formData, recurrenceType, timezone]);
|
||||
|
||||
const endTimeText = useMemo((): string => {
|
||||
const endTime = formData.endTime;
|
||||
@@ -326,7 +322,7 @@ export function PlannedDowntimeForm(
|
||||
const formattedEndTime = endTime.format(TIME_FORMAT);
|
||||
const formattedEndDate = endTime.format(DATE_FORMAT);
|
||||
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
|
||||
}, [formData, recurrenceType]);
|
||||
}, [formData, recurrenceType, timezone]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -492,36 +488,6 @@ export function PlannedDowntimeForm(
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Scope
|
||||
<Tooltip
|
||||
mouseLeaveDelay={0.3}
|
||||
title={
|
||||
<span>
|
||||
Scope the planned downtime by alert labels.{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/alerts-management/planned-maintenance/#scoping-with-label-expressions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Info size={13} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="scope"
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder='e.g. env = "prod" AND region = "us-east-1"'
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<ModalButtonWrapper>
|
||||
<Button
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { Check, ChevronDown, Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { Dropdown } from 'antd';
|
||||
import { useListServiceAccounts } from 'api/generated/services/serviceaccount';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import CreateServiceAccountModal from 'components/CreateServiceAccountModal/CreateServiceAccountModal';
|
||||
@@ -134,7 +133,7 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
|
||||
const totalCount = allAccounts.length;
|
||||
|
||||
const filterMenuItems: MenuProps['items'] = [
|
||||
const filterMenuItems: MenuItem[] = [
|
||||
{
|
||||
key: FilterMode.All,
|
||||
label: (
|
||||
@@ -231,10 +230,9 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
) : (
|
||||
<div className="sa-settings__list-section">
|
||||
<div className="sa-settings__controls">
|
||||
<Dropdown
|
||||
<DropdownMenuSimple
|
||||
menu={{ items: filterMenuItems }}
|
||||
trigger={['click']}
|
||||
overlayClassName="sa-settings-filter-dropdown"
|
||||
className="sa-settings-filter-dropdown"
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
@@ -247,7 +245,7 @@ function ServiceAccountsSettings(): JSX.Element {
|
||||
className="sa-settings-filter-trigger__chevron"
|
||||
/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</DropdownMenuSimple>
|
||||
|
||||
<div className="sa-settings__search">
|
||||
<Input
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
|
||||
@@ -129,6 +130,7 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
});
|
||||
|
||||
it('filter dropdown to "Active" hides DISABLED accounts', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(
|
||||
<NuqsTestingAdapter>
|
||||
<ServiceAccountsSettings />
|
||||
@@ -137,10 +139,10 @@ describe('ServiceAccountsSettings (integration)', () => {
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /All accounts/i }));
|
||||
await user.click(screen.getByRole('button', { name: /All accounts/i }));
|
||||
|
||||
const activeOption = await screen.findByText(/Active ⎯/i);
|
||||
fireEvent.click(activeOption);
|
||||
await user.click(activeOption);
|
||||
|
||||
await screen.findByText('CI Bot');
|
||||
expect(screen.queryByText('Legacy Bot')).not.toBeInTheDocument();
|
||||
|
||||
@@ -662,7 +662,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.pinned):hover,
|
||||
&:not(.pinned).is-hovered,
|
||||
&.dropdown-open {
|
||||
flex: 0 0 240px;
|
||||
max-width: 240px;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
MouseEvent,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -25,7 +26,14 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button, Dropdown, MenuProps, Modal, Tooltip } from 'antd';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@signozhq/ui/dropdown-menu';
|
||||
import { Button, MenuProps, Modal, Tooltip } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { Logout } from 'api/utils';
|
||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
@@ -162,7 +170,9 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
|
||||
const [hasScroll, setHasScroll] = useState(false);
|
||||
const navTopSectionRef = useRef<HTMLDivElement>(null);
|
||||
const sidenavRef = useRef<HTMLDivElement>(null);
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const isDropdownOpenRef = useRef(false);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [pinnedMenuItems, setPinnedMenuItems] = useState<SidebarItem[]>([]);
|
||||
@@ -175,9 +185,27 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
// When the dropdown is open its content renders in a portal outside
|
||||
// the sidenav, which causes the browser to fire mouseleave on the
|
||||
// sidenav. Keep the sidenav expanded in that case.
|
||||
if (isDropdownOpenRef.current) {
|
||||
return;
|
||||
}
|
||||
setIsHovered(false);
|
||||
}, []);
|
||||
|
||||
const handleDropdownOpenChange = useCallback((open: boolean): void => {
|
||||
isDropdownOpenRef.current = open;
|
||||
setIsDropdownOpen(open);
|
||||
if (!open) {
|
||||
// Re-sync hover state on close: the cursor may have moved to the
|
||||
// portal content (outside .sideNav), so mouseleave never fired.
|
||||
requestAnimationFrame(() => {
|
||||
setIsHovered(sidenavRef.current?.matches(':hover') ?? false);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkScroll = useCallback((): void => {
|
||||
if (navTopSectionRef.current) {
|
||||
const { scrollHeight, clientHeight, scrollTop } = navTopSectionRef.current;
|
||||
@@ -959,9 +987,11 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
return (
|
||||
<div className={cx('sidenav-container', isPinned && 'pinned')}>
|
||||
<div
|
||||
ref={sidenavRef}
|
||||
className={cx(
|
||||
'sideNav',
|
||||
isPinned && 'pinned',
|
||||
isHovered && 'is-hovered',
|
||||
isDropdownOpen && 'dropdown-open',
|
||||
)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
@@ -1182,46 +1212,95 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
{isAIAssistantEnabled && renderNavItems([aiAssistantMenuItem], false)}
|
||||
|
||||
<div className="nav-dropdown-item">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: helpSupportDropdownMenuItems,
|
||||
onClick: handleHelpSupportMenuItemClick,
|
||||
}}
|
||||
placement="topLeft"
|
||||
overlayClassName="nav-dropdown-overlay help-support-dropdown"
|
||||
trigger={['click']}
|
||||
onOpenChange={(open): void => setIsDropdownOpen(open)}
|
||||
>
|
||||
<div className="nav-item">
|
||||
<div className="nav-item-data" data-testid="help-support-nav-item">
|
||||
<div className="nav-item-icon">{helpSupportMenuItem.icon}</div>
|
||||
<DropdownMenu onOpenChange={handleDropdownOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="nav-item">
|
||||
<div className="nav-item-data" data-testid="help-support-nav-item">
|
||||
<div className="nav-item-icon">{helpSupportMenuItem.icon}</div>
|
||||
|
||||
<div className="nav-item-label">{helpSupportMenuItem.label}</div>
|
||||
<div className="nav-item-label">{helpSupportMenuItem.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="nav-dropdown-overlay help-support-dropdown"
|
||||
>
|
||||
{helpSupportDropdownMenuItems.map((item, idx) => {
|
||||
if ('type' in item) {
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
return <DropdownMenuSeparator key={`help-sep-${idx}`} />;
|
||||
}
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={String(item.key)}
|
||||
leftIcon={item.icon}
|
||||
onClick={(e): void =>
|
||||
handleHelpSupportMenuItemClick({
|
||||
...item,
|
||||
key: String(item.key),
|
||||
domEvent: e.nativeEvent,
|
||||
} as unknown as SidebarItem)
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="nav-dropdown-item">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: userSettingsDropdownMenuItems,
|
||||
onClick: handleSettingsMenuItemClick,
|
||||
}}
|
||||
placement="topLeft"
|
||||
overlayClassName="nav-dropdown-overlay settings-dropdown"
|
||||
trigger={['click']}
|
||||
onOpenChange={(open): void => setIsDropdownOpen(open)}
|
||||
>
|
||||
<div className={cx('nav-item', isSettingsPage && 'active')}>
|
||||
<div className="nav-item-active-marker" />
|
||||
<div className="nav-item-data" data-testid="settings-nav-item">
|
||||
<div className="nav-item-icon">{userSettingsMenuItem.icon}</div>
|
||||
<DropdownMenu onOpenChange={handleDropdownOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className={cx('nav-item', isSettingsPage && 'active')}>
|
||||
<div className="nav-item-active-marker" />
|
||||
<div className="nav-item-data" data-testid="settings-nav-item">
|
||||
<div className="nav-item-icon">{userSettingsMenuItem.icon}</div>
|
||||
|
||||
<div className="nav-item-label">{userSettingsMenuItem.label}</div>
|
||||
<div className="nav-item-label">{userSettingsMenuItem.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="nav-dropdown-overlay settings-dropdown"
|
||||
>
|
||||
{(userSettingsDropdownMenuItems ?? []).map((item, idx) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
if ('type' in item && item.type === 'divider') {
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
return <DropdownMenuSeparator key={`settings-sep-${idx}`} />;
|
||||
}
|
||||
const settingsItem = item as {
|
||||
key?: string | number;
|
||||
label?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
};
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={String(settingsItem.key)}
|
||||
leftIcon={settingsItem.icon}
|
||||
disabled={settingsItem.disabled}
|
||||
onClick={(e): void =>
|
||||
handleSettingsMenuItemClick({
|
||||
key: String(settingsItem.key),
|
||||
domEvent: e.nativeEvent,
|
||||
} as unknown as SidebarItem)
|
||||
}
|
||||
>
|
||||
{settingsItem.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
border-color: var(--l1-border);
|
||||
margin: 0;
|
||||
}
|
||||
.dropdown-icon {
|
||||
margin-right: 4px;
|
||||
.dropdown-trigger-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
.dropdown-menu {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Divider, Dropdown, MenuProps, Tooltip } from 'antd';
|
||||
import { Button, Divider, Tooltip } from 'antd';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { Copy, Ellipsis, PenLine, Trash2 } from '@signozhq/icons';
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
} from 'pages/AlertDetails/hooks';
|
||||
import CopyToClipboard from 'periscope/components/CopyToClipboard';
|
||||
import { useAlertRule } from 'providers/Alert';
|
||||
import { CSSProperties } from 'styled-components';
|
||||
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
|
||||
import { AlertDef } from 'types/api/alerts/def';
|
||||
|
||||
@@ -21,16 +21,6 @@ import RenameModal from './RenameModal';
|
||||
|
||||
import './ActionButtons.styles.scss';
|
||||
|
||||
const menuItemStyle: CSSProperties = {
|
||||
fontSize: '14px',
|
||||
letterSpacing: '0.14px',
|
||||
};
|
||||
|
||||
const menuItemStyleV2: CSSProperties = {
|
||||
fontSize: '13px',
|
||||
letterSpacing: '0.13px',
|
||||
};
|
||||
|
||||
function AlertActionButtons({
|
||||
ruleId,
|
||||
alertDetails,
|
||||
@@ -68,9 +58,7 @@ function AlertActionButtons({
|
||||
|
||||
const isV2Alert = alertDetails.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
|
||||
|
||||
const finalMenuItemStyle = isV2Alert ? menuItemStyleV2 : menuItemStyle;
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
const menuItems: MenuItem[] = [
|
||||
...(!isV2Alert
|
||||
? [
|
||||
{
|
||||
@@ -78,7 +66,6 @@ function AlertActionButtons({
|
||||
label: 'Rename',
|
||||
icon: <PenLine size={16} color={Color.BG_VANILLA_400} />,
|
||||
onClick: handleRename,
|
||||
style: finalMenuItemStyle,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -87,17 +74,13 @@ function AlertActionButtons({
|
||||
label: 'Duplicate',
|
||||
icon: <Copy size={16} color={Color.BG_VANILLA_400} />,
|
||||
onClick: handleAlertDuplicate,
|
||||
style: finalMenuItemStyle,
|
||||
},
|
||||
{
|
||||
key: 'delete-rule',
|
||||
label: 'Delete',
|
||||
icon: <Trash2 size={16} color={Color.BG_CHERRY_400} />,
|
||||
onClick: handleAlertDelete,
|
||||
style: {
|
||||
...finalMenuItemStyle,
|
||||
color: Color.BG_CHERRY_400,
|
||||
},
|
||||
danger: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -140,16 +123,21 @@ function AlertActionButtons({
|
||||
|
||||
<Divider type="vertical" />
|
||||
|
||||
<Dropdown trigger={['click']} menu={{ items: menuItems }}>
|
||||
<Tooltip title="More options">
|
||||
<Ellipsis
|
||||
size={16}
|
||||
color={isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400}
|
||||
cursor="pointer"
|
||||
className="dropdown-icon"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
<DropdownMenuSimple menu={{ items: menuItems }}>
|
||||
<span className="dropdown-trigger-wrapper">
|
||||
<Tooltip title="More options">
|
||||
<Button
|
||||
type="text"
|
||||
icon={
|
||||
<Ellipsis
|
||||
size={16}
|
||||
color={isDarkMode ? Color.BG_VANILLA_400 : Color.BG_INK_400}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</DropdownMenuSimple>
|
||||
</div>
|
||||
|
||||
<RenameModal
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Form,
|
||||
MenuProps,
|
||||
Space,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import { Button, Divider, Form, Space, Tooltip } from 'antd';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import cx from 'classnames';
|
||||
import { FilterSelect } from 'components/CeleryOverview/CeleryOverviewConfigOptions/CeleryOverviewConfigOptions';
|
||||
@@ -44,16 +37,22 @@ function FunnelStep({
|
||||
const [isAddDetailsModalOpen, setIsAddDetailsModalOpen] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const latencyPointerItems: MenuProps['items'] = LatencyPointers.map(
|
||||
(option) => ({
|
||||
key: option.value,
|
||||
label: option.key,
|
||||
style:
|
||||
option.value === stepData.latency_pointer
|
||||
? { backgroundColor: 'var(--bg-slate-100)' }
|
||||
: {},
|
||||
}),
|
||||
);
|
||||
const latencyPointerItems: MenuItem[] = [
|
||||
{
|
||||
type: 'radio-group',
|
||||
value: stepData.latency_pointer,
|
||||
onChange: (value): void =>
|
||||
onStepChange(index, {
|
||||
latency_pointer: value as FunnelStepData['latency_pointer'],
|
||||
}),
|
||||
children: LatencyPointers.map((option) => ({
|
||||
type: 'radio',
|
||||
key: option.value,
|
||||
label: option.key,
|
||||
value: option.value,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
const updatedCurrentQuery = useMemo(
|
||||
() => ({
|
||||
@@ -211,17 +210,18 @@ function FunnelStep({
|
||||
</div>
|
||||
<div className="latency-pointer">
|
||||
<div className="latency-pointer__label">Latency pointer</div>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: latencyPointerItems,
|
||||
onClick: ({ key }): void =>
|
||||
onStepChange(index, {
|
||||
latency_pointer: key as FunnelStepData['latency_pointer'],
|
||||
}),
|
||||
}}
|
||||
trigger={['click']}
|
||||
disabled={!hasEditPermission}
|
||||
>
|
||||
{hasEditPermission ? (
|
||||
<DropdownMenuSimple menu={{ items: latencyPointerItems }}>
|
||||
<Space>
|
||||
{
|
||||
LatencyPointers.find(
|
||||
(option) => option.value === stepData.latency_pointer,
|
||||
)?.key
|
||||
}
|
||||
<ChevronDown size={14} color="var(--bg-vanilla-400)" />
|
||||
</Space>
|
||||
</DropdownMenuSimple>
|
||||
) : (
|
||||
<Space>
|
||||
{
|
||||
LatencyPointers.find(
|
||||
@@ -230,7 +230,7 @@ function FunnelStep({
|
||||
}
|
||||
<ChevronDown size={14} color="var(--bg-vanilla-400)" />
|
||||
</Space>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { compose, Store } from 'redux';
|
||||
import type { WebSettings } from 'types/generated/webSettings';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -8,7 +7,6 @@ declare global {
|
||||
pylon: any;
|
||||
Appcues: Record<string, any>;
|
||||
__REDUX_DEVTOOLS_EXTENSION_COMPOSE__: typeof compose;
|
||||
signozBootData?: { settings: WebSettings | null };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
export {};
|
||||
|
||||
type BootData = typeof import('../bootData');
|
||||
|
||||
function loadModule(settings?: object | null): BootData {
|
||||
(window as any).signozBootData =
|
||||
settings !== undefined ? { settings } : undefined;
|
||||
let mod!: BootData;
|
||||
jest.isolateModules(() => {
|
||||
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
|
||||
mod = require('../bootData');
|
||||
});
|
||||
return mod;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as any).signozBootData;
|
||||
});
|
||||
|
||||
describe('when window.signozBootData is absent', () => {
|
||||
it('defaults posthog and appcues to enabled', () => {
|
||||
const { bootSettings } = loadModule();
|
||||
expect(bootSettings.posthog.enabled).toBe(true);
|
||||
expect(bootSettings.appcues.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when window.signozBootData.settings is null (injection failed)', () => {
|
||||
it('defaults posthog and appcues to enabled', () => {
|
||||
const { bootSettings } = loadModule(null);
|
||||
expect(bootSettings.posthog.enabled).toBe(true);
|
||||
expect(bootSettings.appcues.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when window.signozBootData.settings is populated', () => {
|
||||
it('reads posthog enabled: true', () => {
|
||||
const { bootSettings } = loadModule({ posthog: { enabled: true } });
|
||||
expect(bootSettings.posthog.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('reads posthog enabled: false', () => {
|
||||
const { bootSettings } = loadModule({ posthog: { enabled: false } });
|
||||
expect(bootSettings.posthog.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('reads appcues enabled: true', () => {
|
||||
const { bootSettings } = loadModule({ appcues: { enabled: true } });
|
||||
expect(bootSettings.appcues.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('reads appcues enabled: false', () => {
|
||||
const { bootSettings } = loadModule({ appcues: { enabled: false } });
|
||||
expect(bootSettings.appcues.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('missing sub-namespace defaults to enabled', () => {
|
||||
const { bootSettings } = loadModule({ posthog: { enabled: false } });
|
||||
expect(bootSettings.appcues.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when window.signozBootData exists but settings is undefined', () => {
|
||||
it('defaults posthog and appcues to enabled', () => {
|
||||
(window as any).signozBootData = {};
|
||||
let mod!: BootData;
|
||||
jest.isolateModules(() => {
|
||||
// oxlint-disable-next-line typescript-eslint/no-require-imports, typescript-eslint/no-var-requires
|
||||
mod = require('../bootData');
|
||||
});
|
||||
expect(mod.bootSettings.posthog.enabled).toBe(true);
|
||||
expect(mod.bootSettings.appcues.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { WebSettings } from 'types/generated/webSettings';
|
||||
|
||||
const raw = window.signozBootData?.settings as
|
||||
| Partial<WebSettings>
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export const bootSettings: Readonly<WebSettings> = {
|
||||
posthog: { enabled: raw?.posthog?.enabled ?? true },
|
||||
appcues: { enabled: raw?.appcues?.enabled ?? true },
|
||||
};
|
||||
@@ -23,20 +23,6 @@ function devBasePathPlugin(basePath: string): Plugin {
|
||||
};
|
||||
}
|
||||
|
||||
function devBootDataPlugin(env: Record<string, string>): Plugin {
|
||||
return {
|
||||
name: 'dev-boot-data',
|
||||
apply: 'serve',
|
||||
transformIndexHtml(html): string {
|
||||
const settings = {
|
||||
posthog: { enabled: env.VITE_POSTHOG_ENABLED !== 'false' },
|
||||
appcues: { enabled: env.VITE_APPCUES_ENABLED !== 'false' },
|
||||
};
|
||||
return html.replaceAll('[[.Settings]]', JSON.stringify(settings));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rawMarkdownPlugin(): Plugin {
|
||||
return {
|
||||
name: 'raw-markdown',
|
||||
@@ -61,7 +47,6 @@ export default defineConfig(({ mode }): UserConfig => {
|
||||
tsconfigPaths(),
|
||||
rawMarkdownPlugin(),
|
||||
devBasePathPlugin(basePath),
|
||||
devBootDataPlugin(env),
|
||||
react(),
|
||||
createHtmlPlugin({
|
||||
inject: {
|
||||
|
||||
@@ -42,14 +42,7 @@ func (m *MaintenanceMuter) Mutes(ctx context.Context, lset model.LabelSet) bool
|
||||
}
|
||||
now := time.Now()
|
||||
for _, mw := range m.getMaintenances(ctx) {
|
||||
skip, err := mw.ShouldSkip(ruleID, now, lset)
|
||||
if err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to test maintenance window skip condition",
|
||||
slog.String("maintenance_id", mw.ID.StringValue()),
|
||||
slog.String("scope", mw.Scope),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else if skip {
|
||||
if mw.ShouldSkip(ruleID, now) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -68,14 +61,7 @@ func (m *MaintenanceMuter) MutedBy(ctx context.Context, lset model.LabelSet) []s
|
||||
var ids []string
|
||||
now := time.Now()
|
||||
for _, mw := range m.getMaintenances(ctx) {
|
||||
skip, err := mw.ShouldSkip(ruleID, now, lset)
|
||||
if err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to test maintenance window skip condition",
|
||||
slog.String("maintenance_id", mw.ID.StringValue()),
|
||||
slog.String("scope", mw.Scope),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
} else if skip {
|
||||
if mw.ShouldSkip(ruleID, now) {
|
||||
ids = append(ids, mw.ID.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
Expression: `ruleId = "high-cpu-usage" AND severity = "critical"`,
|
||||
Expression: `ruleId == "high-cpu-usage" && severity == "critical"`,
|
||||
ExpressionKind: alertmanagertypes.RuleBasedExpression,
|
||||
Name: "high-cpu-usage",
|
||||
Description: "High CPU critical alerts to webhook",
|
||||
@@ -53,7 +53,7 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
Expression: `ruleId = "high-cpu-usage" AND severity = "warning"`,
|
||||
Expression: `ruleId == "high-cpu-usage" && severity == "warning"`,
|
||||
ExpressionKind: alertmanagertypes.RuleBasedExpression,
|
||||
Name: "high-cpu-usage",
|
||||
Description: "High CPU warning alerts to webhook",
|
||||
@@ -87,25 +87,18 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
|
||||
err = notificationManager.SetNotificationConfig(orgID, "high-cpu-usage", ¬ifConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
activeSchedule := &alertmanagertypes.Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Now().Add(-time.Hour),
|
||||
EndTime: time.Now().Add(time.Hour),
|
||||
}
|
||||
// mwRuleIDAndScope: only critical high-cpu-usage alerts.
|
||||
mwRuleIDAndScope := valuer.GenerateUUID()
|
||||
// mwRuleIDOnly: all high-cpu-usage alerts regardless of severity.
|
||||
mwRuleIDOnly := valuer.GenerateUUID()
|
||||
// mwScopeOnly: all critical alerts regardless of rule ID.
|
||||
mwScopeOnly := valuer.GenerateUUID()
|
||||
|
||||
mwID := valuer.GenerateUUID()
|
||||
maintenanceStore := alertmanagertypestest.NewMockMaintenanceStore(t)
|
||||
maintenanceStore.On("ListPlannedMaintenance", mock.Anything, orgID).Return(
|
||||
[]*alertmanagertypes.PlannedMaintenance{
|
||||
{ID: mwRuleIDAndScope, Schedule: activeSchedule, RuleIDs: []string{"high-cpu-usage"}, Scope: `severity = "critical"`},
|
||||
{ID: mwRuleIDOnly, Schedule: activeSchedule, RuleIDs: []string{"high-cpu-usage"}},
|
||||
{ID: mwScopeOnly, Schedule: activeSchedule, Scope: `severity = "critical"`},
|
||||
}, nil,
|
||||
[]*alertmanagertypes.PlannedMaintenance{{
|
||||
ID: mwID,
|
||||
Schedule: &alertmanagertypes.Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Now().Add(-time.Hour),
|
||||
EndTime: time.Now().Add(time.Hour),
|
||||
},
|
||||
RuleIDs: []string{"high-cpu-usage"},
|
||||
}}, nil,
|
||||
)
|
||||
|
||||
srvCfg := NewConfig()
|
||||
@@ -256,42 +249,18 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
|
||||
require.Equal(t, "{__receiver__=\"webhook\"}:{cluster=\"prod-cluster\", instance=\"server-03\", ruleId=\"high-cpu-usage\"}", alertGroups[2].GroupKey)
|
||||
})
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/alerts", nil)
|
||||
require.NoError(t, err)
|
||||
params, err := alertmanagertypes.NewGettableAlertsParams(req)
|
||||
require.NoError(t, err)
|
||||
alerts, err := server.GetAlerts(ctx, params)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("verify_muting_ruleid_and_scope", func(t *testing.T) {
|
||||
// Window with ruleID + scope mutes only alerts matching both.
|
||||
for _, alert := range alerts {
|
||||
if alert.Labels["ruleId"] == "high-cpu-usage" && alert.Labels["severity"] == "critical" {
|
||||
require.Contains(t, alert.Status.MutedBy, mwRuleIDAndScope.String())
|
||||
} else {
|
||||
require.NotContains(t, alert.Status.MutedBy, mwRuleIDAndScope.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("verify_muting_ruleid_only", func(t *testing.T) {
|
||||
// Window with ruleID but no scope mutes all severities for that rule.
|
||||
t.Run("verify_muting", func(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "/alerts", nil)
|
||||
require.NoError(t, err)
|
||||
params, err := alertmanagertypes.NewGettableAlertsParams(req)
|
||||
require.NoError(t, err)
|
||||
alerts, err := server.GetAlerts(ctx, params)
|
||||
require.NoError(t, err)
|
||||
for _, alert := range alerts {
|
||||
if alert.Labels["ruleId"] == "high-cpu-usage" {
|
||||
require.Contains(t, alert.Status.MutedBy, mwRuleIDOnly.String())
|
||||
require.Equal(t, []string{mwID.String()}, alert.Status.MutedBy)
|
||||
} else {
|
||||
require.NotContains(t, alert.Status.MutedBy, mwRuleIDOnly.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("verify_muting_scope_only", func(t *testing.T) {
|
||||
// Window with scope but no ruleIDs mutes all critical alerts regardless of rule.
|
||||
for _, alert := range alerts {
|
||||
if alert.Labels["severity"] == "critical" {
|
||||
require.Contains(t, alert.Status.MutedBy, mwScopeOnly.String())
|
||||
} else {
|
||||
require.NotContains(t, alert.Status.MutedBy, mwScopeOnly.String())
|
||||
require.Empty(t, alert.Status.MutedBy)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -89,7 +89,6 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
|
||||
Description: maintenance.Description,
|
||||
Schedule: maintenance.Schedule,
|
||||
OrgID: claims.OrgID,
|
||||
Scope: maintenance.Scope,
|
||||
}
|
||||
|
||||
maintenanceRules := make([]*alertmanagertypes.StorablePlannedMaintenanceRule, 0)
|
||||
@@ -124,6 +123,7 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
|
||||
NewInsert().
|
||||
Model(&maintenanceRules).
|
||||
Exec(ctx)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -141,7 +141,6 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
|
||||
Description: storablePlannedMaintenance.Description,
|
||||
Schedule: storablePlannedMaintenance.Schedule,
|
||||
RuleIDs: maintenance.AlertIds,
|
||||
Scope: maintenance.Scope,
|
||||
CreatedAt: storablePlannedMaintenance.CreatedAt,
|
||||
CreatedBy: storablePlannedMaintenance.CreatedBy,
|
||||
UpdatedAt: storablePlannedMaintenance.UpdatedAt,
|
||||
@@ -190,7 +189,6 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
|
||||
Description: maintenance.Description,
|
||||
Schedule: maintenance.Schedule,
|
||||
OrgID: claims.OrgID,
|
||||
Scope: maintenance.Scope,
|
||||
}
|
||||
|
||||
storablePlannedMaintenanceRules := make([]*alertmanagertypes.StorablePlannedMaintenanceRule, 0)
|
||||
@@ -226,6 +224,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
|
||||
Model(new(alertmanagertypes.StorablePlannedMaintenanceRule)).
|
||||
Where("planned_maintenance_id = ?", storablePlannedMaintenance.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -242,6 +241,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -3,6 +3,7 @@ package rulebasednotification
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
||||
@@ -234,13 +235,65 @@ func (r *provider) Match(ctx context.Context, orgID string, ruleID string, set m
|
||||
return matchedChannels, nil
|
||||
}
|
||||
|
||||
// convertLabelSetToEnv delegates to alertmanagertypes.ConvertLabelSetToEnv and
|
||||
// logs when a key is a prefix of another (e.g. "foo" alongside "foo.bar").
|
||||
// convertLabelSetToEnv converts a flat label set with dotted keys into a nested map structure for expr env.
|
||||
// when both a leaf and a deeper nested path exist (e.g. "foo" and "foo.bar"),
|
||||
// the nested structure takes precedence. That means we will replace an existing leaf at any
|
||||
// intermediate path with a map so we can materialize the deeper structure.
|
||||
// TODO(srikanthccv): we need a better solution to handle this, remove the following
|
||||
// when we update the expr to support dotted keys.
|
||||
func (r *provider) convertLabelSetToEnv(ctx context.Context, labelSet model.LabelSet) map[string]interface{} {
|
||||
env, conflict := alertmanagertypes.ConvertLabelSetToEnv(labelSet)
|
||||
if conflict {
|
||||
env := make(map[string]interface{})
|
||||
|
||||
logForReview := false
|
||||
|
||||
for lk, lv := range labelSet {
|
||||
key := strings.TrimSpace(string(lk))
|
||||
value := string(lv)
|
||||
|
||||
if strings.Contains(key, ".") {
|
||||
parts := strings.Split(key, ".")
|
||||
current := env
|
||||
|
||||
for i, raw := range parts {
|
||||
part := strings.TrimSpace(raw)
|
||||
|
||||
last := i == len(parts)-1
|
||||
if last {
|
||||
if _, isMap := current[part].(map[string]interface{}); isMap {
|
||||
logForReview = true
|
||||
// deeper structure already exists; do not overwrite.
|
||||
break
|
||||
}
|
||||
current[part] = value
|
||||
break
|
||||
}
|
||||
|
||||
// ensure a map so we can keep descending.
|
||||
if nextMap, ok := current[part].(map[string]interface{}); ok {
|
||||
current = nextMap
|
||||
continue
|
||||
}
|
||||
|
||||
// if absent or a leaf, replace it with a map.
|
||||
newMap := make(map[string]interface{})
|
||||
current[part] = newMap
|
||||
current = newMap
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// if a map already sits here (due to nested keys), keep the map (nested wins).
|
||||
if _, isMap := env[key].(map[string]interface{}); isMap {
|
||||
logForReview = true
|
||||
continue
|
||||
}
|
||||
env[key] = value
|
||||
}
|
||||
|
||||
if logForReview {
|
||||
r.settings.Logger().InfoContext(ctx, "found label set with conflicting prefix dotted keys", slog.Any("labels", labelSet))
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
|
||||
@@ -925,3 +925,72 @@ func TestProvider_CreateRoutes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertLabelSetToEnv(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
labelSet model.LabelSet
|
||||
expected map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "simple keys",
|
||||
labelSet: model.LabelSet{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": "value2",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested keys",
|
||||
labelSet: model.LabelSet{
|
||||
"foo.bar": "value1",
|
||||
"foo.baz": "value2",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": "value1",
|
||||
"baz": "value2",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "conflict - nested structure wins",
|
||||
labelSet: model.LabelSet{
|
||||
"foo.bar.baz": "deep",
|
||||
"foo.bar": "shallow",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": map[string]interface{}{
|
||||
"baz": "deep",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "conflict - leaf value vs nested",
|
||||
labelSet: model.LabelSet{
|
||||
"foo.bar": "value",
|
||||
"foo": "should_be_ignored",
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
provider := &provider{
|
||||
settings: factory.NewScopedProviderSettings(createTestProviderSettings(), "provider_test"),
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := provider.convertLabelSetToEnv(context.Background(), tt.labelSet)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,24 +29,5 @@ func (provider *provider) addTraceDetailRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v4/traces/{traceID}/waterfall", handler.New(
|
||||
provider.authzMiddleware.ViewAccess(provider.traceDetailHandler.GetWaterfallV4),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetWaterfallV4",
|
||||
Tags: []string{"tracedetail"},
|
||||
Summary: "Get waterfall view for a trace (OOM-safe)",
|
||||
Description: "Two-step fetch: minimal fields for all spans to build the tree, full fields only for the visible window. Aggregations are not included in the response.",
|
||||
Request: new(spantypes.PostableWaterfall),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(spantypes.GettableWaterfallTrace),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
},
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ package envprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/config"
|
||||
@@ -11,21 +9,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// clearSignozEnv unsets all existing SIGNOZ_* env vars for the duration of the test.
|
||||
func clearSignozEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, kv := range os.Environ() {
|
||||
if strings.HasPrefix(kv, prefix) {
|
||||
key := strings.SplitN(kv, "=", 2)[0]
|
||||
orig, _ := os.LookupEnv(key)
|
||||
os.Unsetenv(key)
|
||||
t.Cleanup(func() { os.Setenv(key, orig) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWithStrings(t *testing.T) {
|
||||
clearSignozEnv(t)
|
||||
t.Setenv("SIGNOZ_K1_K2", "string")
|
||||
t.Setenv("SIGNOZ_K3__K4", "string")
|
||||
t.Setenv("SIGNOZ_K5__K6_K7__K8", "string")
|
||||
@@ -47,7 +31,6 @@ func TestGetWithStrings(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetWithNoPrefix(t *testing.T) {
|
||||
clearSignozEnv(t)
|
||||
t.Setenv("K1_K2", "string")
|
||||
t.Setenv("K3_K4", "string")
|
||||
expected := map[string]any{}
|
||||
@@ -60,7 +43,6 @@ func TestGetWithNoPrefix(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetWithGoTypes(t *testing.T) {
|
||||
clearSignozEnv(t)
|
||||
t.Setenv("SIGNOZ_BOOL", "true")
|
||||
t.Setenv("SIGNOZ_STRING", "string")
|
||||
t.Setenv("SIGNOZ_INT", "1")
|
||||
|
||||
@@ -3,15 +3,14 @@ package flagger
|
||||
import "github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
|
||||
var (
|
||||
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
|
||||
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
|
||||
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
|
||||
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
|
||||
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
|
||||
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
|
||||
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
|
||||
FeatureUseFineGrainedAuthz = featuretypes.MustNewName("use_fine_grained_authz")
|
||||
FeatureUseDashboardV2 = featuretypes.MustNewName("use_dashboard_v2")
|
||||
FeatureUseSpanMetrics = featuretypes.MustNewName("use_span_metrics")
|
||||
FeatureKafkaSpanEval = featuretypes.MustNewName("kafka_span_eval")
|
||||
FeatureHideRootUser = featuretypes.MustNewName("hide_root_user")
|
||||
FeatureGetMetersFromZeus = featuretypes.MustNewName("get_meters_from_zeus")
|
||||
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
|
||||
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
|
||||
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
|
||||
FeatureUseFineGrainedAuthz = featuretypes.MustNewName("use_fine_grained_authz")
|
||||
)
|
||||
|
||||
func MustNewRegistry() featuretypes.Registry {
|
||||
@@ -80,14 +79,6 @@ func MustNewRegistry() featuretypes.Registry {
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
&featuretypes.Feature{
|
||||
Name: FeatureUseDashboardV2,
|
||||
Kind: featuretypes.KindBoolean,
|
||||
Stage: featuretypes.StageExperimental,
|
||||
Description: "Controls whether dashboard v2 is enabled",
|
||||
DefaultVariant: featuretypes.MustNewName("disabled"),
|
||||
Variants: featuretypes.NewBooleanVariants(),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
@@ -38,24 +38,3 @@ func (h *handler) GetWaterfall(rw http.ResponseWriter, r *http.Request) {
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *handler) GetWaterfallV4(rw http.ResponseWriter, r *http.Request) {
|
||||
req := new(spantypes.PostableWaterfall)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := req.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.module.GetWaterfallV4(r.Context(), mux.Vars(r)["traceID"], req)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func (m *module) GetWaterfall(ctx context.Context, traceID string, req *spantype
|
||||
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, uncollapsedSpans, selectedAllSpans, aggregationResults), nil
|
||||
}
|
||||
|
||||
// getTraceData fetches all spans for a trace and builds the WaterfallTrace.
|
||||
// getTraceData returns the waterfall cache for the given traceID with fallback on DB.
|
||||
func (m *module) getTraceData(ctx context.Context, traceID string) (*spantypes.WaterfallTrace, error) {
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
@@ -61,86 +61,6 @@ func (m *module) getTraceData(ctx context.Context, traceID string) (*spantypes.W
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
|
||||
nodes := make([]*spantypes.WaterfallSpan, len(spanItems))
|
||||
for i := range spanItems {
|
||||
nodes[i] = spanItems[i].ToWaterfallSpan()
|
||||
}
|
||||
return spantypes.NewWaterfallTraceFromSpans(nodes), nil
|
||||
}
|
||||
|
||||
// GetWaterfallV4 is the OOM-safe V4 waterfall.
|
||||
// For large traces (NumSpans > effectiveLimit) it uses a two-step fetch:
|
||||
// minimal fields for all spans to build the tree, then full fields for the
|
||||
// visible window only. Aggregations are not returned.
|
||||
func (m *module) GetWaterfallV4(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error) {
|
||||
summary, err := m.store.GetTraceSummary(ctx, traceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
effectiveLimit := min(req.Limit, m.config.Waterfall.MaxLimitToSelectAllSpans)
|
||||
if summary.NumSpans > uint64(effectiveLimit) {
|
||||
return m.getWindowedWaterfall(ctx, traceID, req, summary, effectiveLimit)
|
||||
}
|
||||
return m.getFullWaterfall(ctx, traceID, summary)
|
||||
}
|
||||
|
||||
func (m *module) getFullWaterfall(ctx context.Context, traceID string, summary *spantypes.TraceSummary) (*spantypes.GettableWaterfallTrace, error) {
|
||||
spanItems, err := m.store.GetTraceSpans(ctx, traceID, summary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(spanItems) == 0 {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
|
||||
nodes := make([]*spantypes.WaterfallSpan, len(spanItems))
|
||||
for i := range spanItems {
|
||||
nodes[i] = spanItems[i].ToWaterfallSpan()
|
||||
}
|
||||
waterfallTrace := spantypes.NewWaterfallTraceFromSpans(nodes)
|
||||
selectedSpans := waterfallTrace.GetAllSpans()
|
||||
|
||||
return spantypes.NewGettableWaterfallTrace(waterfallTrace, selectedSpans, nil, true, nil), nil
|
||||
}
|
||||
|
||||
// getWindowedWaterfall builds the waterfall tree with minimal data and then returns only a window of full spans.
|
||||
func (m *module) getWindowedWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall, summary *spantypes.TraceSummary, effectiveLimit uint) (*spantypes.GettableWaterfallTrace, error) {
|
||||
// Step 1: minimal fetch → build full tree → select visible window
|
||||
minimalSpans, err := m.store.GetMinimalSpans(ctx, traceID, summary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(minimalSpans) == 0 {
|
||||
return nil, spantypes.ErrTraceNotFound
|
||||
}
|
||||
|
||||
nodes := make([]*spantypes.WaterfallSpan, len(minimalSpans))
|
||||
for i := range minimalSpans {
|
||||
nodes[i] = minimalSpans[i].ToWaterfallSpan()
|
||||
}
|
||||
waterfallTrace := spantypes.NewWaterfallTraceFromSpans(nodes)
|
||||
|
||||
selectedSpans, uncollapsedSpans := waterfallTrace.GetSelectedSpans(
|
||||
req.UncollapsedSpans,
|
||||
req.SelectedSpanID,
|
||||
m.config.Waterfall.SpanPageSize,
|
||||
m.config.Waterfall.MaxDepthToAutoExpand,
|
||||
)
|
||||
|
||||
// Step 2: full fetch for the selected window only
|
||||
spanIDs := make([]string, len(selectedSpans))
|
||||
for i, s := range selectedSpans {
|
||||
spanIDs[i] = s.SpanID
|
||||
}
|
||||
fullSpans, err := m.store.GetTraceSpansByIDs(ctx, traceID, summary, spanIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spantypes.EnrichSelectedSpans(selectedSpans, fullSpans)
|
||||
|
||||
return spantypes.NewGettableWaterfallTrace(
|
||||
waterfallTrace, selectedSpans, uncollapsedSpans, false, nil,
|
||||
), nil
|
||||
traceData := spantypes.NewWaterfallTraceFromSpans(spanItems)
|
||||
return traceData, nil
|
||||
}
|
||||
|
||||
@@ -12,9 +12,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
)
|
||||
|
||||
// The $$$$ becomes $$ since go-sqlbuilder escapes $ sign
|
||||
const serviceNameCol = "resource_string_service$$$$name"
|
||||
|
||||
type traceStore struct {
|
||||
telemetryStore telemetrystore.TelemetryStore
|
||||
}
|
||||
@@ -72,64 +69,3 @@ func (s *traceStore) GetTraceSpans(ctx context.Context, traceID string, summary
|
||||
}
|
||||
return spanItems, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetMinimalSpans(ctx context.Context, traceID string, summary *spantypes.TraceSummary) ([]spantypes.MinimalSpan, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"DISTINCT ON (span_id) span_id",
|
||||
"parent_span_id", "timestamp", "duration_nano", "has_error",
|
||||
serviceNameCol,
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
sb.Where(
|
||||
sb.E("trace_id", traceID),
|
||||
sb.GE("ts_bucket_start", summary.Start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", summary.End.Unix()),
|
||||
)
|
||||
sb.OrderByAsc("timestamp")
|
||||
sb.OrderByAsc("name")
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var spans []spantypes.MinimalSpan
|
||||
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying minimal spans")
|
||||
}
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
func (s *traceStore) GetTraceSpansByIDs(ctx context.Context, traceID string, summary *spantypes.TraceSummary, spanIDs []string) ([]spantypes.StorableSpan, error) {
|
||||
if len(spanIDs) == 0 {
|
||||
return []spantypes.StorableSpan{}, nil
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"DISTINCT ON (span_id) timestamp",
|
||||
"duration_nano", "span_id", "trace_id", "has_error", "kind",
|
||||
serviceNameCol, "name", "links as references",
|
||||
"attributes_string", "attributes_number", "attributes_bool", "resources_string",
|
||||
"events", "status_message", "status_code_string", "kind_string", "parent_span_id",
|
||||
"flags", "is_remote", "trace_state", "status_code",
|
||||
"db_name", "db_operation", "http_method", "http_url", "http_host",
|
||||
"external_http_method", "external_http_url", "response_status_code",
|
||||
)
|
||||
sb.From(fmt.Sprintf("%s.%s", spantypes.TraceDB, spantypes.TraceTable))
|
||||
ids := make([]any, len(spanIDs))
|
||||
for i, id := range spanIDs {
|
||||
ids[i] = id
|
||||
}
|
||||
sb.Where(
|
||||
sb.E("trace_id", traceID),
|
||||
sb.In("span_id", ids...),
|
||||
sb.GE("ts_bucket_start", summary.Start.Unix()-1800),
|
||||
sb.LE("ts_bucket_start", summary.End.Unix()),
|
||||
)
|
||||
sb.OrderByAsc("timestamp")
|
||||
sb.OrderByAsc("name")
|
||||
query, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
var spans []spantypes.StorableSpan
|
||||
if err := s.telemetryStore.ClickhouseDB().Select(ctx, &spans, query, args...); err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "error querying trace spans by IDs")
|
||||
}
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
@@ -10,11 +10,9 @@ import (
|
||||
// Handler exposes HTTP handlers for trace detail APIs.
|
||||
type Handler interface {
|
||||
GetWaterfall(http.ResponseWriter, *http.Request)
|
||||
GetWaterfallV4(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
// Module defines the business logic for trace detail operations.
|
||||
type Module interface {
|
||||
GetWaterfall(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error)
|
||||
GetWaterfallV4(ctx context.Context, traceID string, req *spantypes.PostableWaterfall) (*spantypes.GettableWaterfallTrace, error)
|
||||
}
|
||||
|
||||
@@ -1770,15 +1770,6 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
Route: "",
|
||||
})
|
||||
|
||||
useDashboardV2 := aH.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureUseDashboardV2, evalCtx)
|
||||
featureSet = append(featureSet, &licensetypes.Feature{
|
||||
Name: valuer.NewString(flagger.FeatureUseDashboardV2.String()),
|
||||
Active: useDashboardV2,
|
||||
Usage: 0,
|
||||
UsageLimit: -1,
|
||||
Route: "",
|
||||
})
|
||||
|
||||
if constants.IsDotMetricsEnabled {
|
||||
for idx, feature := range featureSet {
|
||||
if feature.Name == licensetypes.DotMetricsEnabled {
|
||||
|
||||
@@ -206,7 +206,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddIntegrationDashboardFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddSourceToDashboardFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewMigrateCloudIntegrationDashboardsFactory(sqlstore),
|
||||
sqlmigration.NewAddScopeToPlannedMaintenanceFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addScopeToPlannedMaintenance struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
func NewAddScopeToPlannedMaintenanceFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("add_scope_to_planned"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addScopeToPlannedMaintenance{
|
||||
sqlstore: sqlstore,
|
||||
sqlschema: sqlschema,
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (migration *addScopeToPlannedMaintenance) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *addScopeToPlannedMaintenance) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
table, _, err := migration.sqlschema.GetTable(ctx, "planned_maintenance")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
column := &sqlschema.Column{
|
||||
Name: sqlschema.ColumnName("scope"),
|
||||
DataType: sqlschema.DataTypeText,
|
||||
Nullable: true,
|
||||
}
|
||||
|
||||
sqls := migration.sqlschema.Operator().AddColumn(table, nil, column, nil)
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addScopeToPlannedMaintenance) Down(ctx context.Context, db *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/expr-lang/expr"
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
var ErrCodeInvalidScopeExpression = errors.MustNewCode("invalid_scope_expression")
|
||||
|
||||
// ConvertLabelSetToEnv converts a label set into a map suitable for use as an
|
||||
// expr environment. Dotted keys (e.g. "kubernetes.node") are expanded into
|
||||
// nested maps so that expr can resolve them without panicking. When a dotted
|
||||
// path conflicts with a plain key, the nested structure takes precedence.
|
||||
//
|
||||
// The second return value reports whether such a prefix conflict was detected
|
||||
// (a plain key collided with a nested map, or a nested path overwrote a plain
|
||||
// leaf).
|
||||
func ConvertLabelSetToEnv(lset model.LabelSet) (map[string]any, bool) {
|
||||
env := map[string]any{}
|
||||
conflict := false
|
||||
for lk, lv := range lset {
|
||||
key := strings.TrimSpace(string(lk))
|
||||
value := string(lv)
|
||||
if strings.Contains(key, ".") {
|
||||
parts := strings.Split(key, ".")
|
||||
current := env
|
||||
for i, raw := range parts {
|
||||
part := strings.TrimSpace(raw)
|
||||
if i == len(parts)-1 {
|
||||
// Last segment: if a nested map already exists here, a
|
||||
// deeper path has been processed first — keep it and flag.
|
||||
if _, isMap := current[part].(map[string]any); isMap {
|
||||
conflict = true
|
||||
break
|
||||
}
|
||||
current[part] = value
|
||||
break
|
||||
}
|
||||
if nextMap, ok := current[part].(map[string]any); ok {
|
||||
current = nextMap
|
||||
} else {
|
||||
// Intermediate segment hit a plain leaf — overwrite with a
|
||||
// map so the deeper path can be materialised, and flag.
|
||||
if _, exists := current[part]; exists {
|
||||
conflict = true
|
||||
}
|
||||
newMap := map[string]any{}
|
||||
current[part] = newMap
|
||||
current = newMap
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Plain key collides with an already-built nested map — keep the map
|
||||
// (nested wins) and flag.
|
||||
if _, isMap := env[key].(map[string]any); isMap {
|
||||
conflict = true
|
||||
continue
|
||||
}
|
||||
env[key] = value
|
||||
}
|
||||
return env, conflict
|
||||
}
|
||||
|
||||
// EvalScopeExpression compiles and runs the expression against the provided
|
||||
// labels. It returns (result, error). Callers should log the error and
|
||||
// decide how to handle a failed evaluation (the maintenance muter treats a
|
||||
// failure as "don't skip" so alerts pass through).
|
||||
func EvalScopeExpression(expression string, lset model.LabelSet) (bool, error) {
|
||||
env, _ := ConvertLabelSetToEnv(lset)
|
||||
program, err := expr.Compile(expression, expr.Env(env), expr.AllowUndefinedVariables())
|
||||
if err != nil {
|
||||
return false, errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeInvalidScopeExpression, "compile scope expression %q", expression)
|
||||
}
|
||||
output, err := expr.Run(program, env)
|
||||
if err != nil {
|
||||
return false, errors.Wrapf(err, errors.TypeInternal, ErrCodeInvalidScopeExpression, "run scope expression %q", expression)
|
||||
}
|
||||
result, ok := output.(bool)
|
||||
if !ok {
|
||||
return false, errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidScopeExpression, "scope expression %q returned non-bool value %T (%v)", expression, output, output)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/common/model"
|
||||
)
|
||||
|
||||
func TestEvalScopeExpression(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
expression string
|
||||
lset model.LabelSet
|
||||
want bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "equality match",
|
||||
expression: `env = "production"`,
|
||||
lset: model.LabelSet{"env": "production"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "equality no match",
|
||||
expression: `env = "production"`,
|
||||
lset: model.LabelSet{"env": "staging"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "inequality match",
|
||||
expression: `env != "production"`,
|
||||
lset: model.LabelSet{"env": "staging"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "AND - both match",
|
||||
expression: `env = "production" AND service = "api"`,
|
||||
lset: model.LabelSet{"env": "production", "service": "api"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "AND - partial match",
|
||||
expression: `env = "production" AND service = "api"`,
|
||||
lset: model.LabelSet{"env": "production", "service": "worker"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "OR - first matches",
|
||||
expression: `env = "production" OR env = "staging"`,
|
||||
lset: model.LabelSet{"env": "production"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "OR - second matches",
|
||||
expression: `env = "production" OR env = "staging"`,
|
||||
lset: model.LabelSet{"env": "staging"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "OR - none match",
|
||||
expression: `env = "production" OR env = "staging"`,
|
||||
lset: model.LabelSet{"env": "development"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "undefined label returns false",
|
||||
expression: `env = "production"`,
|
||||
lset: model.LabelSet{"service": "api"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "in list - present",
|
||||
expression: `env in ["production", "staging"]`,
|
||||
lset: model.LabelSet{"env": "production"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "in list - absent",
|
||||
expression: `env in ["production", "staging"]`,
|
||||
lset: model.LabelSet{"env": "development"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "invalid expression returns error",
|
||||
expression: `env =`,
|
||||
lset: model.LabelSet{"env": "production"},
|
||||
want: false,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "non-bool expression returns error",
|
||||
expression: `env`,
|
||||
lset: model.LabelSet{"env": "production"},
|
||||
want: false,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, err := EvalScopeExpression(c.expression, c.lset)
|
||||
if (err != nil) != c.wantErr {
|
||||
t.Errorf("EvalScopeExpression(%q, %v) error = %v, wantErr %v", c.expression, c.lset, err, c.wantErr)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("EvalScopeExpression(%q, %v) = %v, want %v", c.expression, c.lset, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertLabelSetToEnv(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
lset model.LabelSet
|
||||
expected map[string]interface{}
|
||||
wantConflict bool
|
||||
}{
|
||||
{
|
||||
name: "simple keys",
|
||||
lset: model.LabelSet{"key1": "value1", "key2": "value2"},
|
||||
expected: map[string]interface{}{"key1": "value1", "key2": "value2"},
|
||||
},
|
||||
{
|
||||
name: "dotted keys become nested maps",
|
||||
lset: model.LabelSet{"foo.bar": "value1", "foo.baz": "value2"},
|
||||
expected: map[string]interface{}{
|
||||
"foo": map[string]interface{}{"bar": "value1", "baz": "value2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deeper dotted key wins over shallow dotted key",
|
||||
lset: model.LabelSet{"foo.bar.baz": "deep", "foo.bar": "shallow"},
|
||||
expected: map[string]interface{}{
|
||||
"foo": map[string]interface{}{
|
||||
"bar": map[string]interface{}{"baz": "deep"},
|
||||
},
|
||||
},
|
||||
wantConflict: true,
|
||||
},
|
||||
{
|
||||
name: "nested structure wins over plain key",
|
||||
lset: model.LabelSet{"foo.bar": "value", "foo": "ignored"},
|
||||
expected: map[string]interface{}{
|
||||
"foo": map[string]interface{}{"bar": "value"},
|
||||
},
|
||||
wantConflict: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, gotConflict := ConvertLabelSetToEnv(c.lset)
|
||||
if !reflect.DeepEqual(got, c.expected) {
|
||||
t.Errorf("ConvertLabelSetToEnv() map = %v, want %v", got, c.expected)
|
||||
}
|
||||
if gotConflict != c.wantConflict {
|
||||
t.Errorf("ConvertLabelSetToEnv() conflict = %v, want %v", gotConflict, c.wantConflict)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,14 @@ import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/expr-lang/expr"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/uptrace/bun"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
var ErrCodeInvalidPlannedMaintenancePayload = errors.MustNewCode("invalid_planned_maintenance_payload")
|
||||
|
||||
const scopeDocUrl = "https://signoz.io/docs/alerts-management/planned-maintenance/#scoping-with-label-expressions"
|
||||
|
||||
type MaintenanceStatus struct {
|
||||
valuer.String
|
||||
}
|
||||
@@ -63,7 +58,6 @@ type StorablePlannedMaintenance struct {
|
||||
Description string `bun:"description,type:text"`
|
||||
Schedule *Schedule `bun:"schedule,type:text,notnull"`
|
||||
OrgID string `bun:"org_id,type:text"`
|
||||
Scope string `bun:"scope,type:text"`
|
||||
}
|
||||
|
||||
type PlannedMaintenance struct {
|
||||
@@ -72,7 +66,6 @@ type PlannedMaintenance struct {
|
||||
Description string `json:"description"`
|
||||
Schedule *Schedule `json:"schedule" required:"true"`
|
||||
RuleIDs []string `json:"alertIds"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
@@ -89,7 +82,6 @@ type PostablePlannedMaintenance struct {
|
||||
Description string `json:"description"`
|
||||
Schedule *Schedule `json:"schedule" required:"true"`
|
||||
AlertIds []string `json:"alertIds"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
func (p *PostablePlannedMaintenance) Validate() error {
|
||||
@@ -124,15 +116,6 @@ func (p *PostablePlannedMaintenance) Validate() error {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "end time cannot be before start time")
|
||||
}
|
||||
}
|
||||
if p.Scope != "" {
|
||||
if _, err := expr.Compile(p.Scope, expr.AllowUndefinedVariables(), expr.AsBool()); err != nil {
|
||||
err := errors.Newf(
|
||||
errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload,
|
||||
"invalid scope: %s", err.Error(),
|
||||
)
|
||||
return err.WithUrl(scopeDocUrl)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -168,7 +151,7 @@ func (m *PlannedMaintenance) HasScheduleRecurrenceBoundsMismatch() bool {
|
||||
(recurrence.EndTime != nil && !recurrence.EndTime.Equal(m.Schedule.EndTime))
|
||||
}
|
||||
|
||||
func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time, lset model.LabelSet) (bool, error) {
|
||||
func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time) bool {
|
||||
// Check if the alert ID is in the maintenance window
|
||||
found := false
|
||||
if len(m.RuleIDs) > 0 {
|
||||
@@ -185,27 +168,9 @@ func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time, lset model
|
||||
}
|
||||
|
||||
if !found {
|
||||
return false, nil
|
||||
return false
|
||||
}
|
||||
|
||||
if !m.IsActive(now) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if m.Scope != "" {
|
||||
result, err := EvalScopeExpression(m.Scope, lset)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !result {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// IsActive reports whether [now] falls inside the maintenance window's schedule.
|
||||
func (m *PlannedMaintenance) IsActive(now time.Time) bool {
|
||||
// If alert is found, we check if it should be skipped based on the schedule
|
||||
loc, err := time.LoadLocation(m.Schedule.Timezone)
|
||||
if err != nil {
|
||||
@@ -336,6 +301,14 @@ func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, rec *Recurrence
|
||||
return currentTime.Sub(candidate) <= rec.Duration.Duration()
|
||||
}
|
||||
|
||||
func (m *PlannedMaintenance) IsActive(now time.Time) bool {
|
||||
ruleID := "maintenance"
|
||||
if len(m.RuleIDs) > 0 {
|
||||
ruleID = (m.RuleIDs)[0]
|
||||
}
|
||||
return m.ShouldSkip(ruleID, now)
|
||||
}
|
||||
|
||||
func (m *PlannedMaintenance) IsUpcoming() bool {
|
||||
loc, err := time.LoadLocation(m.Schedule.Timezone)
|
||||
if err != nil {
|
||||
@@ -416,7 +389,6 @@ func (m PlannedMaintenance) MarshalJSON() ([]byte, error) {
|
||||
Description string `json:"description" db:"description"`
|
||||
Schedule *Schedule `json:"schedule" db:"schedule"`
|
||||
AlertIds []string `json:"alertIds" db:"alert_ids"`
|
||||
Scope string `json:"scope,omitempty" db:"scope"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
CreatedBy string `json:"createdBy" db:"created_by"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
@@ -429,7 +401,6 @@ func (m PlannedMaintenance) MarshalJSON() ([]byte, error) {
|
||||
Description: m.Description,
|
||||
Schedule: m.Schedule,
|
||||
AlertIds: m.RuleIDs,
|
||||
Scope: m.Scope,
|
||||
CreatedAt: m.CreatedAt,
|
||||
CreatedBy: m.CreatedBy,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
@@ -453,7 +424,6 @@ func (m *PlannedMaintenanceWithRules) ToPlannedMaintenance() *PlannedMaintenance
|
||||
Description: m.Description,
|
||||
Schedule: m.Schedule,
|
||||
RuleIDs: ruleIDs,
|
||||
Scope: m.Scope,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
CreatedBy: m.CreatedBy,
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/common/model"
|
||||
)
|
||||
|
||||
// Helper function to create a time pointer.
|
||||
@@ -669,193 +668,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
}
|
||||
|
||||
for idx, c := range cases {
|
||||
result, err := c.maintenance.ShouldSkip(c.name, c.ts, model.LabelSet{})
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
result := c.maintenance.ShouldSkip(c.name, c.ts)
|
||||
if result != c.skip {
|
||||
t.Errorf("skip %v, got %v, case:%d - %s", c.skip, result, idx, c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldSkip_Scope(t *testing.T) {
|
||||
activeSchedule := func() *Schedule {
|
||||
return &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Now().UTC().Add(-time.Hour),
|
||||
EndTime: time.Now().UTC().Add(time.Hour),
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
maintenance *PlannedMaintenance
|
||||
ruleID string
|
||||
ts time.Time
|
||||
lset model.LabelSet
|
||||
skip bool
|
||||
}{
|
||||
{
|
||||
name: "empty scope - no label filtering applied",
|
||||
maintenance: &PlannedMaintenance{Schedule: activeSchedule()},
|
||||
ruleID: "rule-1",
|
||||
ts: now,
|
||||
lset: model.LabelSet{"env": "production"},
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "scope matches labels",
|
||||
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production"`},
|
||||
ruleID: "rule-1",
|
||||
ts: now,
|
||||
lset: model.LabelSet{"env": "production"},
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "scope does not match labels",
|
||||
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production"`},
|
||||
ruleID: "rule-1",
|
||||
ts: now,
|
||||
lset: model.LabelSet{"env": "staging"},
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
name: "AND expression - both conditions match",
|
||||
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" AND service = "api"`},
|
||||
ruleID: "rule-1",
|
||||
ts: now,
|
||||
lset: model.LabelSet{"env": "production", "service": "api"},
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "AND expression - one condition does not match",
|
||||
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" AND service = "api"`},
|
||||
ruleID: "rule-1",
|
||||
ts: now,
|
||||
lset: model.LabelSet{"env": "production", "service": "worker"},
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
name: "OR expression - first alternative matches",
|
||||
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" OR env = "staging"`},
|
||||
ruleID: "rule-1",
|
||||
ts: now,
|
||||
lset: model.LabelSet{"env": "production"},
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "OR expression - second alternative matches",
|
||||
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" OR env = "staging"`},
|
||||
ruleID: "rule-1",
|
||||
ts: now,
|
||||
lset: model.LabelSet{"env": "staging"},
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "OR expression - neither alternative matches",
|
||||
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" OR env = "staging"`},
|
||||
ruleID: "rule-1",
|
||||
ts: now,
|
||||
lset: model.LabelSet{"env": "development"},
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
name: "scope references label absent from lset",
|
||||
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production"`},
|
||||
ruleID: "rule-1",
|
||||
ts: now,
|
||||
lset: model.LabelSet{"service": "api"},
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
name: "in expression - value is in list",
|
||||
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env in ["production", "staging"]`},
|
||||
ruleID: "rule-1",
|
||||
ts: now,
|
||||
lset: model.LabelSet{"env": "staging"},
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "in expression - value not in list",
|
||||
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env in ["production", "staging"]`},
|
||||
ruleID: "rule-1",
|
||||
ts: now,
|
||||
lset: model.LabelSet{"env": "development"},
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
name: "ruleID in list and scope matches - should skip",
|
||||
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), RuleIDs: []string{"rule-1", "rule-2"}, Scope: `env = "production"`},
|
||||
ruleID: "rule-1",
|
||||
ts: now,
|
||||
lset: model.LabelSet{"env": "production"},
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "ruleID not in list and scope matches - ruleID gate prevents skip",
|
||||
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), RuleIDs: []string{"rule-2"}, Scope: `env = "production"`},
|
||||
ruleID: "rule-1",
|
||||
ts: now,
|
||||
lset: model.LabelSet{"env": "production"},
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
name: "ruleID in list but scope does not match - should not skip",
|
||||
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), RuleIDs: []string{"rule-1"}, Scope: `env = "production"`},
|
||||
ruleID: "rule-1",
|
||||
ts: now,
|
||||
lset: model.LabelSet{"env": "staging"},
|
||||
skip: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, err := c.maintenance.ShouldSkip(c.ruleID, c.ts, c.lset)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if got != c.skip {
|
||||
t.Errorf("ShouldSkip() = %v, want %v", got, c.skip)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostablePlannedMaintenance_ValidateScope(t *testing.T) {
|
||||
validSchedule := &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Now().UTC(),
|
||||
EndTime: time.Now().UTC().Add(time.Hour),
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
scope string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "empty scope", scope: "", wantErr: false},
|
||||
{name: "simple equality", scope: `env = "production"`, wantErr: false},
|
||||
{name: "AND expression", scope: `env = "production" AND service = "api"`, wantErr: false},
|
||||
{name: "OR expression", scope: `env = "production" OR env = "staging"`, wantErr: false},
|
||||
{name: "in expression", scope: `env in ["production", "staging"]`, wantErr: false},
|
||||
{name: "incomplete expression", scope: `env =`, wantErr: true},
|
||||
{name: "non-bool expression", scope: `"just a string"`, wantErr: true},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
p := &PostablePlannedMaintenance{
|
||||
Name: "test",
|
||||
Schedule: validSchedule,
|
||||
Scope: c.scope,
|
||||
}
|
||||
err := p.Validate()
|
||||
if (err != nil) != c.wantErr {
|
||||
t.Errorf("Validate() error = %v, wantErr %v", err, c.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@ func (s *Schedule) UnmarshalJSON(data []byte) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// TODO(jatinderjit): if endTime.IsZero() then we should not set the endTime
|
||||
s.EndTime = time.Date(endTime.Year(), endTime.Month(), endTime.Day(), endTime.Hour(), endTime.Minute(), endTime.Second(), endTime.Nanosecond(), loc)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,4 @@ type SpanMapperStore interface {
|
||||
type TraceStore interface {
|
||||
GetTraceSummary(ctx context.Context, traceID string) (*TraceSummary, error)
|
||||
GetTraceSpans(ctx context.Context, traceID string, summary *TraceSummary) ([]StorableSpan, error)
|
||||
GetMinimalSpans(ctx context.Context, traceID string, summary *TraceSummary) ([]MinimalSpan, error)
|
||||
GetTraceSpansByIDs(ctx context.Context, traceID string, summary *TraceSummary, spanIDs []string) ([]StorableSpan, error)
|
||||
}
|
||||
|
||||
@@ -132,31 +132,6 @@ type StorableSpan struct {
|
||||
ResponseStatusCode string `ch:"response_status_code"`
|
||||
}
|
||||
|
||||
// MinimalSpan with only the fields needed to build the parent-child tree.
|
||||
type MinimalSpan struct {
|
||||
SpanID string `ch:"span_id"`
|
||||
ParentSpanID string `ch:"parent_span_id"`
|
||||
StartTime time.Time `ch:"timestamp"`
|
||||
DurationNano uint64 `ch:"duration_nano"`
|
||||
HasError bool `ch:"has_error"`
|
||||
ServiceName string `ch:"resource_string_service$$name"`
|
||||
}
|
||||
|
||||
func (item *MinimalSpan) ToWaterfallSpan() *WaterfallSpan {
|
||||
return &WaterfallSpan{
|
||||
SpanID: item.SpanID,
|
||||
ParentSpanID: item.ParentSpanID,
|
||||
TimeUnix: uint64(item.StartTime.UnixNano()),
|
||||
DurationNano: item.DurationNano,
|
||||
HasError: item.HasError,
|
||||
ServiceName: item.ServiceName,
|
||||
Resource: map[string]string{"service.name": item.ServiceName},
|
||||
Children: make([]*WaterfallSpan, 0),
|
||||
Attributes: make(map[string]any),
|
||||
Events: make([]Event, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// NewMissingWaterfallSpan creates a synthetic placeholder span for a parent that has no recorded data.
|
||||
func NewMissingWaterfallSpan(spanID, traceID string, timeUnixNano, durationNano uint64) *WaterfallSpan {
|
||||
return &WaterfallSpan{
|
||||
@@ -322,24 +297,6 @@ func (item *StorableSpan) ToWaterfallSpan() *WaterfallSpan {
|
||||
}
|
||||
}
|
||||
|
||||
func EnrichSelectedSpans(window []*WaterfallSpan, fullSpans []StorableSpan) {
|
||||
fullByID := make(map[string]*StorableSpan, len(fullSpans))
|
||||
for i := range fullSpans {
|
||||
fullByID[fullSpans[i].SpanID] = &fullSpans[i]
|
||||
}
|
||||
for i, ws := range window {
|
||||
full, ok := fullByID[ws.SpanID]
|
||||
if !ok {
|
||||
continue // synthesized MissingSpan — keep empty shell
|
||||
}
|
||||
newWS := full.ToWaterfallSpan()
|
||||
newWS.Level = ws.Level
|
||||
newWS.HasChildren = ws.HasChildren
|
||||
newWS.SubTreeNodeCount = ws.SubTreeNodeCount
|
||||
window[i] = newWS
|
||||
}
|
||||
}
|
||||
|
||||
// getSpanIndex returns the index of matched span and -1 for no match.
|
||||
func getSpanIndex(spans []*WaterfallSpan, targetSpanID string) int {
|
||||
for i, s := range spans {
|
||||
|
||||
@@ -62,24 +62,26 @@ func NewWaterfallTrace(
|
||||
}
|
||||
}
|
||||
|
||||
// NewWaterfallTraceFromSpans requires WaterfallSpan nodes with only below fields:
|
||||
// SpanID, ParentSpanID, TimeUnix, DurationNano, HasError, and ServiceName.
|
||||
func NewWaterfallTraceFromSpans(nodes []*WaterfallSpan) *WaterfallTrace {
|
||||
func NewWaterfallTraceFromSpans(spans []StorableSpan) *WaterfallTrace {
|
||||
var (
|
||||
startTime, endTime, totalErrorSpans uint64
|
||||
spanIDToSpanNodeMap = make(map[string]*WaterfallSpan, len(nodes))
|
||||
spanIDToSpanNodeMap = make(map[string]*WaterfallSpan, len(spans))
|
||||
traceRoots []*WaterfallSpan
|
||||
hasMissingSpans bool
|
||||
)
|
||||
|
||||
for _, span := range nodes {
|
||||
if startTime == 0 || span.TimeUnix < startTime {
|
||||
startTime = span.TimeUnix
|
||||
for _, item := range spans {
|
||||
span := item.ToWaterfallSpan()
|
||||
startTimeUnixNano := uint64(item.StartTime.UnixNano())
|
||||
if startTime == 0 || startTimeUnixNano < startTime {
|
||||
startTime = startTimeUnixNano
|
||||
}
|
||||
endTime = max(endTime, span.TimeUnix+span.DurationNano)
|
||||
endTime = max(endTime, startTimeUnixNano+span.DurationNano)
|
||||
|
||||
if span.HasError {
|
||||
totalErrorSpans++
|
||||
}
|
||||
|
||||
spanIDToSpanNodeMap[span.SpanID] = span
|
||||
}
|
||||
|
||||
@@ -114,7 +116,7 @@ func NewWaterfallTraceFromSpans(nodes []*WaterfallSpan) *WaterfallTrace {
|
||||
return NewWaterfallTrace(
|
||||
startTime,
|
||||
endTime,
|
||||
uint64(len(nodes)),
|
||||
uint64(len(spans)),
|
||||
totalErrorSpans,
|
||||
spanIDToSpanNodeMap,
|
||||
traceRoots,
|
||||
|
||||
Reference in New Issue
Block a user