Compare commits

..

11 Commits

Author SHA1 Message Date
Nikhil Soni
51da3e0d72 refactor: use sqlbuider for queries 2026-05-25 23:49:10 +05:30
Nikhil Soni
a004ba8d06 chore: update openapi specs 2026-05-25 21:07:43 +05:30
Nikhil Soni
e41b46bbb4 refactor: move conversion logic to types 2026-05-25 21:05:53 +05:30
Nikhil Soni
15ac97f49f chore: avoid unnecessary diffs 2026-05-25 21:05:53 +05:30
Nikhil Soni
f6971c8f9f refactor: keep the waterfall changes in new api version
This is to avoid the contract change in existing v3
2026-05-25 21:05:53 +05:30
Nikhil Soni
72c65d7dd9 feat: break down waterfall module to handle large spans
Handling large traces in two steps to avoid high
memory allocation
2026-05-25 21:05:53 +05:30
Nikhil Soni
7a88dbabdd feat: add store methods for minimal trace fetch 2026-05-25 21:05:53 +05:30
Naman Verma
bb471848cc chore: feature flag for dashboard v2 (#11339)
* chore: feature flag for dashboard v2

* fix: fix alignment

* chore: add flag to v1 api as well
2026-05-25 14:08:04 +00:00
SagarRajput-7
bd55e70882 feat(boot-settings): runtime enable/disable control for PostHog and Appcues (#11416)
* feat(boot-settings): move SDK config from build-time env vars to runtime boot data injection

* feat(boot-settings): scope runtime injection to posthog/appcues enabled flags only

* feat(boot-settings): refactor code

* feat(boot-settings): refactor code

* feat(boot-settings): use generated WebSettings types for BE↔FE contract
2026-05-25 13:49:55 +00:00
Jatinderjit Singh
6cf22e98dd feat(planned-downtime): scope maintenance windows to label expressions (#11186)
* add maintenanceMuteStage to move planned maintenance to alertmanager

Rules previously skipped rule.Eval() entirely during maintenance windows.
This change moves suppression to MaintenanceMuter, injected as a Stage
in the alertmanager notification pipeline. Now rules always evaluate and
everys suppression is handled by alertmanager.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: wrap routing pipeline once instead of per-route injection

Replace the per-route-entry loop with a single MultiStage wrap so
maintenance suppression runs once per dispatch group before routing.

* refactor: move maintenance mute stage into custom pipelineBuilder

Copy notify.PipelineBuilder locally so we can inject mms between the
silence stage and the receiver stage (GossipSettle → Inhibit →
TimeActive → TimeMute → Silence → mms → Receiver), matching the
correct suppression order the team requires.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: add license header to pipeline_builder.go

Copied code originates from Apache-2.0 licensed Prometheus Alertmanager;
add dual copyright + SPDX identifier following the repo's convention.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: replace SPDX tag with full Apache 2.0 license boilerplate

The full license text is unambiguously compliant with Apache 2.0 Section 4(a),
which requires giving recipients "a copy of this License".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: pass MaintenanceMuter directly to pipelineBuilder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: remove dead orgID param from task constructors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* rename buildReceiverStage -> createReceiverStage

* refactor: replace maintenanceMuteStage with notify.NewMuteStage

MaintenanceMuter already satisfies types.Muter, and pipelineBuilder has
its own pb.metrics, so the hand-rolled maintenanceMuteStage wrapper is
redundant. Use notify.NewMuteStage(pb.muter, pb.metrics) directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: hoist MuteStage construction out of the receiver loop

MuteStage holds no per-receiver state, so one instance shared across
all receivers is sufficient — matching how is/ss are handled upstream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: always initialize maintenanceStore; remove nil guards

Tests now use a real sqlrulestore-backed MaintenanceMuter instead of
passing nil. With nil no longer a valid input, remove the nil guards
in server.go and pipeline_builder.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: move MaintenanceMuter to Server and pass it to pipelineBuilder.New

- Remove muter from pipelineBuilder struct and newPipelineBuilder();
  pass it as a parameter to New() instead, consistent with inhibitor/silencer
- Store muter on Server so GetAlerts can call Mutes() alongside the
  inhibitor and silencer, ensuring maintenance-suppressed alerts show
  the correct muted status in API responses

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* remove redundant MemMarker wrapper

* feat: surface maintenance-suppressed alerts via mutedBy in GetAlerts

Alerts suppressed by an active maintenance window were being correctly
muted in the notification pipeline but appeared as state=active in the
v2 GetAlerts response, since MaintenanceMuter.Mutes had no marker
side-effect (unlike inhibitor/silencer).

Add MaintenanceMuter.MutedBy returning the matching window IDs, and
plumb a mutedByFunc callback through NewGettableAlertsFromAlertProvider
into AlertToOpenAPIAlert. The upstream v2 API forces state=suppressed
when mutedBy is non-empty, so the frontend's existing state-based
rendering picks it up without further changes.

Use the dedicated mutedBy field rather than SilencedBy to avoid
violating the "complete set of silence IDs" contract that anything
querying silences by ID would rely on.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* code cleanup

* refactor: move maintenance (planned downtime) to alertmanager packages

Types move from pkg/types/ruletypes/ to pkg/types/alertmanagertypes/:
- maintenance.go, recurrence.go, schedule.go (+ tests)

Store impl moves from pkg/ruler/rulestore/sqlrulestore/ to
pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore/.

Maintenance windows mute alerts, so they belong with alertmanager
rather than the rule types.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test: add unit tests for MaintenanceMuter

Covers Mutes/MutedBy semantics (empty label, rule match, empty-RuleIDs
matches-all, future windows, multi-window) and the result cache
(single-fetch within TTL, stale-cache fallback on store error,
re-fetch after expiry, concurrency safety).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Update schema changes

* Re-add marker

* fix NewMaintenanceStore in tests

* Go lint fixes

* test: use mockery-generated mock for MaintenanceStore in muter tests

Replace hand-written fakeMaintenanceStore with a mockery-generated
MockMaintenanceStore, consistent with the alertmanagertest pattern.
Also adds MaintenanceStore to .mockery.yml so the mock stays in sync.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: regenerate mocks via make gen-mocks

Picks up new MockHandler for the Handler interface in pkg/alertmanager
and regenerates MockMaintenanceStore with canonical mockery formatting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* cleanup test

* test: add e2e muting tests for maintenance window behaviour

* Add label expression support to planned downtime

Alert instances can now be scoped by label expression
(e.g. env == "prod"), scoping suppression below the rule level.
A window with no rule IDs and a label expression silences any
alert whose labels match, regardless of which rule fired it;
when rule IDs are also present, the expression is evaluated only
within the matched rules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Remove redundant || undefined from labelExpression assignment

* Move label expression evaluation into ShouldSkip

ShouldSkip now owns all three suppression checks in sequence:
rule ID match → schedule active → label expression.
IsActive passes nil labels so the expression check is skipped
(no instance labels available for UI status).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* remove redundant LabelSet->map conversion

* implement Down migration to drop label_expression column

* fix lset type and update openapi spec

* fix(tests): resolve envprovider env isolation, factory name length, and ShouldSkip signature

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* remove unused function `evaluateLabelExpression`

* chore: rename label expression to scope

* test(maintenance): add tests for scope label expression filtering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(e2e): verify scope-based maintenance muting in alertmanager flow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Use `AND` instead of `&&`

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>

* fix(maintenance): consolidate label-set-to-env conversion to avoid expr panic

Move ConvertLabelSetToEnv to alertmanagertypes so both the maintenance
scope evaluator and the route-policy evaluator share one implementation.
Dotted label keys (e.g. kubernetes.node) are expanded into nested maps,
preventing the expr-lang panic that occurs when one key is a prefix of
another.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(planned-downtime): use clickable learn more link in scope tooltip

* chore(sqlmigration): renumber add_scope_to_planned_maintenance to 086

078 collided with add_sa_managed_role_txn; bumped to the next free number
and reordered registration after add_source_to_dashboard (085).

* refactor(maintenance): extract scope expression eval and surface errors

- Move ConvertLabelSetToEnv and EvalScopeExpression into expression.go
  with companion tests in expression_test.go.
- EvalScopeExpression now returns (bool, error) instead of swallowing
  compile/run failures and non-bool outputs; ShouldSkip logs the error
  via slog.Default() and falls back to not suppressing (safety-first).
- Update test fixtures to the SQL-style operator form (`=`, AND, OR)
  matching the placeholder and reviewer suggestions.

* chore: use `=` instead of `==` in expressions

* fix(maintenance): satisfy forbidigo/sloglint in scope eval

- Replace fmt.Errorf with pkg/errors Wrapf/Newf using a new
  ErrCodeInvalidScopeExpression code.
- Use slog ErrorContext (with context.Background()) instead of Error to
  satisfy sloglint.

* perf(maintenance): fold prefix-conflict detection into ConvertLabelSetToEnv

ConvertLabelSetToEnv now returns (env, conflict). The rulebased provider
drops its O(n^2) pre-scan and logs based on the flag, restoring the
previous O(n*d) cost while keeping the shared helper.

* chore: add docs URL for invalid scope

* refactor: don't log in types package

* remove down migration

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-05-25 13:40:06 +00:00
Ashwin Bhatkal
39957d322f fix(planned-downtime): remove unused timezone dep from useMemo hooks (#11448)
The startTimeText and endTimeText useMemo hooks did not reference
timezone in their callback bodies, so including it in the dependency
arrays caused unnecessary recomputations whenever the timezone form
field changed.
2026-05-25 13:24:03 +00:00
75 changed files with 1864 additions and 860 deletions

View File

@@ -129,6 +129,8 @@ components:
type: string
schedule:
$ref: '#/components/schemas/AlertmanagertypesSchedule'
scope:
type: string
status:
$ref: '#/components/schemas/AlertmanagertypesMaintenanceStatus'
updatedAt:
@@ -272,6 +274,8 @@ components:
type: string
schedule:
$ref: '#/components/schemas/AlertmanagertypesSchedule'
scope:
type: string
required:
- name
- schedule
@@ -18944,6 +18948,77 @@ 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

View File

@@ -89,6 +89,15 @@ 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 {

View File

@@ -94,6 +94,19 @@
}
})();
</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>
@@ -135,7 +148,10 @@
</script>
<script>
var APPCUES_APP_ID = '<%- APPCUES_APP_ID %>';
if (APPCUES_APP_ID) {
var appcuesSettings =
((window.signozBootData || {}).settings || {}).appcues || {};
var appcuesEnabled = appcuesSettings.enabled !== false;
if (APPCUES_APP_ID && appcuesEnabled) {
(function (d, t) {
var a = d.createElement(t);
a.async = 1;

View File

@@ -17,8 +17,6 @@ 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.',
};

View File

@@ -35,6 +35,7 @@ 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';
@@ -332,7 +333,7 @@ function App(): JSX.Element {
useEffect(() => {
if (isCloudUser || isEnterpriseSelfHostedUser) {
if (process.env.POSTHOG_KEY) {
if (bootSettings.posthog.enabled && 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

View File

@@ -225,6 +225,10 @@ export interface AlertmanagertypesPlannedMaintenanceDTO {
*/
name: string;
schedule: AlertmanagertypesScheduleDTO;
/**
* @type string
*/
scope?: string;
status: AlertmanagertypesMaintenanceStatusDTO;
/**
* @type string
@@ -1714,6 +1718,10 @@ export interface AlertmanagertypesPostablePlannedMaintenanceDTO {
*/
name: string;
schedule: AlertmanagertypesScheduleDTO;
/**
* @type string
*/
scope?: string;
}
export interface AlertmanagertypesPostableRoutePolicyDTO {
@@ -9224,6 +9232,17 @@ export type GetWaterfall200 = {
status: string;
};
export type GetWaterfallV4PathParameters = {
traceID: string;
};
export type GetWaterfallV4200 = {
data: SpantypesGettableWaterfallTraceDTO;
/**
* @type string
*/
status: string;
};
export type QueryRangeV5200 = {
data: Querybuildertypesv5QueryRangeResponseDTO;
/**

View File

@@ -14,6 +14,8 @@ import type {
import type {
GetWaterfall200,
GetWaterfallPathParameters,
GetWaterfallV4200,
GetWaterfallV4PathParameters,
RenderErrorResponseDTO,
SpantypesPostableWaterfallDTO,
} from '../sigNoz.schemas';
@@ -120,3 +122,102 @@ 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));
};

View File

@@ -0,0 +1,7 @@
.dropdown-button {
color: var(--l1-foreground);
}
.dropdown-icon {
font-size: 1.2rem;
}

View File

@@ -0,0 +1,51 @@
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;

View File

@@ -1,7 +1,15 @@
import { useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Button, Col, Popover, Row, Select, Space } from 'antd';
import { DropdownMenuSimple, type MenuProps } from '@signozhq/ui/dropdown-menu';
import {
Button,
Col,
Dropdown,
MenuProps,
Popover,
Row,
Select,
Space,
} from 'antd';
import { Typography } from '@signozhq/ui/typography';
import axios from 'axios';
import TextToolTip from 'components/TextToolTip';
@@ -233,9 +241,9 @@ function ExplorerCard({
</Popover>
<Share2 onClick={onCopyUrlHandler} size="md" />
{viewKey && (
<DropdownMenuSimple menu={moreOptionMenu}>
<Button type="text" size="small" icon={<Ellipsis size="md" />} />
</DropdownMenuSimple>
<Dropdown trigger={['click']} menu={moreOptionMenu}>
<Ellipsis size="md" />
</Dropdown>
)}
</Space>
</OffSetCol>

View File

@@ -6,7 +6,7 @@ import {
useMemo,
useState,
} from 'react';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import { Dropdown } from 'antd';
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 && (
<DropdownMenuSimple
<Dropdown
className="query-actions-dropdown"
menu={{
items: [
@@ -217,10 +217,10 @@ export const QueryV2 = forwardRef(function QueryV2(
: []),
],
}}
align="end"
placement="bottomRight"
>
<Ellipsis size={16} />
</DropdownMenuSimple>
</Dropdown>
)}
</div>
</div>

View File

@@ -4,14 +4,14 @@ import type {
TableColumnsType as ColumnsType,
TableColumnType as ColumnType,
} from 'antd';
import { Button, Flex } from 'antd';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { Button, Dropdown, Flex, MenuProps } from 'antd';
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,9 +84,8 @@ function DynamicColumnTable({
);
};
const items: MenuItem[] =
const items: MenuProps['items'] =
dynamicColumns?.map((column, index) => ({
key: String(index),
label: (
<div
className="dynamicColumnsTable-items"
@@ -100,6 +99,8 @@ function DynamicColumnTable({
/>
</div>
),
key: index,
type: 'checkbox',
})) || [];
// Get current page from URL or default to 1
@@ -128,14 +129,18 @@ function DynamicColumnTable({
<Flex justify="flex-end" align="center" gap={8}>
{facingIssueBtn && <LaunchChatSupport {...facingIssueBtn} />}
{dynamicColumns && (
<DropdownMenuSimple menu={{ items }}>
<Dropdown
getPopupContainer={popupContainer}
menu={{ items }}
trigger={['click']}
>
<Button
className="dynamicColumnTable-button filter-btn"
size="middle"
icon={<SlidersHorizontal size={14} />}
data-testid="additional-filters-button"
/>
</DropdownMenuSimple>
</Dropdown>
)}
</Flex>

View File

@@ -1,7 +1,6 @@
import { Dispatch, SetStateAction, useCallback, useMemo } from 'react';
import { ChevronDown, Globe } from '@signozhq/icons';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import { Button } from 'antd';
import { Button, Dropdown } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import TimeItems, {
timePreferance,
@@ -28,17 +27,20 @@ function TimePreference({
const menu = useMemo(
() => ({
items: menuItems.map((item) => ({
...item,
onClick: timeMenuItemOnChangeHandler,
})),
items: menuItems,
onClick: timeMenuItemOnChangeHandler,
}),
[timeMenuItemOnChangeHandler],
);
return (
<DropdownMenuSimple menu={menu} className="time-selection-menu">
<Button className="time-selection-target">
<Dropdown
menu={menu}
rootClassName="time-selection-menu"
className="time-selection-target"
trigger={['click']}
>
<Button>
<div className="button-selected-text">
<Globe size={14} />
<Typography.Text className="selected-value">
@@ -47,7 +49,7 @@ function TimePreference({
</div>
<ChevronDown size="md" />
</Button>
</DropdownMenuSimple>
</Dropdown>
);
}

View File

@@ -11,4 +11,5 @@ 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',
}

View File

@@ -11,13 +11,8 @@ 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 { Skeleton } from 'antd';
import { Dropdown, Skeleton } from 'antd';
import {
RenderErrorResponseDTO,
ZeustypesHostDTO,
@@ -205,15 +200,10 @@ export default function CustomDomainSettings(): JSX.Element {
!workspaceName ? 'workspace-name-hidden' : ''
}`}
>
<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">
<Dropdown
trigger={['click']}
disabled={isFetchingHosts}
dropdownRender={(): JSX.Element => (
<div className="workspace-url-dropdown">
<span className="workspace-url-dropdown-header">
All Workspace URLs
@@ -246,8 +236,14 @@ export default function CustomDomainSettings(): JSX.Element {
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
)}
>
<Button variant="link" color="none">
<Link2 size={12} />
<span>{stripProtocol(activeHost?.url ?? '')}</span>
<ChevronDown size={12} />
</Button>
</Dropdown>
<span className="custom-domain-card-meta-timezone">
<Clock size={11} />
{timezone.offset}

View File

@@ -1,5 +1,4 @@
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';
@@ -143,13 +142,12 @@ 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
await user.click(
fireEvent.click(
screen.getByRole('button', { name: /custom-host\.test\.cloud/i }),
);

View File

@@ -1,7 +1,6 @@
import { useState } from 'react';
import { CloudDownload } from '@signozhq/icons';
import { DropdownMenuSimple, type MenuProps } from '@signozhq/ui/dropdown-menu';
import { Button, Flex } from 'antd';
import { Button, Dropdown, MenuProps, Flex } from 'antd';
import { unparse } from 'papaparse';
import { DownloadProps } from './Download.types';
@@ -68,7 +67,7 @@ function Download({ data, isLoading, fileName }: DownloadProps): JSX.Element {
};
return (
<DropdownMenuSimple menu={menu}>
<Dropdown menu={menu} trigger={['click']}>
<Button
className="download-button"
loading={isLoading || isDownloading}
@@ -80,7 +79,7 @@ function Download({ data, isLoading, fileName }: DownloadProps): JSX.Element {
Download
</Flex>
</Button>
</DropdownMenuSimple>
</Dropdown>
);
}

View File

@@ -1,4 +1,8 @@
import { Col, Input as InputComponent } from 'antd';
import {
Col,
Dropdown as DropDownComponent,
Input as InputComponent,
} from 'antd';
import { Typography as TypographyComponent } from '@signozhq/ui/typography';
import styled from 'styled-components';
@@ -30,6 +34,16 @@ 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;

View File

@@ -1,7 +1,6 @@
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { AppProvider } from 'providers/App/App';
@@ -177,7 +176,6 @@ 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>
@@ -210,7 +208,7 @@ describe('WidgetGraphComponent', () => {
expect(skeleton).toBeInTheDocument();
const moreOptionsButton = getByTestId('widget-header-options');
await user.click(moreOptionsButton);
fireEvent.mouseEnter(moreOptionsButton);
const menu = await findByRole('menu');
expect(menu).toBeInTheDocument();

View File

@@ -54,17 +54,6 @@
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;
}

View File

@@ -467,7 +467,6 @@ 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}
@@ -484,7 +483,7 @@ describe('WidgetHeader', () => {
const moreOptionsIcon = await screen.findByTestId(WIDGET_HEADER_OPTIONS_ID);
expect(moreOptionsIcon).toBeInTheDocument();
await user.click(moreOptionsIcon);
await userEvent.hover(moreOptionsIcon);
await screen.findByText(CREATE_ALERTS_TEXT);
@@ -495,7 +494,6 @@ 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',
@@ -519,12 +517,12 @@ describe('WidgetHeader', () => {
expect(useCreateAlerts).toHaveBeenCalledWith(mockWidget, 'dashboardView');
const moreOptionsIcon = await screen.findByTestId(WIDGET_HEADER_OPTIONS_ID);
await user.click(moreOptionsIcon);
await userEvent.hover(moreOptionsIcon);
const createAlertsMenuItem = await screen.findByText(CREATE_ALERTS_TEXT);
// Verify the menu item is clickable by actually clicking it
await user.click(createAlertsMenuItem);
await userEvent.click(createAlertsMenuItem);
expect(mockCreateAlertsHandler).toHaveBeenCalledTimes(1);
});
});

View File

@@ -15,8 +15,7 @@ import {
X,
} from '@signozhq/icons';
import { Color } from '@signozhq/design-tokens';
import { Button, Input, Tooltip } from 'antd';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import { Button, Dropdown, Input, MenuProps, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
import ErrorPopover from 'components/ErrorPopover/ErrorPopover';
@@ -129,7 +128,7 @@ function WidgetHeader({
],
);
const onMenuItemSelectHandler = useCallback(
const onMenuItemSelectHandler: MenuProps['onClick'] = useCallback(
({ key }: { key: string }): void => {
if (isTWidgetOptions(key)) {
const functionToCall = keyMethodMapping[key];
@@ -189,8 +188,18 @@ function WidgetHeader({
{
key: MenuItemKeys.CreateAlerts,
icon: <Bell size="md" />,
label: MENUITEM_KEYS_VS_LABELS[MenuItemKeys.CreateAlerts],
rightIcon: <SquareArrowOutUpRight size="lg" />,
label: (
<span
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
}}
>
{MENUITEM_KEYS_VS_LABELS[MenuItemKeys.CreateAlerts]}
<SquareArrowOutUpRight size={10} />
</span>
),
isVisible: headerMenuList?.includes(MenuItemKeys.CreateAlerts) || false,
disabled: false,
},
@@ -212,10 +221,8 @@ function WidgetHeader({
const menu = useMemo(
() => ({
items: updatedMenuList.map((item) => ({
...item,
onClick: onMenuItemSelectHandler,
})),
items: updatedMenuList,
onClick: onMenuItemSelectHandler,
}),
[updatedMenuList, onMenuItemSelectHandler],
);
@@ -314,12 +321,7 @@ function WidgetHeader({
/>
)}
{menu && Array.isArray(menu.items) && menu.items.length > 0 && (
<DropdownMenuSimple
menu={menu}
side="bottom"
align="end"
className="widget-header-dropdown"
>
<Dropdown menu={menu} trigger={['hover']} placement="bottomRight">
<Button
data-testid="widget-header-options"
className={`widget-header-more-options ${
@@ -327,7 +329,7 @@ function WidgetHeader({
}`}
icon={<EllipsisVertical size="md" />}
/>
</DropdownMenuSimple>
</Dropdown>
)}
</div>
</>

View File

@@ -6,7 +6,6 @@ export interface MenuItem {
key: MenuItemKeys;
icon: ReactNode;
label: ReactNode;
rightIcon?: ReactNode;
isVisible: boolean;
disabled: boolean;
danger?: boolean;

View File

@@ -1,9 +1,9 @@
import type { MenuItem as DropdownMenuItem } from '@signozhq/ui/dropdown-menu';
import type { MenuItemType } from 'antd/es/menu/hooks/useItems';
import { MenuItemKeys } from './contants';
import { MenuItem } from './types';
export const generateMenuList = (actions: MenuItem[]): DropdownMenuItem[] =>
export const generateMenuList = (actions: MenuItem[]): MenuItemType[] =>
actions
.filter((action: MenuItem) => action.isVisible)
.map(({ key, icon: Icon, label, disabled, ...rest }) => ({

View File

@@ -3,8 +3,7 @@ import { useTranslation } from 'react-i18next';
import { UseQueryResult } from 'react-query';
import { Button, Flex, Input } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { Ellipsis, Plus } from '@signozhq/icons';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import { Plus } from '@signozhq/icons';
import type { ColumnsType } from 'antd/es/table/interface';
import logEvent from 'api/common/logEvent';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
@@ -16,6 +15,7 @@ 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,67 +323,55 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
dataIndex: 'id',
key: 'action',
width: 10,
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) })
render: (id: RuletypesRuleDTO['id'], record): JSX.Element => (
<div data-testid="alert-actions">
<DropDown
onDropDownItemClick={(item): void =>
alertActionLogEvent(item.key, record)
}
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
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"
style={{ color: 'var(--l1-foreground)' }}
icon={<Ellipsis size={16} />}
/>
</DropdownMenuSimple>
</div>
);
},
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>
),
});
}

View File

@@ -12,11 +12,12 @@ 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,
@@ -552,7 +553,7 @@ function DashboardsList(): JSX.Element {
];
const getCreateDashboardItems = useMemo(() => {
const menuItems: MenuItem[] = [
const menuItems: MenuProps['items'] = [
{
label: (
<div
@@ -710,11 +711,11 @@ function DashboardsList(): JSX.Element {
{createNewDashboard && (
<section className="actions">
<DropdownMenuSimple
className="new-dashboard-menu"
<Dropdown
overlayClassName="new-dashboard-menu"
menu={{ items: getCreateDashboardItems }}
side="bottom"
align="end"
placement="bottomRight"
trigger={['click']}
>
<Button
type="text"
@@ -726,7 +727,7 @@ function DashboardsList(): JSX.Element {
>
New Dashboard
</Button>
</DropdownMenuSimple>
</Dropdown>
<Button
type="text"
className="learn-more"
@@ -755,11 +756,11 @@ function DashboardsList(): JSX.Element {
onChange={handleSearch}
/>
{createNewDashboard && (
<DropdownMenuSimple
className="new-dashboard-menu"
<Dropdown
overlayClassName="new-dashboard-menu"
menu={{ items: getCreateDashboardItems }}
side="bottom"
align="end"
placement="bottomRight"
trigger={['click']}
>
<Button
type="primary"
@@ -772,7 +773,7 @@ function DashboardsList(): JSX.Element {
>
New dashboard
</Button>
</DropdownMenuSimple>
</Dropdown>
)}
</div>

View File

@@ -2,13 +2,7 @@ import { useCallback } from 'react';
import { useCopyToClipboard } from 'react-use';
import { orange } from '@ant-design/colors';
import { Settings } from '@signozhq/icons';
import {
type BaseMenuItem,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@signozhq/ui/dropdown-menu';
import { Dropdown, MenuProps } from 'antd';
import {
negateOperator,
OPERATORS,
@@ -141,38 +135,41 @@ function BodyTitleRenderer({
viewName,
]);
const onClickHandler = (key: string): void => {
const onClickHandler: MenuProps['onClick'] = (props): void => {
const mapper = {
[DROPDOWN_KEY.FILTER_IN]: filterHandler(true),
[DROPDOWN_KEY.FILTER_OUT]: filterHandler(false),
[DROPDOWN_KEY.GROUP_BY]: groupByHandler,
};
const handler = mapper[key];
const handler = mapper[props.key];
if (handler) {
handler();
}
};
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 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 handleNodeClick = useCallback(
(e: React.MouseEvent): void => {
@@ -221,23 +218,15 @@ function BodyTitleRenderer({
}}
onMouseDown={(e): void => e.preventDefault()}
>
<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>
<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>
</span>
)}
{title.toString()}{' '}

View File

@@ -2,8 +2,9 @@ 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';
@@ -94,7 +95,7 @@ function MembersSettings(): JSX.Element {
).length;
const totalCount = allMembers.length;
const filterMenuItems: MenuItem[] = [
const filterMenuItems: MenuProps['items'] = [
{
key: FilterMode.All,
label: (
@@ -170,9 +171,10 @@ function MembersSettings(): JSX.Element {
</div>
<div className="members-settings__controls">
<DropdownMenuSimple
<Dropdown
menu={{ items: filterMenuItems }}
className="members-filter-dropdown"
trigger={['click']}
overlayClassName="members-filter-dropdown"
>
<Button
variant="solid"
@@ -182,7 +184,7 @@ function MembersSettings(): JSX.Element {
<span>{filterLabel}</span>
<ChevronDown size={12} className="members-filter-trigger__chevron" />
</Button>
</DropdownMenuSimple>
</Dropdown>
<div className="members-settings__search">
<Input

View File

@@ -1,5 +1,4 @@
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';
@@ -77,15 +76,14 @@ 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');
await user.click(screen.getByRole('button', { name: /all members/i }));
fireEvent.click(screen.getByRole('button', { name: /all members/i }));
const pendingOption = await screen.findByText(/pending invites/i);
await user.click(pendingOption);
fireEvent.click(pendingOption);
await screen.findByText('charlie@signoz.io');
expect(screen.queryByText('Alice Smith')).not.toBeInTheDocument();

View File

@@ -1,8 +1,7 @@
import { useMemo } from 'react';
import { generatePath } from 'react-router-dom';
import { Color } from '@signozhq/design-tokens';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import { Skeleton } from 'antd';
import { Dropdown, Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import {
useGetMetricAlerts,
@@ -127,11 +126,12 @@ function DashboardsAndAlertsPopover({
return (
<div className="dashboards-and-alerts-popover-container">
{dashboardsPopoverContent && (
<DropdownMenuSimple
<Dropdown
menu={{
items: dashboardsPopoverContent,
}}
align="start"
placement="bottomLeft"
trigger={['click']}
>
<div
className="dashboards-and-alerts-popover dashboards-popover"
@@ -142,14 +142,15 @@ function DashboardsAndAlertsPopover({
{pluralize(dashboards.length, 'dashboard')}
</Typography.Text>
</div>
</DropdownMenuSimple>
</Dropdown>
)}
{alertsPopoverContent && (
<DropdownMenuSimple
<Dropdown
menu={{
items: alertsPopoverContent,
}}
align="start"
placement="bottomLeft"
trigger={['click']}
>
<div
className="dashboards-and-alerts-popover alerts-popover"
@@ -160,7 +161,7 @@ function DashboardsAndAlertsPopover({
{pluralize(alerts.length, 'alert rule')}
</Typography.Text>
</div>
</DropdownMenuSimple>
</Dropdown>
)}
</div>
);

View File

@@ -104,33 +104,38 @@
height: 200px;
background-color: var(--l1-border);
overflow: hidden !important;
.ant-dropdown-menu {
padding: 0;
padding: 4px;
.ant-checkbox-wrapper {
padding: 2px 8px !important;
}
.ant-dropdown-menu-item {
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 */
}
}
}
}

View File

@@ -7,12 +7,7 @@ import {
DropResult,
} from 'react-beautiful-dnd';
import { Color } from '@signozhq/design-tokens';
import { Button, Divider, Input, Tooltip } from 'antd';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@signozhq/ui/dropdown-menu';
import { Button, Divider, Dropdown, Input, MenuProps, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { FieldDataType } from 'api/v5/v5';
import { SOMETHING_WENT_WRONG } from 'constants/api';
@@ -164,12 +159,34 @@ function ExplorerColumnsRenderer({
debouncedSetQuerySearchText(e.target.value);
};
const handleOpenChange = (nextOpen: boolean): void => {
setOpen(nextOpen);
if (nextOpen) {
setSearchText('');
}
};
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 removeSelectedLogField = (name: string): void => {
if (
@@ -221,6 +238,13 @@ function ExplorerColumnsRenderer({
}
};
const toggleDropdown = (): void => {
setOpen(!open);
if (!open) {
setSearchText('');
}
};
const isDarkMode = useIsDarkMode();
return (
@@ -303,38 +327,25 @@ function ExplorerColumnsRenderer({
</Droppable>
</DragDropContext>
<div>
<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>
<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>
</div>
</div>
)}

View File

@@ -146,7 +146,6 @@ describe('ExplorerColumnsRenderer', () => {
});
it('opens and closes the dropdown', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<Wrapper>
<ExplorerColumnsRenderer
@@ -159,12 +158,12 @@ describe('ExplorerColumnsRenderer', () => {
);
const addButton = screen.getByTestId('add-columns-button');
await user.click(addButton);
await userEvent.click(addButton);
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
expect(screen.getByText('attribute1')).toBeInTheDocument();
await user.click(addButton);
await userEvent.click(addButton);
await waitFor(() => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
});

View File

@@ -13,7 +13,7 @@ import { Plus, Trash2 } from '@signozhq/icons';
import { ContextLinkProps, Widgets } from 'types/api/dashboard/getAll';
import { getBaseUrl } from 'utils/basePath';
import VariablesPopover from './VariablesPopover';
import VariablesDropdown from './VariablesDropdown';
import './UpdateContextLinks.styles.scss';
@@ -71,7 +71,7 @@ function UpdateContextLinks({
customVariables: fieldVariables,
});
// Transform variables into the format expected by VariablesPopover
// Transform variables into the format expected by VariablesDropdown
const transformedVariables = useMemo(
() => transformContextVariables(variables),
[variables],
@@ -224,9 +224,7 @@ function UpdateContextLinks({
},
]}
>
{/* TODO: replace with AutoComplete with options for variables and
previously used URLs for better UX */}
<VariablesPopover
<VariablesDropdown
onVariableSelect={handleVariableSelect}
variables={transformedVariables}
>
@@ -254,7 +252,7 @@ function UpdateContextLinks({
/>
</div>
)}
</VariablesPopover>
</VariablesDropdown>
</Form.Item>
{/* Remove the separate variables section */}
@@ -284,7 +282,7 @@ function UpdateContextLinks({
/>
</Col>
<Col span={16}>
<VariablesPopover
<VariablesDropdown
onVariableSelect={(variableName, cursorPosition): void =>
handleParamVariableSelect(index, variableName, cursorPosition)
}
@@ -313,7 +311,7 @@ function UpdateContextLinks({
}
/>
)}
</VariablesPopover>
</VariablesDropdown>
</Col>
<Col span={2}>
<Button

View File

@@ -0,0 +1,26 @@
.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;
}
}

View File

@@ -0,0 +1,93 @@
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;

View File

@@ -1,74 +0,0 @@
.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;
}
}

View File

@@ -1,111 +0,0 @@
// 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;

View File

@@ -204,7 +204,7 @@ const processContextLinks = (
};
/**
* Transforms context variables into the format expected by VariablesPopover
* Transforms context variables into the format expected by VariablesDropdown
* @param variables - Array of context variables from useContextVariables
* @returns Array of transformed variables with proper source descriptions
*/

View File

@@ -1,7 +1,6 @@
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { ChevronDown } from '@signozhq/icons';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { Button, ColorPicker, Space } from 'antd';
import { Button, ColorPicker, Dropdown, MenuProps, Space } from 'antd';
import type { Color } from 'antd/es/color-picker';
import useDebounce from 'hooks/useDebounce';
@@ -27,7 +26,7 @@ function ColorSelector({
setColorFromPicker(hex);
};
const items: MenuItem[] = [
const items: MenuProps['items'] = [
{
key: 'Red',
label: <CustomColor color="Red" />,
@@ -63,7 +62,7 @@ function ColorSelector({
];
return (
<DropdownMenuSimple menu={{ items }}>
<Dropdown menu={{ items }} trigger={['click']}>
<Button
onClick={(e): void => e.preventDefault()}
className="color-selector-button"
@@ -73,7 +72,7 @@ function ColorSelector({
<ChevronDown size="md" />
</Space>
</Button>
</DropdownMenuSimple>
</Dropdown>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Check } from '@signozhq/icons';
import { Check, Info } from '@signozhq/icons';
import {
Button,
DatePicker,
@@ -11,6 +11,7 @@ import {
Select,
SelectProps,
Spin,
Tooltip,
} from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { DefaultOptionType } from 'antd/es/select';
@@ -78,6 +79,7 @@ interface PlannedDowntimeFormData {
alertRules: DefaultOptionType[];
recurrenceSelect?: AlertmanagertypesRecurrenceDTO;
timezone?: string;
scope?: string;
}
const customFormat = DATE_TIME_FORMATS.ORDINAL_DATETIME;
@@ -144,6 +146,7 @@ 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(),
@@ -278,6 +281,7 @@ export function PlannedDowntimeForm(
duration: getDurationInfo(schedule?.recurrence?.duration)?.value ?? '',
} as AlertmanagertypesRecurrenceDTO,
timezone: schedule?.timezone as string,
scope: initialValues.scope || '',
};
}, [initialValues, alertOptions]);
@@ -311,7 +315,7 @@ export function PlannedDowntimeForm(
default:
return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`;
}
}, [formData, recurrenceType, timezone]);
}, [formData, recurrenceType]);
const endTimeText = useMemo((): string => {
const endTime = formData.endTime;
@@ -322,7 +326,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, timezone]);
}, [formData, recurrenceType]);
return (
<Modal
@@ -488,6 +492,36 @@ export function PlannedDowntimeForm(
</Select>
</Form.Item>
</div>
<Form.Item
label={
<span>
Scope&nbsp;
<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

View File

@@ -1,8 +1,9 @@
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';
@@ -133,7 +134,7 @@ function ServiceAccountsSettings(): JSX.Element {
const totalCount = allAccounts.length;
const filterMenuItems: MenuItem[] = [
const filterMenuItems: MenuProps['items'] = [
{
key: FilterMode.All,
label: (
@@ -230,9 +231,10 @@ function ServiceAccountsSettings(): JSX.Element {
) : (
<div className="sa-settings__list-section">
<div className="sa-settings__controls">
<DropdownMenuSimple
<Dropdown
menu={{ items: filterMenuItems }}
className="sa-settings-filter-dropdown"
trigger={['click']}
overlayClassName="sa-settings-filter-dropdown"
>
<Button
variant="solid"
@@ -245,7 +247,7 @@ function ServiceAccountsSettings(): JSX.Element {
className="sa-settings-filter-trigger__chevron"
/>
</Button>
</DropdownMenuSimple>
</Dropdown>
<div className="sa-settings__search">
<Input

View File

@@ -1,5 +1,4 @@
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';
@@ -130,7 +129,6 @@ describe('ServiceAccountsSettings (integration)', () => {
});
it('filter dropdown to "Active" hides DISABLED accounts', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<NuqsTestingAdapter>
<ServiceAccountsSettings />
@@ -139,10 +137,10 @@ describe('ServiceAccountsSettings (integration)', () => {
await screen.findByText('CI Bot');
await user.click(screen.getByRole('button', { name: /All accounts/i }));
fireEvent.click(screen.getByRole('button', { name: /All accounts/i }));
const activeOption = await screen.findByText(/Active ⎯/i);
await user.click(activeOption);
fireEvent.click(activeOption);
await screen.findByText('CI Bot');
expect(screen.queryByText('Legacy Bot')).not.toBeInTheDocument();

View File

@@ -662,7 +662,7 @@
}
}
&:not(.pinned).is-hovered,
&:not(.pinned):hover,
&.dropdown-open {
flex: 0 0 240px;
max-width: 240px;

View File

@@ -1,6 +1,5 @@
import {
MouseEvent,
ReactNode,
useCallback,
useEffect,
useMemo,
@@ -26,14 +25,7 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@signozhq/ui/dropdown-menu';
import { Button, MenuProps, Modal, Tooltip } from 'antd';
import { Button, Dropdown, 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';
@@ -170,9 +162,7 @@ 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[]>([]);
@@ -185,27 +175,9 @@ 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;
@@ -987,11 +959,9 @@ 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}
@@ -1212,95 +1182,46 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
{isAIAssistantEnabled && renderNavItems([aiAssistantMenuItem], false)}
<div className="nav-dropdown-item">
<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>
<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>
<div className="nav-item-label">{helpSupportMenuItem.label}</div>
</div>
<div className="nav-item-label">{helpSupportMenuItem.label}</div>
</div>
</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>
</Dropdown>
</div>
<div className="nav-dropdown-item">
<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>
<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>
<div className="nav-item-label">{userSettingsMenuItem.label}</div>
</div>
<div className="nav-item-label">{userSettingsMenuItem.label}</div>
</div>
</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>
</Dropdown>
</div>
</div>
</div>

View File

@@ -8,10 +8,8 @@
border-color: var(--l1-border);
margin: 0;
}
.dropdown-trigger-wrapper {
display: flex;
justify-content: center;
align-items: center;
.dropdown-icon {
margin-right: 4px;
}
}
.dropdown-menu {

View File

@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Button, Divider, Tooltip } from 'antd';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import { Divider, Dropdown, MenuProps, Tooltip } from 'antd';
import { Switch } from '@signozhq/ui/switch';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Copy, Ellipsis, PenLine, Trash2 } from '@signozhq/icons';
@@ -13,6 +12,7 @@ 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,6 +21,16 @@ 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,
@@ -58,7 +68,9 @@ function AlertActionButtons({
const isV2Alert = alertDetails.schemaVersion === NEW_ALERT_SCHEMA_VERSION;
const menuItems: MenuItem[] = [
const finalMenuItemStyle = isV2Alert ? menuItemStyleV2 : menuItemStyle;
const menuItems: MenuProps['items'] = [
...(!isV2Alert
? [
{
@@ -66,6 +78,7 @@ function AlertActionButtons({
label: 'Rename',
icon: <PenLine size={16} color={Color.BG_VANILLA_400} />,
onClick: handleRename,
style: finalMenuItemStyle,
},
]
: []),
@@ -74,13 +87,17 @@ 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,
danger: true,
style: {
...finalMenuItemStyle,
color: Color.BG_CHERRY_400,
},
},
];
@@ -123,21 +140,16 @@ function AlertActionButtons({
<Divider type="vertical" />
<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>
<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>
</div>
<RenameModal

View File

@@ -1,6 +1,13 @@
import { useMemo, useState } from 'react';
import { Button, Divider, Form, Space, Tooltip } from 'antd';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import {
Button,
Divider,
Dropdown,
Form,
MenuProps,
Space,
Tooltip,
} from 'antd';
import { Switch } from '@signozhq/ui/switch';
import cx from 'classnames';
import { FilterSelect } from 'components/CeleryOverview/CeleryOverviewConfigOptions/CeleryOverviewConfigOptions';
@@ -37,22 +44,16 @@ function FunnelStep({
const [isAddDetailsModalOpen, setIsAddDetailsModalOpen] =
useState<boolean>(false);
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 latencyPointerItems: MenuProps['items'] = LatencyPointers.map(
(option) => ({
key: option.value,
label: option.key,
style:
option.value === stepData.latency_pointer
? { backgroundColor: 'var(--bg-slate-100)' }
: {},
}),
);
const updatedCurrentQuery = useMemo(
() => ({
@@ -210,18 +211,17 @@ function FunnelStep({
</div>
<div className="latency-pointer">
<div className="latency-pointer__label">Latency pointer</div>
{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>
) : (
<Dropdown
menu={{
items: latencyPointerItems,
onClick: ({ key }): void =>
onStepChange(index, {
latency_pointer: key as FunnelStepData['latency_pointer'],
}),
}}
trigger={['click']}
disabled={!hasEditPermission}
>
<Space>
{
LatencyPointers.find(
@@ -230,7 +230,7 @@ function FunnelStep({
}
<ChevronDown size={14} color="var(--bg-vanilla-400)" />
</Space>
)}
</Dropdown>
</div>
</div>
</Form>

View File

@@ -1,5 +1,6 @@
// eslint-disable-next-line no-restricted-imports
import { compose, Store } from 'redux';
import type { WebSettings } from 'types/generated/webSettings';
declare global {
interface Window {
@@ -7,6 +8,7 @@ declare global {
pylon: any;
Appcues: Record<string, any>;
__REDUX_DEVTOOLS_EXTENSION_COMPOSE__: typeof compose;
signozBootData?: { settings: WebSettings | null };
}
}

View File

@@ -0,0 +1,74 @@
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);
});
});

View File

@@ -0,0 +1,11 @@
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 },
};

View File

@@ -23,6 +23,20 @@ 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',
@@ -47,6 +61,7 @@ export default defineConfig(({ mode }): UserConfig => {
tsconfigPaths(),
rawMarkdownPlugin(),
devBasePathPlugin(basePath),
devBootDataPlugin(env),
react(),
createHtmlPlugin({
inject: {

View File

@@ -42,7 +42,14 @@ func (m *MaintenanceMuter) Mutes(ctx context.Context, lset model.LabelSet) bool
}
now := time.Now()
for _, mw := range m.getMaintenances(ctx) {
if mw.ShouldSkip(ruleID, now) {
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 {
return true
}
}
@@ -61,7 +68,14 @@ func (m *MaintenanceMuter) MutedBy(ctx context.Context, lset model.LabelSet) []s
var ids []string
now := time.Now()
for _, mw := range m.getMaintenances(ctx) {
if mw.ShouldSkip(ruleID, now) {
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 {
ids = append(ids, mw.ID.String())
}
}

View File

@@ -41,7 +41,7 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
},
Expression: `ruleId == "high-cpu-usage" && severity == "critical"`,
Expression: `ruleId = "high-cpu-usage" AND 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" && severity == "warning"`,
Expression: `ruleId = "high-cpu-usage" AND severity = "warning"`,
ExpressionKind: alertmanagertypes.RuleBasedExpression,
Name: "high-cpu-usage",
Description: "High CPU warning alerts to webhook",
@@ -87,18 +87,25 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
err = notificationManager.SetNotificationConfig(orgID, "high-cpu-usage", &notifConfig)
require.NoError(t, err)
mwID := valuer.GenerateUUID()
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()
maintenanceStore := alertmanagertypestest.NewMockMaintenanceStore(t)
maintenanceStore.On("ListPlannedMaintenance", mock.Anything, orgID).Return(
[]*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,
[]*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,
)
srvCfg := NewConfig()
@@ -249,18 +256,42 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
require.Equal(t, "{__receiver__=\"webhook\"}:{cluster=\"prod-cluster\", instance=\"server-03\", ruleId=\"high-cpu-usage\"}", alertGroups[2].GroupKey)
})
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)
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.
for _, alert := range alerts {
if alert.Labels["ruleId"] == "high-cpu-usage" {
require.Equal(t, []string{mwID.String()}, alert.Status.MutedBy)
require.Contains(t, alert.Status.MutedBy, mwRuleIDOnly.String())
} else {
require.Empty(t, alert.Status.MutedBy)
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())
}
}
})

View File

@@ -89,6 +89,7 @@ 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)
@@ -123,7 +124,6 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
NewInsert().
Model(&maintenanceRules).
Exec(ctx)
if err != nil {
return err
}
@@ -141,6 +141,7 @@ 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,
@@ -189,6 +190,7 @@ 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)
@@ -224,7 +226,6 @@ 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
}
@@ -241,7 +242,6 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
}
return nil
})
if err != nil {
return err

View File

@@ -3,7 +3,6 @@ package rulebasednotification
import (
"context"
"log/slog"
"strings"
"sync"
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
@@ -235,65 +234,13 @@ func (r *provider) Match(ctx context.Context, orgID string, ruleID string, set m
return matchedChannels, nil
}
// 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.
// convertLabelSetToEnv delegates to alertmanagertypes.ConvertLabelSetToEnv and
// logs when a key is a prefix of another (e.g. "foo" alongside "foo.bar").
func (r *provider) convertLabelSetToEnv(ctx context.Context, labelSet model.LabelSet) map[string]interface{} {
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 {
env, conflict := alertmanagertypes.ConvertLabelSetToEnv(labelSet)
if conflict {
r.settings.Logger().InfoContext(ctx, "found label set with conflicting prefix dotted keys", slog.Any("labels", labelSet))
}
return env
}

View File

@@ -925,72 +925,3 @@ 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)
})
}
}

View File

@@ -29,5 +29,24 @@ 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
}

View File

@@ -2,6 +2,8 @@ package envprovider
import (
"context"
"os"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/config"
@@ -9,7 +11,21 @@ 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")
@@ -31,6 +47,7 @@ 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{}
@@ -43,6 +60,7 @@ 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")

View File

@@ -3,14 +3,15 @@ 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")
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")
)
func MustNewRegistry() featuretypes.Registry {
@@ -79,6 +80,14 @@ 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)

View File

@@ -38,3 +38,24 @@ 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)
}

View File

@@ -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 returns the waterfall cache for the given traceID with fallback on DB.
// getTraceData fetches all spans for a trace and builds the WaterfallTrace.
func (m *module) getTraceData(ctx context.Context, traceID string) (*spantypes.WaterfallTrace, error) {
summary, err := m.store.GetTraceSummary(ctx, traceID)
if err != nil {
@@ -61,6 +61,86 @@ func (m *module) getTraceData(ctx context.Context, traceID string) (*spantypes.W
return nil, spantypes.ErrTraceNotFound
}
traceData := spantypes.NewWaterfallTraceFromSpans(spanItems)
return traceData, nil
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
}

View File

@@ -12,6 +12,9 @@ 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
}
@@ -69,3 +72,64 @@ 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
}

View File

@@ -10,9 +10,11 @@ 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)
}

View File

@@ -1770,6 +1770,15 @@ 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 {

View File

@@ -206,6 +206,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddIntegrationDashboardFactory(sqlstore, sqlschema),
sqlmigration.NewAddSourceToDashboardFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateCloudIntegrationDashboardsFactory(sqlstore),
sqlmigration.NewAddScopeToPlannedMaintenanceFactory(sqlstore, sqlschema),
)
}

View File

@@ -0,0 +1,70 @@
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
}

View File

@@ -0,0 +1,88 @@
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
}

View File

@@ -0,0 +1,163 @@
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)
}
})
}
}

View File

@@ -5,14 +5,19 @@ 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
}
@@ -58,6 +63,7 @@ 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 {
@@ -66,6 +72,7 @@ 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"`
@@ -82,6 +89,7 @@ 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 {
@@ -116,6 +124,15 @@ 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
}
@@ -151,7 +168,7 @@ func (m *PlannedMaintenance) HasScheduleRecurrenceBoundsMismatch() bool {
(recurrence.EndTime != nil && !recurrence.EndTime.Equal(m.Schedule.EndTime))
}
func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time) bool {
func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time, lset model.LabelSet) (bool, error) {
// Check if the alert ID is in the maintenance window
found := false
if len(m.RuleIDs) > 0 {
@@ -168,9 +185,27 @@ func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time) bool {
}
if !found {
return false
return false, nil
}
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 {
@@ -301,14 +336,6 @@ 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 {
@@ -389,6 +416,7 @@ 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"`
@@ -401,6 +429,7 @@ 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,
@@ -424,6 +453,7 @@ 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,

View File

@@ -5,6 +5,7 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/prometheus/common/model"
)
// Helper function to create a time pointer.
@@ -668,9 +669,193 @@ func TestShouldSkipMaintenance(t *testing.T) {
}
for idx, c := range cases {
result := c.maintenance.ShouldSkip(c.name, c.ts)
result, err := c.maintenance.ShouldSkip(c.name, c.ts, model.LabelSet{})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
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)
}
})
}
}

View File

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

View File

@@ -26,4 +26,6 @@ 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)
}

View File

@@ -132,6 +132,31 @@ 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{
@@ -297,6 +322,24 @@ 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 {

View File

@@ -62,26 +62,24 @@ func NewWaterfallTrace(
}
}
func NewWaterfallTraceFromSpans(spans []StorableSpan) *WaterfallTrace {
// NewWaterfallTraceFromSpans requires WaterfallSpan nodes with only below fields:
// SpanID, ParentSpanID, TimeUnix, DurationNano, HasError, and ServiceName.
func NewWaterfallTraceFromSpans(nodes []*WaterfallSpan) *WaterfallTrace {
var (
startTime, endTime, totalErrorSpans uint64
spanIDToSpanNodeMap = make(map[string]*WaterfallSpan, len(spans))
spanIDToSpanNodeMap = make(map[string]*WaterfallSpan, len(nodes))
traceRoots []*WaterfallSpan
hasMissingSpans bool
)
for _, item := range spans {
span := item.ToWaterfallSpan()
startTimeUnixNano := uint64(item.StartTime.UnixNano())
if startTime == 0 || startTimeUnixNano < startTime {
startTime = startTimeUnixNano
for _, span := range nodes {
if startTime == 0 || span.TimeUnix < startTime {
startTime = span.TimeUnix
}
endTime = max(endTime, startTimeUnixNano+span.DurationNano)
endTime = max(endTime, span.TimeUnix+span.DurationNano)
if span.HasError {
totalErrorSpans++
}
spanIDToSpanNodeMap[span.SpanID] = span
}
@@ -116,7 +114,7 @@ func NewWaterfallTraceFromSpans(spans []StorableSpan) *WaterfallTrace {
return NewWaterfallTrace(
startTime,
endTime,
uint64(len(spans)),
uint64(len(nodes)),
totalErrorSpans,
spanIDToSpanNodeMap,
traceRoots,