Compare commits

..

1 Commits

Author SHA1 Message Date
srikanthccv
8179d1a027 fix(alertmanager): resolve email SMTP settings from env 2026-07-26 18:00:15 +05:30
231 changed files with 78176 additions and 52896 deletions

View File

@@ -39,6 +39,8 @@ jobs:
matrix:
suite:
- alerts
- alertmanager
- alertmanagerrotation
- basepath
- callbackauthn
- cloudintegrations

View File

@@ -8,8 +8,6 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlmigration"
"github.com/SigNoz/signoz/pkg/sqlmigrator"
@@ -147,7 +145,7 @@ func newSyncMigrator(ctx context.Context, config signoz.Config, sqlstoreProvider
return nil, err
}
sqlmigrations, err := sqlmigration.New(ctx, providerSettings, config.SQLMigration, signoz.NewSQLMigrationProviderFactories(sqlstore, sqlschema, telemetrystore, providerSettings, impldashboard.NewStore(sqlstore), impltag.NewModule(impltag.NewStore(sqlstore))))
sqlmigrations, err := sqlmigration.New(ctx, providerSettings, config.SQLMigration, signoz.NewSQLMigrationProviderFactories(sqlstore, sqlschema, telemetrystore, providerSettings))
if err != nil {
return nil, err
}

View File

@@ -7238,10 +7238,6 @@ components:
$ref: '#/components/schemas/RuletypesAlertState'
overallStateChanged:
type: boolean
relatedLogsLink:
type: string
relatedTracesLink:
type: string
ruleId:
type: string
ruleName:

View File

@@ -551,12 +551,12 @@ func (module *module) provisionDashboards(ctx context.Context, orgID valuer.UUID
continue
}
createdDashboard, err := module.dashboardModule.CreateV2(ctx, orgID, createdBy, creator, dashboardtypes.SourceIntegration, dashboard.Definition)
createdDashboard, err := module.dashboardModule.Create(ctx, orgID, createdBy, creator, dashboardtypes.SourceIntegration, dashboardtypes.PostableDashboard(dashboard.Definition))
if err != nil {
return err
}
integrationDashboard := cloudintegrationtypes.NewStorableIntegrationDashboard(createdDashboard.ID.StringValue(), cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
integrationDashboard := cloudintegrationtypes.NewStorableIntegrationDashboard(createdDashboard.ID, cloudintegrationtypes.IntegrationDashboardProviderCloudIntegration, slug)
if err := module.store.CreateIntegrationDashboard(ctx, integrationDashboard); err != nil {
return err
}

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: "",
})
aiObservability := ah.Signoz.Flagger.BooleanOrEmpty(ctx, flagger.FeatureEnableAIObservability, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),

View File

@@ -8320,14 +8320,6 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
* @type boolean
*/
overallStateChanged: boolean;
/**
* @type string
*/
relatedLogsLink?: string;
/**
* @type string
*/
relatedTracesLink?: string;
/**
* @type string
*/

View File

@@ -18,10 +18,7 @@ import {
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import {
convertBuilderQueriesToV5,
prepareQueryRangePayloadV5,
} from './prepareQueryRangePayloadV5';
import { prepareQueryRangePayloadV5 } from './prepareQueryRangePayloadV5';
jest.mock('lib/getStartEndRangeTime', () => ({
__esModule: true,
@@ -902,36 +899,3 @@ describe('prepareQueryRangePayloadV5', () => {
expect(logSpec.filter).toStrictEqual({ expression: '' });
});
});
describe('convertBuilderQueriesToV5 having normalization', () => {
const buildSpec = (having: unknown): MetricBuilderQuery => {
const [envelope] = convertBuilderQueriesToV5(
{
A: {
dataSource: DataSource.METRICS,
queryName: 'A',
aggregations: [{ metricName: 'm', spaceAggregation: 'p99' }],
having,
} as unknown as IBuilderQuery,
},
'time_series',
PANEL_TYPES.TIME_SERIES,
);
return envelope.spec as MetricBuilderQuery;
};
it.each([
['a legacy V4 array', []],
['a blank empty-object having', { expression: '' }],
['a whitespace-only having', { expression: ' ' }],
['a nullish having', undefined],
])('drops %s (serializes to undefined)', (_label, having) => {
expect(buildSpec(having).having).toBeUndefined();
});
it('preserves a real having expression', () => {
expect(buildSpec({ expression: 'count() > 5' }).having).toStrictEqual({
expression: 'count() > 5',
});
});
});

View File

@@ -134,21 +134,6 @@ function getFilter(queryData: IBuilderQuery): Filter {
};
}
/**
* Normalizes a builder query's `having` to the V5 shape, treating "no having filter" as absent.
* V4 stored it as an array; V5 expects `{ expression }`. An array (legacy), a nullish value, or a
* blank expression — the query builder seeds `{ expression: '' }` for an empty having — all mean
* "no having" and must serialize to `undefined`. Emitting an empty `{ expression: '' }` sends a
* no-op filter and, because a saved panel never carries one, reads an untouched panel as dirty.
*/
function normalizeHaving(having: unknown): Having | undefined {
if (having == null || Array.isArray(having)) {
return undefined;
}
const { expression } = having as Having;
return expression?.trim() ? (having as Having) : undefined;
}
function createBaseSpec(
queryData: IBuilderQuery,
requestType: RequestType,
@@ -196,7 +181,12 @@ function createBaseSpec(
)
: undefined,
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
having: normalizeHaving(queryData.having),
// V4 uses having as array, V5 uses having as object with expression field
// If having is an array (V4 format), treat it as undefined for V5
having:
isEmpty(queryData.having) || Array.isArray(queryData.having)
? undefined
: (queryData?.having as Having),
functions: isEmpty(queryData.functions)
? undefined
: queryData.functions.map((func: QueryFunction): QueryFunction => {
@@ -424,7 +414,10 @@ function createTraceOperatorBaseSpec(
)
: undefined,
legend: isEmpty(legend) ? undefined : legend,
having: normalizeHaving(having),
// V4 uses having as array, V5 uses having as object with expression field
// If having is an array (V4 format), treat it as undefined for V5
having:
isEmpty(having) || Array.isArray(having) ? undefined : (having as Having),
selectFields: isEmpty(nonEmptySelectColumns)
? undefined
: nonEmptySelectColumns?.map(

View File

@@ -213,9 +213,7 @@ describe.each([
const callArgs = mockDownloadExportData.mock.calls[0][0];
const query = callArgs.body.compositeQuery.queries[0];
expect(query.spec.groupBy).toBeUndefined();
// An empty having ({ expression: '' }) is a no-op filter and serializes to
// undefined — same as the cleared groupBy above.
expect(query.spec.having).toBeUndefined();
expect(query.spec.having).toStrictEqual({ expression: '' });
});
});

View File

@@ -20,7 +20,6 @@ import {
import { Color } from '@signozhq/design-tokens';
import { Button, Select } from 'antd';
import { Checkbox } from '@signozhq/ui/checkbox';
import { TooltipProvider, TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip/TextToolTip';
@@ -34,7 +33,6 @@ import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
import {
ALL_SELECTED_VALUE,
filterOptionsBySearch,
findOptionLabelText,
handleScrollToBottom,
prioritizeOrAddOptionForMultiSelect,
SPACEKEY,
@@ -1939,7 +1937,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}
};
const tag = (
return (
<div
className={cx('ant-select-selection-item', {
'ant-select-selection-item-active': isActive,
@@ -1969,32 +1967,13 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
)}
</div>
);
// `label` arrives already cut to maxTagTextLength, so the reveal reads the
// option's own text (falling back to the raw value for freeform tags).
return (
<TooltipSimple
side="top"
delayDuration={300}
title={findOptionLabelText(options, value)}
>
{tag}
</TooltipSimple>
);
}
// Fallback for safety, should not be reached
return <div />;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
isAllSelected,
activeChipIndex,
selectedChips,
selectedValues,
maxTagCount,
options,
],
[isAllSelected, activeChipIndex, selectedChips, selectedValues, maxTagCount],
);
// Simple onClear handler to prevent clearing ALL
@@ -2013,58 +1992,51 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
// ===== Component Rendering =====
return (
// Self-provided so the per-tag tooltips work wherever this select is rendered,
// without every consumer having to sit under an app-level provider.
<TooltipProvider>
<div
className={cx('custom-multiselect-wrapper', {
'all-selected': allOptionShown || isAllSelected,
<div
className={cx('custom-multiselect-wrapper', {
'all-selected': allOptionShown || isAllSelected,
})}
>
{(allOptionShown || isAllSelected) && !searchText && (
<div className="all-text">ALL</div>
)}
<Select
ref={selectRef}
className={cx('custom-multiselect', className, {
'has-selection': selectedChips.length > 0 && !isAllSelected,
'is-all-selected': isAllSelected,
})}
>
{(allOptionShown || isAllSelected) && !searchText && (
<div className="all-text">ALL</div>
)}
<Select
ref={selectRef}
className={cx('custom-multiselect', className, {
'has-selection': selectedChips.length > 0 && !isAllSelected,
'is-all-selected': isAllSelected,
})}
placeholder={placeholder}
mode="multiple"
showSearch
filterOption={false}
onSearch={handleSearch}
value={displayValue}
onChange={(newValue): void => {
handleInternalChange(newValue, false);
}}
onClear={onClearHandler}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={dropdownMatchSelectWidth}
allowClear={allowClear}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx(
'custom-multiselect-dropdown-container',
popupClassName,
)}
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
tagRender={tagRender as any}
placement={placement}
listHeight={300}
searchValue={searchText}
maxTagTextLength={maxTagTextLength}
maxTagCount={isAllSelected ? undefined : maxTagCount}
{...rest}
/>
</div>
</TooltipProvider>
placeholder={placeholder}
mode="multiple"
showSearch
filterOption={false}
onSearch={handleSearch}
value={displayValue}
onChange={(newValue): void => {
handleInternalChange(newValue, false);
}}
onClear={onClearHandler}
onDropdownVisibleChange={handleDropdownVisibleChange}
open={isOpen}
defaultActiveFirstOption={defaultActiveFirstOption}
popupMatchSelectWidth={dropdownMatchSelectWidth}
allowClear={allowClear}
getPopupContainer={getPopupContainer ?? popupContainer}
suffixIcon={<ChevronDown style={{ cursor: 'default' }} size="md" />}
dropdownRender={customDropdownRender}
menuItemSelectedIcon={null}
popupClassName={cx('custom-multiselect-dropdown-container', popupClassName)}
notFoundContent={<div className="empty-message">{noDataMessage}</div>}
onKeyDown={handleKeyDown}
tagRender={tagRender as any}
placement={placement}
listHeight={300}
searchValue={searchText}
maxTagTextLength={maxTagTextLength}
maxTagCount={isAllSelected ? undefined : maxTagCount}
{...rest}
/>
</div>
);
};

View File

@@ -1,66 +0,0 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import CustomMultiSelect from '../CustomMultiSelect';
const OPTIONS = [
{ label: 'checkout-service-prod', value: 'checkout-service-prod' },
{ label: 'payments-service-prod', value: 'payments-service-prod' },
{ label: 'cart-service-prod', value: 'cart-service-prod' },
];
const SELECTED = ['checkout-service-prod', 'payments-service-prod'];
function renderSelect(): void {
render(
<TooltipProvider>
<CustomMultiSelect
options={OPTIONS}
value={SELECTED}
maxTagCount={1}
maxTagTextLength={10}
maxTagPlaceholder={(omitted): string => `+${omitted.length}`}
/>
</TooltipProvider>,
);
}
/** Hovers an element and lets the tooltip's open delay elapse. */
async function hover(element: HTMLElement): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await user.hover(element);
act(() => {
jest.advanceTimersByTime(500);
});
}
describe('CustomMultiSelect tag tooltip', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("reveals a tag's untruncated value on hover", async () => {
renderSelect();
await hover(screen.getByText('checkout-s...'));
expect(screen.getByRole('tooltip')).toHaveTextContent(
'checkout-service-prod',
);
});
// The `+N` placeholder stays the caller's to render — several callers already wrap
// it in a tooltip of their own, and a second one would stack on top.
it('leaves the +N overflow placeholder untouched', async () => {
renderSelect();
await hover(screen.getByText('+1'));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
});

View File

@@ -109,21 +109,6 @@ export const prioritizeOrAddOptionForMultiSelect = (
return [...flatOutSelectedOptions, ...filteredOptions];
};
export const findOptionLabelText = (
options: OptionData[],
value: string,
): string => {
const match = options
.flatMap((option) =>
'options' in option && Array.isArray(option.options)
? option.options
: [option],
)
.find((option) => option.value === value);
return typeof match?.label === 'string' ? match.label : value;
};
/**
* Filters options based on search text
*/

View File

@@ -1,20 +0,0 @@
@use '../../styles/scrollbar' as *;
// Padding for the tooltip body that hosts a scroll area: the right side is given
// up so the scrollbar can sit flush against the tooltip's edge instead of floating
// inset from it. `.scrollArea` puts that spacing back between the text and the bar.
.tooltipContent {
--tooltip-padding: var(--spacing-2) 0 var(--spacing-2) var(--spacing-4);
}
// How tall a hover reveal grows before its content starts scrolling.
.scrollArea {
max-height: 480px;
overflow-y: auto;
// Keep the page behind the tooltip still once the list hits its end.
overscroll-behavior: contain;
// Gap between the content and the scrollbar (or the tooltip edge when short).
padding-right: var(--spacing-4);
@include custom-scrollbar;
}

View File

@@ -1,25 +0,0 @@
import type { ReactNode } from 'react';
import styles from './TooltipScrollArea.module.scss';
interface TooltipScrollAreaProps {
children: ReactNode;
}
/**
* Pass as the hosting tooltip's content class (`tooltipContentProps`) so its
* padding makes room for the scroll area's own edge handling.
*/
export const TOOLTIP_SCROLL_CONTENT_CLASS = styles.tooltipContent;
/**
* Scroll container for hover reveals that list an unbounded number of items (tag
* chips, variable values): caps the tooltip's height and scrolls past it. Plain CSS
* overflow with a pinned-visible thin scrollbar — a tooltip is transient, so an
* auto-hiding scrollbar would leave no hint that there is more below.
*/
function TooltipScrollArea({ children }: TooltipScrollAreaProps): JSX.Element {
return <div className={styles.scrollArea}>{children}</div>;
}
export default TooltipScrollArea;

View File

@@ -10,6 +10,7 @@ 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',
USE_INFRA_MONITORING_V2 = 'use_infra_monitoring_v2',
ENABLE_AI_OBSERVABILITY = 'enable_ai_observability',
ENABLE_METRICS_REDUCTION = 'enable_metrics_reduction',

View File

@@ -56,19 +56,14 @@
margin-top: 4px;
}
// width/height are set inline from the computed chart dimensions; border-box
// keeps the padding inside that height and overflow:hidden clips the
// virtualized grid to the rectangle.
// Wraps the shared chart Legend. Its width/height are set inline from the
// computed chart dimensions, so the VirtuosoGrid inside gets the same bounded
// box (right column / bottom rows) the uPlot charts use.
.pieChartLegend {
flex: 0 0 auto;
box-sizing: border-box;
min-height: 0;
min-width: 0;
overflow: hidden;
padding-left: 12px;
padding-bottom: 12px;
}
.pieChartLegendRight {
padding-top: 8px;
}

View File

@@ -3,7 +3,6 @@ import { Color } from '@signozhq/design-tokens';
import { Group } from '@visx/group';
import { Pie as VisxPie } from '@visx/shape';
import { defaultStyles, useTooltip, useTooltipInPortal } from '@visx/tooltip';
import cx from 'classnames';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { useResizeObserver } from 'hooks/useDimensions';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
@@ -211,9 +210,7 @@ export default function Pie({
)}
</div>
<div
className={cx(styles.pieChartLegend, {
[styles.pieChartLegendRight]: isRightLegend,
})}
className={styles.pieChartLegend}
style={{
width: legendWidth,
height: legendHeight,

View File

@@ -4,13 +4,8 @@ import { MOCK_QUERY } from 'container/QueryTable/Drilldown/__tests__/mockTableDa
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { rest, server } from 'mocks-server/server';
import {
defaultFeatureFlags,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { Dashboard } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
@@ -46,16 +41,12 @@ const mockUseHistory = jest.mocked(useHistory);
// Mock data
const TEST_QUERY_ID = 'test-query-id';
const TEST_DASHBOARD_ID = 'test-dashboard-id';
const TEST_DASHBOARD_TITLE = 'Test Dashboard';
const TEST_DASHBOARD_DESCRIPTION = 'Test Description';
const TEST_TIMESTAMP = '2023-01-01T00:00:00Z';
const TEST_DASHBOARD_TITLE_2 = 'Test Dashboard for Export';
const NEW_DASHBOARD_ID = 'new-dashboard-id';
// The export dialog talks to the generated Perses-spec dashboard endpoints.
const V2_LIST_ENDPOINT = '*/api/v2/users/me/dashboards';
const V2_CREATE_ENDPOINT = '*/api/v2/dashboards';
// "Create new dashboard" names the dashboard from the `new_dashboard_title` i18n
// key; the test-utils react-i18next mock returns the key verbatim.
const NEW_DASHBOARD_TITLE = 'new_dashboard_title';
const DASHBOARDS_API_ENDPOINT = '*/api/v1/dashboards';
// Use the existing mock query from the codebase
const mockQuery: Query = {
@@ -63,44 +54,23 @@ const mockQuery: Query = {
id: TEST_QUERY_ID, // Override with our test ID
} as Query;
const createMockDashboard = (id: string = TEST_DASHBOARD_ID): Dashboard => ({
id,
data: {
title: TEST_DASHBOARD_TITLE,
description: TEST_DASHBOARD_DESCRIPTION,
tags: [],
layout: [],
variables: {},
},
createdAt: TEST_TIMESTAMP,
updatedAt: TEST_TIMESTAMP,
createdBy: 'test-user',
updatedBy: 'test-user',
});
const ADD_TO_DASHBOARD_BUTTON_NAME = /add to dashboard/i;
interface PickerDashboard {
id: string;
title: string;
}
/** Register the "list dashboards" endpoint the picker reads. */
function mockDashboardList(dashboards: PickerDashboard[]): void {
server.use(
rest.get(V2_LIST_ENDPOINT, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
dashboards: dashboards.map(({ id, title }) => ({
id,
name: title,
spec: { display: { name: title } },
})),
reservedKeywords: [],
},
}),
),
),
);
}
/** Register the "create dashboard" endpoint the "New dashboard" button hits. */
function mockCreateDashboard(id: string): void {
server.use(
rest.post(V2_CREATE_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(201), ctx.json({ status: 'success', data: { id } })),
),
);
}
// Helper function to render component with props
const renderExplorerOptionWrapper = (
overrides = {},
@@ -134,8 +104,6 @@ const renderExplorerOptionWrapper = (
splitedQueries={props.splitedQueries}
signalSource={props.signalSource}
/>,
undefined,
{ appContextOverrides: { featureFlags: defaultFeatureFlags } },
);
};
@@ -189,9 +157,17 @@ describe('ExplorerOptionWrapper', () => {
) => void
>;
mockCreateDashboard(NEW_DASHBOARD_ID);
// Mock the dashboard creation API
const mockNewDashboard = createMockDashboard(NEW_DASHBOARD_ID);
server.use(
rest.post(DASHBOARDS_API_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockNewDashboard })),
),
);
renderExplorerOptionWrapper({ onExport: testOnExport });
renderExplorerOptionWrapper({
onExport: testOnExport,
});
// Find and click the "Add to Dashboard" button
const addToDashboardButton = screen.getByRole('button', {
@@ -211,7 +187,7 @@ describe('ExplorerOptionWrapper', () => {
// Wait for the API call to complete and onExport to be called
await waitFor(() => {
expect(testOnExport).toHaveBeenCalledWith(
{ id: NEW_DASHBOARD_ID, title: NEW_DASHBOARD_TITLE },
{ id: NEW_DASHBOARD_ID, title: TEST_DASHBOARD_TITLE },
true,
);
});
@@ -228,13 +204,21 @@ describe('ExplorerOptionWrapper', () => {
>;
// Mock existing dashboards with unique titles
const dashboards: PickerDashboard[] = [
{ id: 'dashboard-1', title: 'Dashboard 1' },
{ id: 'dashboard-2', title: 'Dashboard 2' },
];
mockDashboardList(dashboards);
const mockDashboard1 = createMockDashboard('dashboard-1');
mockDashboard1.data.title = 'Dashboard 1';
const mockDashboard2 = createMockDashboard('dashboard-2');
mockDashboard2.data.title = 'Dashboard 2';
const mockDashboards = [mockDashboard1, mockDashboard2];
renderExplorerOptionWrapper({ onExport: testOnExport });
server.use(
rest.get(DASHBOARDS_API_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockDashboards })),
),
);
renderExplorerOptionWrapper({
onExport: testOnExport,
});
// Find and click the "Add to Dashboard" button
const addToDashboardButton = screen.getByRole('button', {
@@ -247,15 +231,10 @@ describe('ExplorerOptionWrapper', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Wait for the dashboard select to render AND finish loading. antd
// disables the Select while the list request is in flight, and a click
// on a disabled combobox is a no-op — so sync on the ready (enabled)
// state before clicking.
// Wait for the dashboard select dropdown to render inside the dialog
const modal = screen.getByRole('dialog');
await waitFor(() => {
const combobox = modal.querySelector('[role="combobox"]');
expect(combobox).toBeTruthy();
expect(combobox).not.toBeDisabled();
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
});
const dashboardSelect = modal.querySelector(
@@ -266,11 +245,11 @@ describe('ExplorerOptionWrapper', () => {
// Wait for the dropdown options to appear and select the first dashboard
await waitFor(() => {
expect(screen.getByText(dashboards[0].title)).toBeInTheDocument();
expect(screen.getByText(mockDashboard1.data.title)).toBeInTheDocument();
});
// Click on the first dashboard option
const dashboardOption = screen.getByText(dashboards[0].title);
const dashboardOption = screen.getByText(mockDashboard1.data.title);
await user.click(dashboardOption);
// Wait for the selection to be made and the export button to be enabled
@@ -285,7 +264,7 @@ describe('ExplorerOptionWrapper', () => {
// Wait for onExport to be called with the selected dashboard
await waitFor(() => {
expect(testOnExport).toHaveBeenCalledWith(
{ id: dashboards[0].id, title: dashboards[0].title },
{ id: 'dashboard-1', title: 'Dashboard 1' },
false,
);
});
@@ -326,12 +305,18 @@ describe('ExplorerOptionWrapper', () => {
};
// Mock existing dashboards
const dashboards: PickerDashboard[] = [
{ id: TEST_DASHBOARD_ID, title: TEST_DASHBOARD_TITLE_2 },
];
mockDashboardList(dashboards);
const mockDashboard = createMockDashboard('test-dashboard-id');
mockDashboard.data.title = TEST_DASHBOARD_TITLE_2;
renderExplorerOptionWrapper({ onExport: handleExport });
server.use(
rest.get(DASHBOARDS_API_ENDPOINT, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [mockDashboard] })),
),
);
renderExplorerOptionWrapper({
onExport: handleExport,
});
// Find and click the "Add to Dashboard" button
const addToDashboardButton = screen.getByRole('button', {
@@ -344,12 +329,10 @@ describe('ExplorerOptionWrapper', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
// Wait for the dashboard select to render AND finish loading (see note above).
// Wait for the dashboard select dropdown to render inside the dialog
const modal = screen.getByRole('dialog');
await waitFor(() => {
const combobox = modal.querySelector('[role="combobox"]');
expect(combobox).toBeTruthy();
expect(combobox).not.toBeDisabled();
expect(modal.querySelector('[role="combobox"]')).toBeTruthy();
});
const dashboardSelect = modal.querySelector(
@@ -360,11 +343,11 @@ describe('ExplorerOptionWrapper', () => {
// Wait for the dropdown options to appear and select the dashboard
await waitFor(() => {
expect(screen.getByText(dashboards[0].title)).toBeInTheDocument();
expect(screen.getByText(mockDashboard.data.title)).toBeInTheDocument();
});
// Click on the dashboard option
const dashboardOption = screen.getByText(dashboards[0].title);
const dashboardOption = screen.getByText(mockDashboard.data.title);
await user.click(dashboardOption);
// Wait for the selection to be made and the export button to be enabled
@@ -380,7 +363,7 @@ describe('ExplorerOptionWrapper', () => {
await waitFor(() => {
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/dashboard/${TEST_DASHBOARD_ID}/new?graphType=${panelTypeParam}&widgetId=${widgetId}&compositeQuery=${encodeURIComponent(
`/dashboard/test-dashboard-id/new?graphType=${panelTypeParam}&widgetId=${widgetId}&compositeQuery=${encodeURIComponent(
JSON.stringify(query),
)}`,
);
@@ -389,23 +372,23 @@ describe('ExplorerOptionWrapper', () => {
// Assert that useUpdateDashboard was NOT called (as per PR #8029)
expect(mockMutate).not.toHaveBeenCalled();
});
});
it('should not show export buttons when component is disabled', () => {
const testOnExport = jest.fn() as jest.MockedFunction<
(
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void
>;
it('should not show export buttons when component is disabled', () => {
const testOnExport = jest.fn() as jest.MockedFunction<
(
dashboard: ExportDashboard | null,
isNewDashboard?: boolean,
queryToExport?: Query,
) => void
>;
renderExplorerOptionWrapper({ disabled: true, onExport: testOnExport });
renderExplorerOptionWrapper({ disabled: true, onExport: testOnExport });
// The "Add to Dashboard" button should be disabled
const addToDashboardButton = screen.getByRole('button', {
name: ADD_TO_DASHBOARD_BUTTON_NAME,
// The "Add to Dashboard" button should be disabled
const addToDashboardButton = screen.getByRole('button', {
name: ADD_TO_DASHBOARD_BUTTON_NAME,
});
expect(addToDashboardButton).toBeDisabled();
});
expect(addToDashboardButton).toBeDisabled();
});
});

View File

@@ -9,6 +9,8 @@ import {
DashboardtypesListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
import Card from 'periscope/components/Card/Card';
@@ -20,7 +22,7 @@ import dialsUrl from '@/assets/Icons/dials.svg';
import { getItemIcon } from '../constants';
// The five most-recent dashboards from the V2 list API.
// The five most-recent dashboards, normalised across the v1 and v2 list APIs.
interface RecentDashboard {
id: string;
title: string;
@@ -36,27 +38,52 @@ export default function Dashboards({
}): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { user } = useAppContext();
const isDashboardV2 = useIsDashboardV2();
// Fetch the recent dashboards from whichever API the `use_dashboard_v2` flag
// selects; the inactive one stays disabled so it never fires.
const {
data: v1List,
isLoading: v1Loading,
isError: v1Error,
} = useGetAllDashboard({ enabled: !isDashboardV2 });
const {
data: dashboardsList,
isLoading: isDashboardListLoading,
isError: isDashboardListError,
} = useListDashboardsForUserV2({
sort: DashboardtypesListSortDTO.updated_at,
order: DashboardtypesListOrderDTO.desc,
limit: 5,
offset: 0,
});
data: v2List,
isLoading: v2Loading,
isError: v2Error,
} = useListDashboardsForUserV2(
{
sort: DashboardtypesListSortDTO.updated_at,
order: DashboardtypesListOrderDTO.desc,
limit: 5,
offset: 0,
},
{ query: { enabled: isDashboardV2 } },
);
const sortedDashboards = useMemo<RecentDashboard[]>(
() =>
(dashboardsList?.data?.dashboards ?? []).map((d) => ({
const isDashboardListLoading = isDashboardV2 ? v2Loading : v1Loading;
const isDashboardListError = isDashboardV2 ? v2Error : v1Error;
const sortedDashboards = useMemo<RecentDashboard[]>(() => {
if (isDashboardV2) {
return (v2List?.data?.dashboards ?? []).map((d) => ({
id: d.id,
title: d.spec?.display?.name ?? d.name,
tags: (d.tags ?? []).map((t) => (t.value ? `${t.key}:${t.value}` : t.key)),
})),
[dashboardsList],
);
}));
}
return [...(v1List?.data ?? [])]
.sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
)
.slice(0, 5)
.map((d) => ({
id: d.id,
title: d.data.title,
tags: d.data.tags ?? [],
}));
}, [isDashboardV2, v1List, v2List]);
useEffect(() => {
if (sortedDashboards.length > 0 && !loadingUserPreferences) {

View File

@@ -2,9 +2,15 @@ import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { createDashboardV2 } from 'api/generated/services/dashboard';
import createDashboardV1 from 'api/v1/dashboards/create';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { Dashboard } from 'types/api/dashboard/getAll';
import { useCreateExportDashboard } from '../useCreateExportDashboard';
jest.mock('hooks/useIsDashboardV2');
jest.mock('api/v1/dashboards/create');
jest.mock('api/generated/services/dashboard', () => ({
createDashboardV2: jest.fn(),
}));
@@ -14,6 +20,10 @@ jest.mock('providers/ErrorModalProvider', () => ({
}),
}));
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
typeof useIsDashboardV2
>;
const mockCreateV1 = createDashboardV1 as jest.Mock;
const mockCreateV2 = createDashboardV2 as jest.Mock;
function wrapper({ children }: { children: ReactNode }): JSX.Element {
@@ -30,7 +40,35 @@ beforeEach(() => {
});
describe('useCreateExportDashboard', () => {
it('creates via the V2 Perses endpoint and normalizes the response', async () => {
it('creates via the V1 endpoint and returns the created dashboard when the flag is off', async () => {
mockUseIsDashboardV2.mockReturnValue(false);
const v1Dashboard = {
id: 'v1-new',
data: { title: TITLE },
} as unknown as Dashboard;
mockCreateV1.mockResolvedValue({ httpStatusCode: 200, data: v1Dashboard });
const onCreated = jest.fn();
const { result } = renderHook(
() => useCreateExportDashboard({ title: TITLE, onCreated }),
{ wrapper },
);
act(() => result.current.create());
await waitFor(() =>
expect(onCreated).toHaveBeenCalledWith({ id: 'v1-new', title: TITLE }),
);
expect(mockCreateV1).toHaveBeenCalledWith({
title: TITLE,
uploadedGrafana: false,
version: ENTITY_VERSION_V5,
});
expect(mockCreateV2).not.toHaveBeenCalled();
});
it('creates via the V2 Perses endpoint and normalizes the response when the flag is on', async () => {
mockUseIsDashboardV2.mockReturnValue(true);
mockCreateV2.mockResolvedValue({ data: { id: 'v2-new' } });
const onCreated = jest.fn();
@@ -50,5 +88,6 @@ describe('useCreateExportDashboard', () => {
spec: expect.objectContaining({ display: { name: TITLE } }),
}),
);
expect(mockCreateV1).not.toHaveBeenCalled();
});
});

View File

@@ -1,18 +1,44 @@
import { renderHook } from '@testing-library/react';
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { Dashboard } from 'types/api/dashboard/getAll';
import { useExportDashboards } from '../useExportDashboards';
jest.mock('hooks/useIsDashboardV2');
jest.mock('hooks/dashboard/useGetAllDashboard');
jest.mock('api/generated/services/dashboard', () => ({
useListDashboardsForUserV2: jest.fn(),
}));
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
typeof useIsDashboardV2
>;
const mockUseGetAllDashboard = useGetAllDashboard as jest.Mock;
const mockUseListV2 = useListDashboardsForUserV2 as jest.Mock;
const v1Refetch = jest.fn();
const v2Refetch = jest.fn();
const v1Dashboard = {
id: 'v1-1',
data: { title: 'V1 Dashboard' },
} as unknown as Dashboard;
const v1Other = {
id: 'v1-2',
data: { title: 'Other board' },
} as unknown as Dashboard;
beforeEach(() => {
jest.clearAllMocks();
mockUseGetAllDashboard.mockReturnValue({
data: { data: [v1Dashboard, v1Other] },
isLoading: false,
isFetching: false,
refetch: v1Refetch,
});
mockUseListV2.mockReturnValue({
data: {
data: {
@@ -32,21 +58,54 @@ beforeEach(() => {
});
describe('useExportDashboards', () => {
it('returns the V2 list normalized to the export shape', () => {
it('returns the V1 list and disables the V2 query when the flag is off', () => {
mockUseIsDashboardV2.mockReturnValue(false);
const { result } = renderHook(() => useExportDashboards());
expect(result.current.dashboards).toStrictEqual([
{ id: 'v1-1', title: 'V1 Dashboard' },
{ id: 'v1-2', title: 'Other board' },
]);
expect(mockUseGetAllDashboard).toHaveBeenCalledWith({ enabled: true });
expect(mockUseListV2).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
query: { enabled: false, keepPreviousData: true },
}),
);
});
it('filters the V1 list in memory by title (case-insensitive)', () => {
mockUseIsDashboardV2.mockReturnValue(false);
const { result } = renderHook(() => useExportDashboards('v1 dash'));
expect(result.current.dashboards).toStrictEqual([
{ id: 'v1-1', title: 'V1 Dashboard' },
]);
});
it('returns the V2 list normalized to the export shape when the flag is on', () => {
mockUseIsDashboardV2.mockReturnValue(true);
const { result } = renderHook(() => useExportDashboards());
expect(result.current.dashboards).toStrictEqual([
{ id: 'v2-1', title: 'V2 Dashboard' },
]);
expect(mockUseGetAllDashboard).toHaveBeenCalledWith({ enabled: false });
expect(mockUseListV2).toHaveBeenCalledWith(
expect.objectContaining({ query: undefined }),
expect.objectContaining({
query: { keepPreviousData: true },
query: { enabled: true, keepPreviousData: true },
}),
);
});
it('passes the search term as a name-contains filter clause to the V2 query param', () => {
mockUseIsDashboardV2.mockReturnValue(true);
renderHook(() => useExportDashboards('payments'));
expect(mockUseListV2).toHaveBeenCalledWith(
@@ -55,10 +114,12 @@ describe('useExportDashboards', () => {
);
});
it('refetches the V2 source', () => {
it('refetches the active source', () => {
mockUseIsDashboardV2.mockReturnValue(true);
const { result } = renderHook(() => useExportDashboards());
result.current.refetch();
expect(v2Refetch).toHaveBeenCalledTimes(1);
expect(v1Refetch).not.toHaveBeenCalled();
});
});

View File

@@ -1,9 +1,15 @@
import { renderHook } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { useGetExportToDashboardLink } from '../useGetExportToDashboardLink';
jest.mock('hooks/useIsDashboardV2');
const mockUseIsDashboardV2 = useIsDashboardV2 as jest.MockedFunction<
typeof useIsDashboardV2
>;
const query = { id: 'q1', queryType: 'builder' } as unknown as Query;
const params = {
dashboardId: 'dash-1',
@@ -13,7 +19,21 @@ const params = {
};
describe('useGetExportToDashboardLink', () => {
it('builds a V2 panel/new link (ignoring widgetId)', () => {
it('builds a V1 new-widget link when the dashboard-v2 flag is off', () => {
mockUseIsDashboardV2.mockReturnValue(false);
const { result } = renderHook(() => useGetExportToDashboardLink());
const link = result.current(params);
expect(link?.startsWith('/dashboard/dash-1/new?')).toBe(true);
expect(link).toContain('graphType=');
expect(link).toContain('widgetId=w1');
expect(link).toContain('compositeQuery=');
});
it('builds a V2 panel/new link (ignoring widgetId) when the flag is on', () => {
mockUseIsDashboardV2.mockReturnValue(true);
const { result } = renderHook(() => useGetExportToDashboardLink());
const link = result.current(params);

View File

@@ -1,6 +1,9 @@
import { useCallback } from 'react';
import { useMutation } from 'react-query';
import { createDashboardV2 } from 'api/generated/services/dashboard';
import createDashboardV1 from 'api/v1/dashboards/create';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
@@ -17,13 +20,14 @@ interface UseCreateExportDashboardResult {
}
/**
* "Create a new dashboard to export into". Uses the Perses-spec `createDashboardV2`
* and normalizes to an `ExportDashboard`.
* Flag-aware "create a new dashboard to export into". V2 uses the Perses-spec
* `createDashboardV2`; V1 uses the legacy create. Both normalize to an `ExportDashboard`.
*/
export function useCreateExportDashboard({
title,
onCreated,
}: UseCreateExportDashboardParams): UseCreateExportDashboardResult {
const isDashboardV2 = useIsDashboardV2();
const { showErrorModal } = useErrorModal();
const onError = useCallback(
@@ -31,7 +35,16 @@ export function useCreateExportDashboard({
[showErrorModal],
);
const createDashboardMutation = useMutation(
const v1 = useMutation(createDashboardV1, {
onSuccess: (data) => {
if (data.data) {
onCreated({ id: data.data.id, title: data.data.data.title ?? '' });
}
},
onError,
});
const v2 = useMutation(
() =>
createDashboardV2({
schemaVersion: 'v6',
@@ -53,11 +66,15 @@ export function useCreateExportDashboard({
);
const create = useCallback((): void => {
createDashboardMutation.mutate();
}, [createDashboardMutation]);
if (isDashboardV2) {
v2.mutate();
} else {
v1.mutate({ title, uploadedGrafana: false, version: ENTITY_VERSION_V5 });
}
}, [isDashboardV2, v1, v2, title]);
return {
create,
isLoading: createDashboardMutation.isLoading,
isLoading: isDashboardV2 ? v2.isLoading : v1.isLoading,
};
}

View File

@@ -1,12 +1,15 @@
import { useCallback, useMemo } from 'react';
import { useListDashboardsForUserV2 } from 'api/generated/services/dashboard';
import { DashboardtypesListedDashboardForUserV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import useDebounce from 'hooks/useDebounce';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { Dashboard } from 'types/api/dashboard/getAll';
const V2_LIST_LIMIT = 1000;
const SEARCH_DEBOUNCE_MS = 300;
/** Neutral id+title the picker uses in place of the dashboard entity. */
/** Neutral id+title the picker uses in place of the V1/V2 dashboard entity. */
export interface ExportDashboard {
id: string;
title: string;
@@ -21,12 +24,29 @@ export interface UseExportDashboardsResult {
refetch: () => void;
}
function toExportDashboard(
function fromV2(
item: DashboardtypesListedDashboardForUserV2DTO,
): ExportDashboard {
return { id: item.id, title: item.spec.display?.name || item.name };
}
function fromV1(dashboard: Dashboard): ExportDashboard {
return { id: dashboard.id, title: dashboard.data.title ?? '' };
}
function filterByTitle(
dashboards: ExportDashboard[],
search: string,
): ExportDashboard[] {
const term = search.trim().toLowerCase();
if (!term) {
return dashboards;
}
return dashboards.filter((dashboard) =>
dashboard.title.toLowerCase().includes(term),
);
}
// The V2 list `query` is a filter DSL (`key OP value`), not free text — wrap a typed term
// as a name-contains clause (single quotes escaped).
function toNameQuery(search: string): string | undefined {
@@ -34,28 +54,37 @@ function toNameQuery(search: string): string | undefined {
return term ? `name CONTAINS '${term.replace(/'/g, "\\'")}'` : undefined;
}
/** Picker source: searches the V2 dashboard list server-side (debounced). */
/** Flag-aware picker source: V2 searches server-side (debounced), V1 filters in memory. */
export function useExportDashboards(search = ''): UseExportDashboardsResult {
const isDashboardV2 = useIsDashboardV2();
const debouncedSearch = useDebounce(search, SEARCH_DEBOUNCE_MS);
const listQuery = useListDashboardsForUserV2(
const v1 = useGetAllDashboard({ enabled: !isDashboardV2 });
const v2 = useListDashboardsForUserV2(
{ limit: V2_LIST_LIMIT, query: toNameQuery(debouncedSearch) },
{ query: { keepPreviousData: true } },
{ query: { enabled: isDashboardV2, keepPreviousData: true } },
);
const dashboards = useMemo<ExportDashboard[]>(
() => (listQuery.data?.data?.dashboards ?? []).map(toExportDashboard),
[listQuery.data],
() =>
isDashboardV2
? (v2.data?.data?.dashboards ?? []).map(fromV2)
: filterByTitle((v1.data?.data ?? []).map(fromV1), search),
[isDashboardV2, v1.data, v2.data, search],
);
const refetch = useCallback((): void => {
void listQuery.refetch();
}, [listQuery]);
if (isDashboardV2) {
void v2.refetch();
} else {
void v1.refetch();
}
}, [isDashboardV2, v1, v2]);
return {
dashboards,
isLoading: listQuery.isLoading,
isFetching: listQuery.isFetching,
isLoading: isDashboardV2 ? v2.isLoading : v1.isLoading,
isFetching: isDashboardV2 ? v2.isFetching : v1.isFetching,
refetch,
};
}

View File

@@ -1,7 +1,9 @@
import { useCallback } from 'react';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import { buildExportPanelLink } from 'pages/DashboardPageV2/DashboardContainer/PanelEditor/newPanelRoute';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
interface ExportToDashboardLinkParams {
dashboardId: string;
@@ -11,15 +13,25 @@ interface ExportToDashboardLinkParams {
}
/**
* "Add to Dashboard" link builder for the explorers. Targets the V2 panel editor;
* `null` when the panel type has no V2 kind, so callers skip navigation.
* Flag-aware "Add to Dashboard" link builder for the explorers. V2 targets the panel
* editor; V1 uses the legacy new-widget link. `null` (V2 only) when the panel type has no
* V2 kind, so callers skip navigation.
*/
export function useGetExportToDashboardLink(): (
params: ExportToDashboardLinkParams,
) => string | null {
const isDashboardV2 = useIsDashboardV2();
return useCallback(
({ dashboardId, panelType, query }: ExportToDashboardLinkParams) =>
buildExportPanelLink({ dashboardId, panelType, query }),
[],
({ dashboardId, panelType, query, widgetId }: ExportToDashboardLinkParams) =>
isDashboardV2
? buildExportPanelLink({ dashboardId, panelType, query })
: generateExportToDashboardLink({
query,
panelType,
dashboardId,
widgetId,
}),
[isDashboardV2],
);
}

View File

@@ -0,0 +1,10 @@
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
export function useIsDashboardV2(): boolean {
const { featureFlags } = useAppContext();
return Boolean(
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_DASHBOARD_V2)
?.active,
);
}

View File

@@ -1,7 +1,15 @@
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import DashboardPageV2 from 'pages/DashboardPageV2';
import DashboardPage from './DashboardPage';
// Serves the V2 dashboard detail page when the `use_dashboard_v2` flag is active;
// otherwise the existing V1 page. Lets V2 dark-ship behind the flag without
// changing route definitions.
function DashboardPageEntry(): JSX.Element {
return <DashboardPageV2 />;
const isDashboardV2 = useIsDashboardV2();
return isDashboardV2 ? <DashboardPageV2 /> : <DashboardPage />;
}
export default DashboardPageEntry;

View File

@@ -113,15 +113,3 @@
align-items: center;
gap: 4px;
}
// Hidden tags revealed on hovering the `+N` badge: the same chips as inline, one per
// line (easier to scan than a wrapped cloud) and width-capped so a long tag wraps
// instead of stretching the tooltip off-screen.
.overflowTags {
display: flex;
max-width: 360px;
flex-direction: column;
align-items: flex-start;
gap: 4px;
overflow-wrap: anywhere;
}

View File

@@ -20,9 +20,6 @@ import { linkifyText } from 'utils/linkifyText';
import { openInNewTab } from 'utils/navigation';
import styles from './DashboardInfo.module.scss';
import { TOOLTIP_SCROLL_CONTENT_CLASS } from 'components/TooltipScrollArea/TooltipScrollArea';
import TagsOverflowTooltip from './TagsOverflowTooltip';
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
import { useDashboardStore } from '../../store/useDashboardStore';
@@ -234,10 +231,7 @@ function DashboardInfo({
<TagBadge key={tag}>{tag}</TagBadge>
))}
{remainingTags.length > 0 && (
<TooltipSimple
title={<TagsOverflowTooltip tags={remainingTags} />}
tooltipContentProps={{ className: TOOLTIP_SCROLL_CONTENT_CLASS }}
>
<TooltipSimple title={remainingTags.join(', ')}>
<span data-testid="dashboard-tags-overflow">
<TagBadge>+{remainingTags.length}</TagBadge>
</span>

View File

@@ -1,23 +0,0 @@
import TagBadge from 'components/TagBadge/TagBadge';
import TooltipScrollArea from 'components/TooltipScrollArea/TooltipScrollArea';
import styles from './DashboardInfo.module.scss';
interface TagsOverflowTooltipProps {
/** The tags the cluster isn't showing inline. */
tags: string[];
}
function TagsOverflowTooltip({ tags }: TagsOverflowTooltipProps): JSX.Element {
return (
<TooltipScrollArea>
<div className={styles.overflowTags} data-testid="dashboard-tags-tooltip">
{tags.map((tag) => (
<TagBadge key={tag}>{tag}</TagBadge>
))}
</div>
</TooltipScrollArea>
);
}
export default TagsOverflowTooltip;

View File

@@ -1,21 +0,0 @@
import { render, screen } from '@testing-library/react';
import TagsOverflowTooltip from '../TagsOverflowTooltip';
describe('TagsOverflowTooltip', () => {
it('renders every hidden tag as its own chip', () => {
render(
<TagsOverflowTooltip tags={['production', 'team-checkout', 'tier-1']} />,
);
const chips = screen
.getByTestId('dashboard-tags-tooltip')
.querySelectorAll('[data-slot="badge"]');
expect(Array.from(chips).map((chip) => chip.textContent)).toStrictEqual([
'production',
'team-checkout',
'tier-1',
]);
});
});

View File

@@ -2,7 +2,7 @@
from the collapsible config sections above by the same hairline divider. */
.divider {
height: 1px;
background: var(--l1-border);
background: var(--l2-border);
margin: 18px 0;
}

View File

@@ -1,4 +1,4 @@
import { Flame } from '@signozhq/icons';
import { Bell } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { useCreateAlertFromPanel } from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel';
@@ -38,7 +38,7 @@ function ConfigActions({
<div className={styles.list}>
<ConfigActionRow
testId="panel-editor-v2-create-alert"
icon={<Flame size={14} />}
icon={<Bell size={14} />}
label="Create alert"
onClick={(): void => createAlert(panel, panelId)}
/>

View File

@@ -1,5 +1,3 @@
@use '../../../../../styles/scrollbar' as *;
.config {
display: flex;
flex-direction: column;
@@ -8,24 +6,33 @@
background-color: var(--l1-background);
overflow-y: auto;
overflow-x: hidden;
padding-bottom: 44px;
@include custom-scrollbar;
//TODO: replace this with custom-scrollbar mixin
// Thin, unobtrusive scrollbar (replaces the chunky native bar).
$thumb: color-mix(in srgb, var(--bg-vanilla-100) 16%, transparent);
scrollbar-width: thin;
scrollbar-color: $thumb transparent;
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: $thumb;
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
}
.heading {
padding: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.marker {
display: inline-block;
width: 8px;
height: 4px;
border-radius: 20px;
background-color: var(--primary);
margin-bottom: 18px;
padding: 16px 16px 0 16px;
}
.title {
@@ -42,10 +49,11 @@
.eyebrow {
display: block;
margin: 0 2px 10px;
font-size: 11px;
font-weight: 600;
padding: 16px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--l1-foreground);
}
@@ -53,7 +61,7 @@
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
padding: 0 16px;
}
.field {
@@ -63,19 +71,20 @@
}
.divider {
// flex-shrink:0 keeps the 1px line from collapsing to 0 once the pane
// content overflows and the flex column starts shrinking its children.
flex-shrink: 0;
height: 1px;
background: var(--l1-border);
background: var(--l2-border);
margin: 18px 0;
}
.sectionsContainer {
padding: 0 16px;
}
.sections {
display: flex;
flex-direction: column;
& > * {
padding: 0 16px;
border-top: 1px solid var(--l1-border);
& > * + * {
border-top: 1px solid var(--l2-border);
}
}

View File

@@ -77,10 +77,8 @@ function ConfigPane({
return (
<div className={styles.config}>
<header className={styles.heading}>
<span className={styles.marker} />
<Typography.Text>Panel Details</Typography.Text>
<Typography.Text>Panel settings</Typography.Text>
</header>
<div className={styles.divider} />
<div className={styles.group}>
<div className={styles.field}>
@@ -110,7 +108,7 @@ function ConfigPane({
<>
<div className={styles.divider} />
<div className={styles.sectionsContainer}>
<span className={styles.eyebrow}>DISPLAY OPTIONS</span>
<span className={styles.eyebrow}>Display</span>
<div className={styles.sections}>
{sections.map((config) => (
<SectionSlot

View File

@@ -1,39 +0,0 @@
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
interface SectionHeaderQuickAddConfig {
label: string;
testId: string;
}
interface SectionHeaderQuickAddProps {
action: SectionHeaderQuickAddConfig;
/** Expands the section and runs the editor's registered add handler. */
onClick: () => void;
}
/** Quick-add control rendered in a configuration section's header, beside the chevron. */
function SectionHeaderQuickAdd({
action,
onClick,
}: SectionHeaderQuickAddProps): JSX.Element {
return (
<TooltipSimple title="Quick Add" side="top" arrow>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
aria-label={action.label}
// Not `testId`: TooltipTrigger's Slot merge overwrites it with undefined.
data-testid={action.testId}
onClick={onClick}
>
<Plus size={15} />
</Button>
</TooltipSimple>
);
}
export default SectionHeaderQuickAdd;

View File

@@ -1,4 +1,3 @@
import { type ReactNode, useCallback, useRef, useState } from 'react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import {
type PanelFormattingSlice,
@@ -10,41 +9,19 @@ import {
import type { SectionEditorContext } from '../sectionContext';
import { resolveSectionEditor } from '../sectionRegistry';
import SettingsSection from '../SettingsSection/SettingsSection';
import SectionHeaderQuickAdd from './SectionHeaderQuickAdd';
// `yAxisUnit` is derived from the spec below, not forwarded, so it's omitted.
type SectionSlotProps = {
config: SectionConfig;
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
} & Omit<SectionEditorContext, 'yAxisUnit' | 'registerHeaderAction'>;
// Per-section header content; `trigger` expands the section and runs the editor's handler.
const SECTION_HEADER_SLOT: Partial<
Record<SectionKind, (trigger: () => void) => ReactNode>
> = {
[SectionKind.Thresholds]: (trigger): ReactNode => (
<SectionHeaderQuickAdd
action={{
label: 'Add Threshold',
testId: 'panel-editor-v2-add-threshold-header',
}}
onClick={trigger}
/>
),
[SectionKind.ContextLinks]: (trigger): ReactNode => (
<SectionHeaderQuickAdd
action={{
label: 'Add Context Link',
testId: 'panel-editor-v2-add-link-header',
}}
onClick={trigger}
/>
),
};
} & Omit<SectionEditorContext, 'yAxisUnit'>;
/**
* Renders one configuration section: its collapsible wrapper plus the registered editor
* for `config.kind`. Renders nothing when the kind has no editor yet.
* for `config.kind`, wired through the registry's spec lens. Renders nothing when the
* kind has no editor yet (sections roll out incrementally), so a kind can declare a
* section before its editor exists.
*/
function SectionSlot({
config,
@@ -59,43 +36,13 @@ function SectionSlot({
stepInterval,
metricUnit,
}: SectionSlotProps): JSX.Element | null {
const editor = resolveSectionEditor(config.kind);
// Controlled so the header slot can expand on click; list sections open when populated.
const [open, setOpen] = useState(() => {
if (config.kind === SectionKind.Visualization) {
return true;
}
const value = editor?.get(spec);
return Array.isArray(value) && value.length > 0;
});
// The editor mounts only while open, so a collapsed-click defers the handler until it registers.
const actionHandlerRef = useRef<(() => void) | null>(null);
const pendingActionRef = useRef(false);
const registerHeaderAction = useCallback(
(handler: (() => void) | null): void => {
actionHandlerRef.current = handler;
if (handler && pendingActionRef.current) {
pendingActionRef.current = false;
handler();
}
},
[],
);
const triggerHeaderAction = useCallback((): void => {
setOpen(true);
if (actionHandlerRef.current) {
actionHandlerRef.current();
} else {
pendingActionRef.current = true;
}
}, []);
// A kind can hide a section based on current spec state (e.g. Histogram legend once
// queries are merged) — skip it before resolving the editor.
if (config.isHidden?.(spec)) {
return null;
}
const editor = resolveSectionEditor(config.kind);
if (!editor) {
return null;
}
@@ -104,19 +51,17 @@ function SectionSlot({
const { Component, get, update } = editor;
// Atomic sections carry no `controls`; controlled ones do.
const controls = 'controls' in config ? config.controls : undefined;
// Forwarded to editors that scope to the panel's unit (e.g. the thresholds unit picker).
// The panel's formatting unit, forwarded to editors that scope to it (thresholds
// restrict their unit picker to this unit's category, as in V1).
const yAxisUnit = (spec.plugin.spec as { formatting?: PanelFormattingSlice })
.formatting?.unit;
const headerSlot = SECTION_HEADER_SLOT[config.kind]?.(triggerHeaderAction);
return (
<SettingsSection
title={title}
icon={<Icon size={15} />}
open={open}
onOpenChange={setOpen}
headerSlot={headerSlot}
// Open Visualization by default so the type switcher is visible.
defaultOpen={config.kind === SectionKind.Visualization}
>
<Component
value={get(spec)}
@@ -131,7 +76,6 @@ function SectionSlot({
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
registerHeaderAction={registerHeaderAction}
/>
</SettingsSection>
);

View File

@@ -1,72 +0,0 @@
import { useState } from 'react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import {
type SectionConfig,
SectionKind,
ThresholdVariant,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import { render, screen, userEvent } from 'tests/test-utils';
import SectionSlot from '../SectionSlot';
const THRESHOLDS_CONFIG: SectionConfig = {
kind: SectionKind.Thresholds,
controls: { variant: ThresholdVariant.LABEL },
};
function makeSpec(thresholds: unknown[] = []): DashboardtypesPanelSpecDTO {
return {
display: { name: 'CPU' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: { thresholds } },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
}
// Stateful harness so onChange feeds back into the spec (as ConfigPane owns it).
function Harness({ initial = [] }: { initial?: unknown[] } = {}): JSX.Element {
const [spec, setSpec] = useState<DashboardtypesPanelSpecDTO>(
makeSpec(initial),
);
return (
<SectionSlot config={THRESHOLDS_CONFIG} spec={spec} onChangeSpec={setSpec} />
);
}
describe('SectionSlot header action', () => {
it('shows the header "+" while the section is collapsed', () => {
render(<Harness />);
// Collapsed: body (inline add) hidden, but the header quick-add is available.
expect(
screen.queryByTestId('panel-editor-v2-add-threshold'),
).not.toBeInTheDocument();
expect(
screen.getByTestId('panel-editor-v2-add-threshold-header'),
).toBeInTheDocument();
});
it('starts expanded when the section already has items', () => {
render(
<Harness initial={[{ value: 80, color: '#F5B225', label: 'High' }]} />,
);
// Body is shown on mount (no header click needed) because content exists.
expect(
screen.getByTestId('panel-editor-v2-add-threshold'),
).toBeInTheDocument();
expect(screen.getByText('High')).toBeInTheDocument();
});
it('expands the section and adds a threshold when the header "+" is clicked', async () => {
const user = userEvent.setup();
render(<Harness />);
await user.click(screen.getByTestId('panel-editor-v2-add-threshold-header'));
// Expanded, with a fresh row opened in edit mode.
expect(
screen.getByTestId('panel-editor-v2-add-threshold'),
).toBeInTheDocument();
expect(screen.getByTestId('threshold-value-0')).toBeInTheDocument();
});
});

View File

@@ -1,19 +1,10 @@
.header {
display: flex;
align-items: center;
gap: 6px;
gap: 11px;
width: 100%;
height: 44px;
}
// Disclosure control (icon tile + title); fills the row so the action slot and chevron sit right.
.toggle {
display: flex;
flex: 1;
align-items: center;
gap: 11px;
min-width: 0;
padding: 0 !important;
padding: 0 4px;
border: none;
background: transparent;
cursor: pointer;
@@ -46,6 +37,8 @@
}
.chevron {
flex: none;
color: var(--l2-border);
transition: transform 0.15s ease;
&.open {

View File

@@ -1,6 +1,5 @@
import { type ReactNode, useState } from 'react';
import { ChevronDown } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
@@ -10,74 +9,45 @@ interface SettingsSectionProps {
title: string;
icon?: ReactNode;
defaultOpen?: boolean;
/** Controlled open state; when set, the section defers to `onOpenChange`. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** Rendered between the title and the chevron. */
headerSlot?: ReactNode;
children: ReactNode;
}
/**
* Collapsible container for one configuration section in the V2 panel editor's ConfigPane.
* Collapsible container for one configuration section in the V2 panel editor's
* ConfigPane. Header shows an icon tile (accented when expanded), the title, and a
* rotating chevron; sections are separated by hairline dividers (no surrounding boxes),
* matching the Configure-panel design.
*/
function SettingsSection({
title,
icon,
defaultOpen = false,
open,
onOpenChange,
headerSlot,
children,
}: SettingsSectionProps): JSX.Element {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = open !== undefined;
const isOpen = isControlled ? open : internalOpen;
const toggle = (): void => {
const next = !isOpen;
if (!isControlled) {
setInternalOpen(next);
}
onOpenChange?.(next);
};
const [isOpen, setIsOpen] = useState(defaultOpen);
const serializedTitle = title.toLowerCase().replace(/\s+/g, '-');
return (
<section className={styles.section}>
<div className={styles.header}>
<button
type="button"
className={styles.toggle}
aria-expanded={isOpen}
data-testid={`config-section-${serializedTitle}`}
onClick={toggle}
>
{icon && (
<span className={cx(styles.iconTile, { [styles.iconTileOpen]: isOpen })}>
{icon}
</span>
)}
<Typography.Text className={styles.title}>{title}</Typography.Text>
</button>
{headerSlot}
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
prefix={
<ChevronDown
size={15}
className={cx(styles.chevron, { [styles.open]: isOpen })}
/>
}
aria-label={isOpen ? `Collapse ${title}` : `Expand ${title}`}
tabIndex={-1}
onClick={toggle}
<button
type="button"
className={styles.header}
aria-expanded={isOpen}
data-testid={`config-section-${serializedTitle}`}
onClick={(): void => setIsOpen((prev) => !prev)}
>
{icon && (
<span className={cx(styles.iconTile, { [styles.iconTileOpen]: isOpen })}>
{icon}
</span>
)}
<Typography.Text className={styles.title}>{title}</Typography.Text>
<ChevronDown
size={15}
className={cx(styles.chevron, { [styles.open]: isOpen })}
/>
</div>
</button>
{isOpen && <div className={styles.body}>{children}</div>}
</section>
);

View File

@@ -1,56 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SettingsSection from '../SettingsSection';
describe('SettingsSection', () => {
it('renders an arbitrary headerSlot node beside the header', () => {
render(
<SettingsSection
title="Thresholds"
headerSlot={
<button type="button" aria-label="custom action" data-testid="my-action" />
}
>
<div>body</div>
</SettingsSection>,
);
expect(screen.getByTestId('my-action')).toBeInTheDocument();
});
it('is collapsed by default: hides the body until the header is clicked', async () => {
const user = userEvent.setup();
render(
<SettingsSection title="Thresholds">
<div data-testid="body">body</div>
</SettingsSection>,
);
expect(screen.queryByTestId('body')).not.toBeInTheDocument();
await user.click(screen.getByTestId('config-section-thresholds'));
expect(screen.getByTestId('body')).toBeInTheDocument();
});
it('defers to onOpenChange when open is controlled', async () => {
const user = userEvent.setup();
const onOpenChange = jest.fn();
const { rerender } = render(
<SettingsSection title="Thresholds" open={false} onOpenChange={onOpenChange}>
<div data-testid="body">body</div>
</SettingsSection>,
);
expect(screen.queryByTestId('body')).not.toBeInTheDocument();
await user.click(screen.getByTestId('config-section-thresholds'));
expect(onOpenChange).toHaveBeenCalledWith(true);
rerender(
<SettingsSection title="Thresholds" open onOpenChange={onOpenChange}>
<div data-testid="body">body</div>
</SettingsSection>,
);
expect(screen.getByTestId('body')).toBeInTheDocument();
});
});

View File

@@ -1,13 +1,20 @@
import { useState } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { render, screen, userEvent } from 'tests/test-utils';
import { EQueryType } from 'types/common/dashboard';
import ConfigPane from '../ConfigPane';
// The Actions group's hook navigates/logs; stub it so ConfigPane renders without a router.
jest.mock(
'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel',
() => ({
useCreateAlertFromPanel: (): jest.Mock => jest.fn(),
}),
);
function spec(unit?: string): DashboardtypesPanelSpecDTO {
return {
display: { name: 'CPU', description: 'usage' },
@@ -33,23 +40,7 @@ function renderConfigPane(
panelId: 'panel-1',
...overrides,
};
// Stateful so typed edits feed back into the spec, as the panel editor owns it.
function Harness(): JSX.Element {
const [currentSpec, setCurrentSpec] = useState(props.spec);
return (
<ConfigPane
{...props}
spec={currentSpec}
onChangeSpec={(next): void => {
props.onChangeSpec(next);
setCurrentSpec(next);
}}
/>
);
}
render(<Harness />);
render(<ConfigPane {...props} />);
return props;
}
@@ -63,15 +54,14 @@ describe('ConfigPane', () => {
);
});
it('reports title edits through onChangeSpec (into spec.display)', async () => {
const user = userEvent.setup();
it('reports title edits through onChangeSpec (into spec.display)', () => {
const { onChangeSpec } = renderConfigPane();
const title = screen.getByTestId('panel-editor-v2-title');
await user.clear(title);
await user.type(title, 'Memory');
fireEvent.change(screen.getByTestId('panel-editor-v2-title'), {
target: { value: 'Memory' },
});
expect(onChangeSpec).toHaveBeenLastCalledWith(
expect(onChangeSpec).toHaveBeenCalledWith(
expect.objectContaining({
display: { name: 'Memory', description: 'usage' },
}),

View File

@@ -1,10 +1,6 @@
// Fill the section field so the select lines up with the other full-width controls.
.select {
width: 100%;
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
}
}
.item {

View File

@@ -5,7 +5,7 @@
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--l2-border);
border-radius: 2px;
border-radius: 6px;
background: var(--l2-background-60);
}

View File

@@ -1,5 +1,3 @@
@use '../../../../../../../styles/scrollbar' as *;
.container {
display: flex;
flex-direction: column;
@@ -7,7 +5,6 @@
}
.list {
@include custom-scrollbar;
width: 100%;
}

View File

@@ -21,6 +21,4 @@ export interface SectionEditorContext {
stepInterval?: number;
/** Unit the selected metric was sent with; drives the unit selector's mismatch warning. */
metricUnit?: string;
/** An editor registers the handler its header action (e.g. a quick-add "+") triggers; `null` to clear. */
registerHeaderAction?: (handler: (() => void) | null) => void;
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import type { DashboardtypesLinkDTO } from 'api/generated/services/sigNoz.schemas';
@@ -7,7 +7,6 @@ import type {
SectionKind,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
import type { SectionEditorContext } from '../../sectionContext';
import ContextLinkDialog from './ContextLinkDialog';
import ContextLinkListItem from './ContextLinkListItem';
import { useContextLinkVariables } from './useContextLinkVariables';
@@ -22,9 +21,7 @@ import styles from './ContextLinksSection.module.scss';
function ContextLinksSection({
value,
onChange,
registerHeaderAction,
}: SectionEditorProps<SectionKind.ContextLinks> &
Pick<SectionEditorContext, 'registerHeaderAction'>): JSX.Element {
}: SectionEditorProps<SectionKind.ContextLinks>): JSX.Element {
const links = value ?? [];
const variables = useContextLinkVariables();
@@ -34,16 +31,6 @@ function ContextLinksSection({
index: null,
});
const openAddDialog = useCallback(
(): void => setDialog({ open: true, index: null }),
[],
);
useEffect(() => {
registerHeaderAction?.(openAddDialog);
return (): void => registerHeaderAction?.(null);
}, [registerHeaderAction, openAddDialog]);
const removeAt = (index: number): void =>
onChange(links.filter((_, i) => i !== index));
@@ -79,7 +66,7 @@ function ContextLinksSection({
color="secondary"
prefix={<Plus size={14} />}
data-testid="panel-editor-v2-add-link"
onClick={openAddDialog}
onClick={(): void => setDialog({ open: true, index: null })}
>
Add Context Link
</Button>

View File

@@ -8,9 +8,6 @@
:global(.ant-select) {
width: 100%;
}
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
}
}
// Stacked per-column unit pickers; each column keeps the standard field layout.

View File

@@ -96,9 +96,6 @@
:global(.ant-select) {
width: 100%;
}
:global(.ant-select-selector) {
border-color: var(--l2-border) !important;
}
}
.invalidUnit {

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import {
@@ -63,10 +63,7 @@ type ThresholdsSectionProps = {
/** `variant` picks the row editor + element shape; defaults to `label`. */
controls?: { variant?: ThresholdVariant };
onChange: (next: AnyThreshold[]) => void;
} & Pick<
SectionEditorContext,
'yAxisUnit' | 'tableColumns' | 'registerHeaderAction'
>;
} & Pick<SectionEditorContext, 'yAxisUnit' | 'tableColumns'>;
/**
* Edits the `thresholds` slice for every panel kind. All variants share the same
@@ -80,7 +77,6 @@ function ThresholdsSection({
onChange,
yAxisUnit,
tableColumns = [],
registerHeaderAction,
}: ThresholdsSectionProps): JSX.Element {
const variant = controls?.variant ?? ThresholdVariant.LABEL;
const thresholds = value ?? [];
@@ -97,17 +93,12 @@ function ThresholdsSection({
onChange(thresholds.map((t, i) => (i === index ? next : t)));
};
const addThreshold = useCallback((): void => {
const current = value ?? [];
onChange([...current, defaultThreshold(variant, tableColumns)]);
setEditingIndex(current.length);
setUnsavedIndex(current.length);
}, [value, onChange, variant, tableColumns]);
useEffect(() => {
registerHeaderAction?.(addThreshold);
return (): void => registerHeaderAction?.(null);
}, [registerHeaderAction, addThreshold]);
const addThreshold = (): void => {
const nextIndex = thresholds.length;
onChange([...thresholds, defaultThreshold(variant, tableColumns)]);
setEditingIndex(nextIndex);
setUnsavedIndex(nextIndex);
};
const beginEdit = (index: number): void => {
editSnapshot.current = thresholds[index] ?? null;

View File

@@ -5,7 +5,6 @@ import { DialogWrapper } from '@signozhq/ui/dialog';
import { Divider } from '@signozhq/ui/divider';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import { useConfirmableAction } from 'hooks/useConfirmableAction';
import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
@@ -68,11 +67,6 @@ function Header({
<Typography.Text>Configure panel</Typography.Text>
</div>
<div className={styles.actions}>
<HeaderRightSection
enableAnnouncements={false}
enableShare={false}
enableFeedback={false}
/>
{showSwitchToView && (
<Button
variant="outlined"

View File

@@ -1,69 +0,0 @@
import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import Header from '../Header';
jest.mock('hooks/useIsAIAssistantEnabled', () => ({
useIsAIAssistantEnabled: jest.fn(),
}));
jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: (): unknown => ({
isCloudUser: true,
isEnterpriseSelfHostedUser: false,
}),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockUseIsAIAssistantEnabled = useIsAIAssistantEnabled as jest.Mock;
function renderHeader(): void {
// AppLayout supplies the TooltipProvider in the app; the header is rendered bare here.
render(
<MemoryRouter>
<TooltipProvider>
<Header
isDirty={false}
isSaving={false}
onSave={jest.fn()}
onClose={jest.fn()}
/>
</TooltipProvider>
</MemoryRouter>,
);
}
describe('PanelEditor Header', () => {
afterEach(() => {
mockUseIsAIAssistantEnabled.mockReset();
});
// The editor is a full page, so the side nav's Noz entry point is gone while it is
// open — the header has to offer it instead.
it('offers Noz alongside the editor actions', () => {
mockUseIsAIAssistantEnabled.mockReturnValue(true);
renderHeader();
expect(screen.getByRole('button', { name: 'Open Noz' })).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-save')).toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-close')).toBeInTheDocument();
});
it('omits Noz when the AI assistant is disabled', () => {
mockUseIsAIAssistantEnabled.mockReturnValue(false);
renderHeader();
expect(
screen.queryByRole('button', { name: 'Open Noz' }),
).not.toBeInTheDocument();
expect(screen.getByTestId('panel-editor-v2-save')).toBeInTheDocument();
});
});

View File

@@ -9,7 +9,7 @@ import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { AllTheProviders } from 'tests/test-utils';
import { fromPerses, toPerses } from '../../../queryV5/persesQueryAdapters';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { usePanelEditorQuerySync } from '../usePanelEditorQuerySync';
// Exercises the REAL query builder provider (not mocks) so the dirty check is
@@ -45,28 +45,6 @@ function makePanel(queries: DashboardtypesQueryDTO[]): DashboardtypesPanelDTO {
} as unknown as DashboardtypesPanelDTO;
}
/** Providers whose URL already carries `query` as the compositeQuery param (a mid-edit refresh). */
function makeUrlWrapper(
query: Query,
): ({ children }: { children: React.ReactNode }) => JSX.Element {
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
return function UrlWrapper({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return (
<AllTheProviders initialRoute={`/?${params.toString()}`}>
{children}
</AllTheProviders>
);
};
}
describe('usePanelEditorQuerySync (real query builder)', () => {
it('an untouched existing panel is NOT query-dirty on mount', async () => {
const saved = makeSavedQueries();
@@ -134,80 +112,6 @@ describe('usePanelEditorQuerySync (real query builder)', () => {
expect(result.current.isQueryDirty).toBe(false);
});
it('a saved panel with no `having` is NOT dirty on mount (builder seeds an empty having)', async () => {
// Reproduces the reported bug: a panel saved as a bare signoz/BuilderQuery that never
// carried a `having`. On editor open the builder hydrates from the URL and
// prepareQueryBuilderData seeds an empty `{ expression: '' }`, so a verbatim envelope
// compare flags the untouched panel as dirty. Seed via the URL (not resetQuery) so the
// hydration path that synthesizes the having actually runs.
const savedNoHaving: DashboardtypesQueryDTO[] = [
{
kind: 'time_series',
spec: {
name: 'A',
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: 'A',
signal: 'metrics',
source: '',
aggregations: [
{
metricName: 'signoz_latency.bucket',
temporality: 'delta',
timeAggregation: '',
spaceAggregation: 'p99',
},
],
disabled: false,
filter: { expression: 'service.name IN $service_name' },
groupBy: [
{
name: 'service.name',
signal: '',
fieldContext: 'resource',
fieldDataType: '',
},
],
order: [
{
key: {
name: '__result',
signal: '',
fieldContext: '',
fieldDataType: '',
},
direction: 'desc',
},
],
limit: 100,
legend: '',
},
},
},
},
] as unknown as DashboardtypesQueryDTO[];
// The editor puts the (having-less) seed query into the URL; the provider then hydrates
// from it, which is where the empty having is added.
const seededInUrl = fromPerses(savedNoHaving, panelType);
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
draft: makePanel(savedNoHaving),
panelType,
setSpec: jest.fn(),
refetch: jest.fn(),
savedQueries: savedNoHaving,
}),
{ wrapper: makeUrlWrapper(seededInUrl) },
);
await waitFor(() => expect(result.current.isQueryDirty).toBe(false));
expect(result.current.isQueryDirty).toBe(false);
});
it('retains an in-editor query edit carried in the URL across a refresh (and reads dirty)', async () => {
// Simulate a refresh mid-edit: the saved panel is unchanged, but the URL still
// carries the last-run (edited) query. The builder must hydrate from the URL —
@@ -226,8 +130,23 @@ describe('usePanelEditorQuerySync (real query builder)', () => {
],
},
};
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(editedInUrl)),
);
const setSpec = jest.fn();
const wrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<AllTheProviders initialRoute={`/?${params.toString()}`}>
{children}
</AllTheProviders>
);
const { result } = renderHook(
() =>
usePanelEditorQuerySync({
@@ -238,7 +157,7 @@ describe('usePanelEditorQuerySync (real query builder)', () => {
refetch: jest.fn(),
savedQueries: saved,
}),
{ wrapper: makeUrlWrapper(editedInUrl) },
{ wrapper },
);
// The URL edit is retained → dirty, and it's synced into the draft so the

View File

@@ -1,24 +1,16 @@
@use '../../../../../../styles/scrollbar' as *;
// Centred, vertically-stacked panel state (no query / no data / error). Fills
// the panel body below the header and centres its content both axes. When the
// panel is too small to fit icon + text + actions, it scrolls instead of
// clipping the buttons at the edge (`safe center` keeps the top reachable).
// the panel body below the header and centres its content both axes.
.message {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: safe center;
justify-content: center;
gap: 6px;
padding: 16px;
text-align: center;
min-height: 0;
min-width: 0;
overflow: auto;
// Match the shared panel scrollbar (chart legend, table/list panels).
@include custom-scrollbar;
}
// Muted glyph in a soft tinted disc so the icon reads as decorative chrome
@@ -59,5 +51,4 @@
justify-content: center;
gap: 8px;
margin-top: 8px;
max-width: 100%;
}

View File

@@ -33,7 +33,7 @@ describe('preparePieData', () => {
]);
});
it('serialises a single group-by column as {key="value"} (V1 parity)', () => {
it('keeps one slice per group row for a single value column', () => {
const table = tableWith(
[
{
@@ -53,59 +53,8 @@ describe('preparePieData', () => {
const slices = preparePieData(args([table]));
expect(slices.map((s) => [s.label, s.value])).toStrictEqual([
['{service.name="adservice"}', 100],
['{service.name="cartservice"}', 200],
]);
});
it('serialises a grouped metrics query as {direction="write"} (V1 parity)', () => {
const table = tableWith(
[
{
name: 'direction',
queryName: 'A',
isValueColumn: false,
id: 'direction',
},
{ name: 'A', queryName: 'A', isValueColumn: true, id: 'A' },
],
[
{ data: { direction: 'write', A: 3170000000 } },
{ data: { direction: 'read', A: 10000000 } },
],
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => s.label)).toStrictEqual([
'{direction="write"}',
'{direction="read"}',
]);
});
it('substitutes a legend format template with the group-by values', () => {
const table = tableWith(
[
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
{ name: 'count', queryName: 'A', isValueColumn: true, id: 'A' },
],
[
{ data: { 'service.name': 'adservice', A: 100 } },
{ data: { 'service.name': 'cartservice', A: 200 } },
],
{ legend: 'service.name = {{service.name}}' },
);
const slices = preparePieData(args([table]));
expect(slices.map((s) => s.label)).toStrictEqual([
'service.name = adservice',
'service.name = cartservice',
['adservice', 100],
['cartservice', 200],
]);
});

View File

@@ -1,6 +1,5 @@
import { themeColors } from 'constants/theme';
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
import getLabelName from 'lib/getLabelName';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
@@ -52,8 +51,7 @@ export function preparePieData({
if (hasMultipleValueColumns) {
label = groupLabel ? `${groupLabel} · ${column.name}` : column.name;
} else {
// V1 parity: serialise group-by labels as `{key="value"}`.
label = getLabelName(labels, table.queryName || '', table.legend || '');
label = groupLabel || table.legend || table.queryName || '';
}
const color = customColors?.[label] ?? generateColor(label, colorMap);

View File

@@ -1,60 +0,0 @@
import type { PanelTableColumn } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { formatTableCellText } from '../tableColumns';
jest.mock('../../../utils/formatPanelValue', () => ({
formatPanelValue: (value: number, unit?: string): string =>
`${value}${unit ?? ''}`,
}));
const valueColumn: PanelTableColumn = {
name: 'p99',
queryName: 'A',
isValueColumn: true,
id: 'A',
};
const groupColumn: PanelTableColumn = {
name: 'service',
queryName: 'A',
isValueColumn: false,
id: 'service',
};
describe('formatTableCellText', () => {
it.each([null, undefined, ''])(
'renders empty value-column cells (%p) as n/a instead of 0',
(raw) => {
expect(formatTableCellText(valueColumn, raw, 'ms', undefined)).toBe('n/a');
},
);
it('keeps a real zero as a formatted 0, not n/a', () => {
expect(formatTableCellText(valueColumn, 0, 'ms', undefined)).toBe('0ms');
});
it('formats a numeric value column through unit + precision', () => {
expect(formatTableCellText(valueColumn, 1234, 'ms', undefined)).toBe(
'1234ms',
);
});
it('renders a non-numeric value cell as raw text', () => {
expect(formatTableCellText(valueColumn, 'n/a', 'ms', undefined)).toBe('n/a');
});
it.each([null, undefined, ''])(
'renders empty group-column cells (%p) as n/a',
(raw) => {
expect(formatTableCellText(groupColumn, raw, undefined, undefined)).toBe(
'n/a',
);
},
);
it('renders group-column labels as raw text', () => {
expect(
formatTableCellText(groupColumn, 'frontend', undefined, undefined),
).toBe('frontend');
});
});

View File

@@ -64,19 +64,6 @@ describe('buildTableCsvRows', () => {
expect(rows).toStrictEqual([{ service: 'api', p99: 'n/a' }]);
});
it('exports empty value cells as n/a rather than 0', () => {
const rows = buildTableCsvRows({
table: {
...table,
rows: [{ data: { service: 'api', A: null } }],
},
columnUnits: { A: 'ms' },
decimalPrecision: undefined,
});
expect(rows).toStrictEqual([{ service: 'api', p99: 'n/a' }]);
});
});
describe('getTableCsvRows', () => {

View File

@@ -18,17 +18,6 @@ import styles from './TablePanel.module.scss';
/** A prepared scalar-table row flattened for the antd Table, with the antd key. */
export type TableRowData = Record<string, unknown> & { key: number };
const NA_TEXT = 'n/a';
// Empty cells (null/undefined/'') aren't numbers: `Number(null)` is 0, which
// would render/colour/sort an empty cell as a real zero.
function toCellNumber(raw: unknown): number {
if (raw == null || raw === '') {
return NaN;
}
return Number(raw);
}
/**
* Groups table thresholds by the column they target, mapping each onto the
* V2-native `PanelThreshold` consumed by `resolveActiveThreshold`. A column with
@@ -54,9 +43,6 @@ export function formatTableCellText(
unit: string | undefined,
decimalPrecision?: PrecisionOption,
): string {
if (raw == null || raw === '') {
return NA_TEXT;
}
if (!col.isValueColumn) {
return coerceToString(raw);
}
@@ -70,17 +56,15 @@ export function formatTableCellText(
// Sort comparator: numeric when both cells parse as numbers (value columns and
// numeric group keys), otherwise a locale string compare. Nullish sorts last.
function compareCells(a: unknown, b: unknown): number {
const aNum = toCellNumber(a);
const bNum = toCellNumber(b);
const aNum = Number(a);
const bNum = Number(b);
if (Number.isFinite(aNum) && Number.isFinite(bNum)) {
return aNum - bNum;
}
const aEmpty = a == null || a === '';
const bEmpty = b == null || b === '';
if (aEmpty) {
return bEmpty ? 0 : 1;
if (a == null) {
return b == null ? 0 : 1;
}
if (bEmpty) {
if (b == null) {
return -1;
}
return coerceToString(a).localeCompare(coerceToString(b));
@@ -129,7 +113,7 @@ export function buildTableColumns({
compareCells(a[key], b[key]),
render: (raw: unknown): React.ReactNode => {
const text = formatTableCellText(col, raw, unit, decimalPrecision);
const num = toCellNumber(raw);
const num = Number(raw);
if (
!col.isValueColumn ||
colThresholds.length === 0 ||
@@ -147,7 +131,7 @@ export function buildTableColumns({
const cellProps: React.HTMLAttributes<HTMLElement> = {};
if (col.isValueColumn && colThresholds.length > 0) {
const num = toCellNumber(record[key]);
const num = Number(record[key]);
if (Number.isFinite(num)) {
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
if (threshold?.format === 'background') {

View File

@@ -14,16 +14,15 @@ import type {
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
Antenna,
BarChart,
Columns3,
Hash,
Link2,
Layers,
LayoutDashboard,
Link,
Palette,
PencilRuler,
Scale3D,
Signpost,
Wallpaper,
Ruler,
SlidersHorizontal,
} from '@signozhq/icons';
// Derived from an actual icon component so the type stays exact (size is a
@@ -158,14 +157,14 @@ export type SectionConfig =
// Per-section title + sidebar icon. Pure data; the editor component + spec lens
// live in the ConfigPane section registry.
export const SECTION_METADATA = {
[SectionKind.Formatting]: { title: 'Formatting & Units', icon: PencilRuler },
[SectionKind.Axes]: { title: 'Axes', icon: Scale3D },
[SectionKind.Legend]: { title: 'Legend', icon: Signpost },
[SectionKind.Formatting]: { title: 'Formatting & Units', icon: Hash },
[SectionKind.Axes]: { title: 'Axes', icon: Ruler },
[SectionKind.Legend]: { title: 'Legend', icon: Layers },
[SectionKind.ChartAppearance]: { title: 'Chart appearance', icon: Palette },
[SectionKind.Visualization]: { title: 'Visualization', icon: Wallpaper },
[SectionKind.Visualization]: { title: 'Visualization', icon: LayoutDashboard },
[SectionKind.Buckets]: { title: 'Histogram / Buckets', icon: BarChart },
[SectionKind.Thresholds]: { title: 'Thresholds', icon: Antenna },
[SectionKind.ContextLinks]: { title: 'Context Links', icon: Link2 },
[SectionKind.Thresholds]: { title: 'Thresholds', icon: SlidersHorizontal },
[SectionKind.ContextLinks]: { title: 'Context Links', icon: Link },
[SectionKind.Columns]: { title: 'Columns', icon: Columns3 },
} as const satisfies Record<SectionKind, SectionMetadata>;

View File

@@ -67,16 +67,6 @@
vertical-align: bottom;
}
// The values behind a pill's `+N`, one bullet each, width-capped so a long value
// wraps instead of stretching the tooltip off-screen.
.overflowValues {
max-width: 360px;
margin: 0;
padding-left: 14px;
list-style: disc outside;
overflow-wrap: anywhere;
}
// Shared by the Text and value selectors: strips the antd control chrome so the
// selector blends into the variable pill.
.control {
@@ -138,3 +128,13 @@
min-width: 0 !important;
}
}
.overflowTooltip {
display: flex;
flex-direction: column;
gap: 2px;
}
.overflowName {
font-weight: var(--font-weight-medium);
}

View File

@@ -9,13 +9,28 @@ import { selectVariablesExpanded } from '../store/slices/collapseSlice';
import { useDashboardStore } from '../store/useDashboardStore';
import AddVariableFull from './components/AddVariable/AddVariableFull';
import AddVariableIcon from './components/AddVariable/AddVariableIcon';
import { TOOLTIP_SCROLL_CONTENT_CLASS } from 'components/TooltipScrollArea/TooltipScrollArea';
import HiddenVariablesTooltip from './components/HiddenVariablesTooltip/HiddenVariablesTooltip';
import type { VariableSelection } from './selectionTypes';
import { useVariableSelection } from './hooks/useVariableSelection';
import VariableSelector from './components/VariableSelector/VariableSelector';
import styles from './VariablesBar.module.scss';
// Short display of a variable's current selection, for the collapsed +N tooltip.
function formatSelection(selection: VariableSelection | undefined): string {
if (!selection) {
return '—';
}
if (selection.allSelected) {
return 'ALL';
}
const { value } = selection;
if (Array.isArray(value)) {
return value.length > 0 ? value.join(', ') : '—';
}
return value === '' || value === null || value === undefined
? '—'
: String(value);
}
interface VariablesBarProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
@@ -115,12 +130,15 @@ function VariablesBar({ dashboard }: VariablesBarProps): JSX.Element | null {
) : (
<TooltipSimple
side="top"
tooltipContentProps={{ className: TOOLTIP_SCROLL_CONTENT_CLASS }}
title={
<HiddenVariablesTooltip
variables={hiddenVariables}
selections={selection}
/>
<div className={styles.overflowTooltip}>
{hiddenVariables.map((variable) => (
<div key={variable.name}>
<span className={styles.overflowName}>{variable.name}</span>:{' '}
{formatSelection(selection[variable.name])}
</div>
))}
</div>
}
>
{moreButton}

View File

@@ -1,84 +0,0 @@
import { render, screen } from '@testing-library/react';
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../DashboardSettings/Variables/variableFormModel';
import type { VariableSelectionMap } from '../selectionTypes';
import HiddenVariablesTooltip from '../components/HiddenVariablesTooltip/HiddenVariablesTooltip';
function variable(name: string): VariableFormModel {
return { ...emptyVariableFormModel(), name };
}
function renderTooltip(
names: string[],
selections: VariableSelectionMap,
): HTMLElement {
render(
<HiddenVariablesTooltip
variables={names.map(variable)}
selections={selections}
/>,
);
return screen.getByTestId('hidden-variables-tooltip');
}
describe('HiddenVariablesTooltip', () => {
it('names each hidden variable with a $ prefix, apart from its value', () => {
renderTooltip(['env', 'service'], {
env: { value: 'production', allSelected: false },
service: { value: ['checkout', 'cart'], allSelected: false },
});
expect(screen.getByText('$env')).toBeInTheDocument();
expect(screen.getByText('$service')).toBeInTheDocument();
});
it('bullets out a variable holding several values', () => {
renderTooltip(['service'], {
service: { value: ['checkout', 'cart', 'api'], allSelected: false },
});
expect(
screen.getAllByRole('listitem').map((item) => item.textContent),
).toStrictEqual(['checkout', 'cart', 'api']);
});
it('keeps a lone value unbulleted', () => {
renderTooltip(['env'], {
env: { value: 'production', allSelected: false },
});
expect(screen.queryByRole('list')).not.toBeInTheDocument();
expect(screen.getByText('production')).toBeInTheDocument();
});
it('labels all-selected and unset variables', () => {
renderTooltip(['env', 'host'], {
env: { value: null, allSelected: true },
});
expect(screen.getByText('ALL')).toBeInTheDocument();
expect(screen.getByText('—')).toBeInTheDocument();
});
it('lists every hidden variable, however many there are', () => {
const names = Array.from({ length: 12 }, (_, i) => `var${i}`);
const tooltip = renderTooltip(names, {});
expect(tooltip).toHaveTextContent('$var0');
expect(tooltip).toHaveTextContent('$var11');
});
it('bullets every value it is given, without truncating', () => {
const values = Array.from({ length: 14 }, (_, i) => `v${i}`);
renderTooltip(['service'], {
service: { value: values, allSelected: false },
});
expect(screen.getAllByRole('listitem')).toHaveLength(14);
});
});

View File

@@ -1,90 +0,0 @@
import { act, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import type { VariableSelection } from '../selectionTypes';
import ValueSelector from '../components/selectors/ValueSelector';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
const VALUES = ['checkout-service-prod', 'payments-service-prod'];
// A strict subset of the options — selecting every option renders as ALL instead.
const OPTIONS = [...VALUES, 'cart-service-prod'];
function renderSelector(
selection: VariableSelection,
options: string[],
multiSelect = true,
): void {
render(
<TooltipProvider>
<ValueSelector
options={options}
variableType="dynamic"
multiSelect={multiSelect}
showAllOption
selection={selection}
onChange={jest.fn()}
emptyFallback={{ value: [], allSelected: false }}
testId="variable-select-env"
/>
</TooltipProvider>,
);
}
/** Hovers an element and lets the tooltip's open delay elapse. */
async function hover(element: HTMLElement): Promise<void> {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await user.hover(element);
act(() => {
jest.advanceTimersByTime(500);
});
}
describe('ValueSelector', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("reveals a tag's full value on hovering that tag", async () => {
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
// maxTagCount={1} + maxTagTextLength={10} → the one visible tag is cut short.
await hover(screen.getByText('checkout-s...'));
expect(screen.getByRole('tooltip')).toHaveTextContent(
'checkout-service-prod',
);
});
it('reveals the hidden values on hovering the +N overflow', async () => {
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
await hover(screen.getByText('+1'));
expect(screen.getByRole('tooltip')).toHaveTextContent(
'payments-service-prod',
);
});
it('does not reveal anything from the rest of the control', async () => {
renderSelector({ value: VALUES, allSelected: false }, OPTIONS);
await hover(screen.getByTestId('variable-select-env'));
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument();
});
it('renders ALL for an all-selected variable', () => {
renderSelector({ value: null, allSelected: true }, ['a', 'b']);
expect(screen.getByText('ALL')).toBeInTheDocument();
});
});

View File

@@ -1,39 +0,0 @@
import { describeSelection } from '../utils/selectionDisplay';
describe('describeSelection', () => {
it('reports an all-selected variable as ALL', () => {
expect(describeSelection({ value: null, allSelected: true })).toStrictEqual({
kind: 'all',
});
});
it('lists a multi-select selection', () => {
expect(
describeSelection({ value: ['a', 'b'], allSelected: false }),
).toStrictEqual({ kind: 'values', values: ['a', 'b'] });
});
it('keeps every value, however many there are', () => {
const values = Array.from({ length: 30 }, (_, i) => `v${i}`);
expect(
describeSelection({ value: values, allSelected: false }),
).toStrictEqual({ kind: 'values', values });
});
it('lists a single value on its own', () => {
expect(
describeSelection({ value: 'production', allSelected: false }),
).toStrictEqual({ kind: 'values', values: ['production'] });
});
it('reports a missing or empty selection as empty', () => {
expect(describeSelection(undefined)).toStrictEqual({ kind: 'empty' });
expect(describeSelection({ value: '', allSelected: false })).toStrictEqual({
kind: 'empty',
});
expect(describeSelection({ value: [], allSelected: false })).toStrictEqual({
kind: 'empty',
});
});
});

View File

@@ -1,117 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import {
emptyVariableFormModel,
type VariableFormModel,
} from '../../DashboardSettings/Variables/variableFormModel';
import { VariableFetchState } from '../../store/slices/variableFetchSlice';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useFetchedVariableOptions } from '../hooks/useFetchedVariableOptions';
jest.mock('react-redux', () => ({ useSelector: jest.fn() }));
jest.mock('api/dynamicVariables/getFieldValues', () => ({
getFieldValues: jest.fn(),
}));
const mockUseSelector = useSelector as unknown as jest.Mock;
const mockGetFieldValues = getFieldValues as unknown as jest.Mock;
function fieldValues(values: string[]): unknown {
return { data: { normalizedValues: values, complete: true } };
}
/** A promise resolved by the test, so the in-flight window is deterministic. */
function deferred(): {
promise: Promise<unknown>;
resolve: (value: unknown) => void;
} {
let settle: (value: unknown) => void = () => {};
const promise = new Promise<unknown>((resolve) => {
settle = resolve;
});
return { promise, resolve: settle };
}
function dynamicVariable(name: string): VariableFormModel {
return {
...emptyVariableFormModel(),
name,
type: 'DYNAMIC',
dynamicAttribute: 'service.name',
};
}
function wrapper({ children }: { children: React.ReactNode }): JSX.Element {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
describe('useFetchedVariableOptions', () => {
beforeEach(() => {
mockUseSelector.mockImplementation((selector: (state: unknown) => unknown) =>
selector({
globalTime: {
minTime: 1_000,
maxTime: 2_000,
isAutoRefreshDisabled: true,
},
}),
);
});
afterEach(() => {
mockGetFieldValues.mockReset();
useDashboardStore.setState({
variableFetchStates: {},
variableLastUpdated: {},
variableCycleIds: {},
variableResolvedEmpty: {},
});
});
it('keeps the previous options while a new fetch cycle is in flight', async () => {
const refetch = deferred();
mockGetFieldValues
.mockResolvedValueOnce(fieldValues(['prod', 'staging']))
.mockReturnValueOnce(refetch.promise);
useDashboardStore.setState({
variableFetchStates: { env: VariableFetchState.Loading },
variableCycleIds: { env: 1 },
});
const variable = dynamicVariable('env');
const { result } = renderHook(
() => useFetchedVariableOptions(variable, [variable], {}),
{ wrapper },
);
await waitFor(() =>
expect(result.current.options).toStrictEqual(['prod', 'staging']),
);
// What a sibling dynamic's selection change does: bump the cycle id, which keys
// a fresh request. The options must not blink empty in the meantime, or an ALL
// selection (rendered from them) falls back to the placeholder.
act(() => {
useDashboardStore.setState({ variableCycleIds: { env: 2 } });
});
await waitFor(() => expect(result.current.loading).toBe(true));
expect(result.current.options).toStrictEqual(['prod', 'staging']);
await act(async () => {
refetch.resolve(fieldValues(['prod']));
await refetch.promise;
});
await waitFor(() => expect(result.current.options).toStrictEqual(['prod']));
});
});

View File

@@ -1,39 +0,0 @@
.tooltip {
display: flex;
max-width: 360px;
flex-direction: column;
gap: 8px;
}
.row {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.name {
--typography-text-display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
// A lone value, ALL, or the unset dash.
.value {
--typography-text-display: block;
font-weight: var(--font-weight-medium);
overflow-wrap: anywhere;
}
// One bullet per value when a variable holds several.
.values {
margin: 0;
padding-left: 14px;
list-style: disc outside;
}
.valueItem {
font-weight: var(--font-weight-medium);
overflow-wrap: anywhere;
}

View File

@@ -1,42 +0,0 @@
import TooltipScrollArea from 'components/TooltipScrollArea/TooltipScrollArea';
import { Typography } from '@signozhq/ui/typography';
import type { VariableFormModel } from '../../../DashboardSettings/Variables/variableFormModel';
import type { VariableSelectionMap } from '../../selectionTypes';
import SelectionValue from './SelectionValue';
import styles from './HiddenVariablesTooltip.module.scss';
interface HiddenVariablesTooltipProps {
/** The variables the collapsed bar isn't showing, in bar order. */
variables: VariableFormModel[];
selections: VariableSelectionMap;
}
/**
* Body of the collapsed bar's `+N` tooltip: what each hidden variable is set to.
* Name and value sit on their own lines — `$name` muted above its value, which
* bullets out when there are several — so the two read apart at a glance instead of
* running together as `name: value`.
* Every hidden variable is listed; the box scrolls rather than truncating.
*/
function HiddenVariablesTooltip({
variables,
selections,
}: HiddenVariablesTooltipProps): JSX.Element {
return (
<TooltipScrollArea>
<div className={styles.tooltip} data-testid="hidden-variables-tooltip">
{variables.map((variable) => (
<div className={styles.row} key={variable.name}>
<Typography.Text size="xs" color="muted" className={styles.name}>
${variable.name}
</Typography.Text>
<SelectionValue selection={selections[variable.name]} />
</div>
))}
</div>
</TooltipScrollArea>
);
}
export default HiddenVariablesTooltip;

View File

@@ -1,46 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import type { VariableSelection } from '../../selectionTypes';
import { describeSelection } from '../../utils/selectionDisplay';
import styles from './HiddenVariablesTooltip.module.scss';
interface SelectionValueProps {
selection?: VariableSelection;
}
/**
* A variable's current value as tooltip content: `ALL`, a dash when unset, the lone
* value on its own, or — when it holds several — one bullet per value so the list
* doesn't read as one run-on string.
*/
function SelectionValue({ selection }: SelectionValueProps): JSX.Element {
const display = describeSelection(selection);
if (display.kind !== 'values') {
return (
<Typography.Text size="sm" className={styles.value}>
{display.kind === 'all' ? 'ALL' : '—'}
</Typography.Text>
);
}
if (display.values.length === 1) {
return (
<Typography.Text size="sm" className={styles.value}>
{display.values[0]}
</Typography.Text>
);
}
return (
<ul className={styles.values}>
{display.values.map((value) => (
<Typography.Text asChild size="sm" className={styles.valueItem} key={value}>
<li>{value}</li>
</Typography.Text>
))}
</ul>
);
}
export default SelectionValue;

View File

@@ -1,43 +0,0 @@
import { TooltipSimple } from '@signozhq/ui/tooltip';
import TooltipScrollArea, {
TOOLTIP_SCROLL_CONTENT_CLASS,
} from 'components/TooltipScrollArea/TooltipScrollArea';
import styles from '../../VariablesBar.module.scss';
interface OverflowValuesTooltipProps {
/** The selected values the pill hides behind this `+N`. */
values: string[];
}
/**
* The multi-select's `+N` overflow item, revealing on hover the values it stands
* for — one bullet each, all of them, scrolling rather than truncating. The
* scrollbar stays put instead of auto-hiding, so a capped list reads as scrollable.
*/
function OverflowValuesTooltip({
values,
}: OverflowValuesTooltipProps): JSX.Element {
return (
<TooltipSimple
side="top"
delayDuration={300}
tooltipContentProps={{ className: TOOLTIP_SCROLL_CONTENT_CLASS }}
title={
<TooltipScrollArea>
<ul className={styles.overflowValues}>
{values.map((value) => (
<li key={value}>{value}</li>
))}
</ul>
</TooltipScrollArea>
}
>
{/* rc-select copies this node into the overflow wrapper's `title`; an empty
one keeps the browser's own "[object Object]" tooltip out of the way. */}
<span title="">+{values.length}</span>
</TooltipSimple>
);
}
export default OverflowValuesTooltip;

View File

@@ -6,7 +6,6 @@ import { DashboardDetailEvents } from 'pages/DashboardPageV2/constants/events';
import type { VariableSelection } from '../../selectionTypes';
import { areSelectionsEqual } from '../../utils/resolveVariableSelection';
import OverflowValuesTooltip from './OverflowValuesTooltip';
import styles from '../../VariablesBar.module.scss';
interface ValueSelectorProps {
@@ -98,13 +97,7 @@ function ValueSelector({
placeholder="Select value"
maxTagCount={1}
maxTagTextLength={10}
maxTagPlaceholder={(omitted): JSX.Element => (
<OverflowValuesTooltip
values={omitted.map((item) =>
typeof item.label === 'string' ? item.label : String(item.value ?? ''),
)}
/>
)}
maxTagPlaceholder={(omitted): string => `+${omitted.length}`}
// Offer ALL only once options load, else a concrete value reads as "all".
enableAllSelection={showAllOption && options.length > 0}
onDropdownVisibleChange={(open): void => {

View File

@@ -89,7 +89,6 @@ export function useFetchedVariableOptions(
enabled: variable.type === 'QUERY' && canFetch,
refetchOnWindowFocus: false,
cacheTime,
keepPreviousData: true,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)
@@ -125,7 +124,6 @@ export function useFetchedVariableOptions(
variable.type === 'DYNAMIC' && !!variable.dynamicAttribute && canFetch,
refetchOnWindowFocus: false,
cacheTime,
keepPreviousData: true,
onSettled: (_, error) =>
error
? onVariableFetchFailure(variable.name)

View File

@@ -1,29 +0,0 @@
import type { VariableSelection } from '../selectionTypes';
/**
* What a selection reads as in a tooltip: the ALL label, nothing at all, or the
* concrete values. Never truncated — the tooltip scrolls instead.
*/
export type SelectionDisplay =
| { kind: 'all' }
| { kind: 'empty' }
| { kind: 'values'; values: string[] };
/** Describes a selection for display. */
export function describeSelection(
selection: VariableSelection | undefined,
): SelectionDisplay {
if (!selection) {
return { kind: 'empty' };
}
if (selection.allSelected) {
return { kind: 'all' };
}
const { value } = selection;
const values = (Array.isArray(value) ? value : [value])
.filter((entry) => entry !== '' && entry !== null && entry !== undefined)
.map(String);
return values.length === 0 ? { kind: 'empty' } : { kind: 'values', values };
}

View File

@@ -387,23 +387,15 @@ describe('usePanelQuery', () => {
expect(result.current.pagination?.canNext).toBe(false);
});
it('drives canNext from the cursor OR a full page (offset fallback for non-timestamp sorts)', () => {
// Full page, no cursor → the backend's offset path (a non-timestamp sort skips the
// window/cursor path), so a full page is the has-more signal.
it('drives canNext from the response cursor, not the row count', () => {
// Full page but no cursor → backend says these are the last rows.
withResponse(rawResponse(25));
const fullPage = renderHook(() =>
const noCursor = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(fullPage.result.current.pagination?.canNext).toBe(true);
expect(noCursor.result.current.pagination?.canNext).toBe(false);
// Partial page, no cursor → the last page.
withResponse(rawResponse(3));
const partialPage = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),
);
expect(partialPage.result.current.pagination?.canNext).toBe(false);
// Cursor present (even on a partial page) → more rows (timestamp window path).
// Cursor present (even on a partial page) → more rows.
withResponse(rawResponse(3, 'cursor-1'));
const withCursor = renderHook(() =>
usePanelQuery({ panel: listPanel({}), panelId: 'p1' }),

View File

@@ -283,10 +283,8 @@ export function usePanelQuery({
[pageSize],
);
// Paging handles for raw/list panels. The backend only emits `nextCursor` on the
// timestamp-ordered window path (isWindowList); a non-timestamp sort falls back to plain
// offset paging with no cursor. So treat a full page as a has-more signal too — matching the
// offset heuristic the logs/traces explorers use (there's no total count on the wire).
// Paging handles for raw/list panels. The backend sets `nextCursor` iff the page filled,
// so it's the authoritative has-more signal (there's no total count on the wire).
const pagination = useMemo<PanelPagination | undefined>(() => {
if (!isPaginated) {
return undefined;
@@ -302,7 +300,7 @@ export function usePanelQuery({
return {
pageIndex: Math.floor(safeOffset / safePageSize),
canPrev: safeOffset > 0,
canNext: !!result?.nextCursor || (result?.rows?.length ?? 0) >= safePageSize,
canNext: !!result?.nextCursor,
goPrev,
goNext,
pageSize: safePageSize,

View File

@@ -7,7 +7,7 @@
// small ones as data URIs — they ship in the bundle and render with no network
// call. Logos are a large, JSON-only catalogue, so `?url` keeps them as emitted
// files fetched (and cached) on demand rather than bloating the bundle.
const iconModules = import.meta.glob('../../../assets/Icons/**/*.{svg,png}', {
const iconModules = import.meta.glob('../../../assets/Icons/**/*.svg', {
eager: true,
import: 'default',
});

View File

@@ -1,7 +1,15 @@
import { useIsDashboardV2 } from 'hooks/useIsDashboardV2';
import DashboardsListPageV2 from 'pages/DashboardsListPageV2';
import DashboardsListPage from './DashboardsListPage';
// Serves the V2 dashboards list when the `use_dashboard_v2` flag is active;
// otherwise the existing V1 list. Lets V2 dark-ship behind the flag without
// changing route definitions.
function DashboardsListPageEntry(): JSX.Element {
return <DashboardsListPageV2 />;
const isDashboardV2 = useIsDashboardV2();
return isDashboardV2 ? <DashboardsListPageV2 /> : <DashboardsListPage />;
}
export default DashboardsListPageEntry;

View File

@@ -8,10 +8,7 @@ import {
// top-level `name` / `image` / `tags` / `schemaVersion`) or a bare spec — wrap
// the latter with defaults so users can paste either shape that exists in the
// wild (e.g. testdata/perses.json is a bare spec). The legacy nested
// `{ metadata: { ... }, spec }` shape is also accepted and flattened. A legacy
// v1 dashboard (top-level `version`, no `spec`/`schemaVersion`) is posted
// unchanged: the create endpoint migrates it to v2 server-side, and wrapping
// it here would misdeclare it as a v6 spec.
// `{ metadata: { ... }, spec }` shape is also accepted and flattened.
//
// The backend requires `name` (immutable identifier); if the payload doesn't
// carry one, fall back to `generateName: true` so the server assigns one.
@@ -19,16 +16,6 @@ export function normalizeToPostable(
parsed: Record<string, unknown>,
): DashboardtypesPostableDashboardV2DTO {
const hasSpec = 'spec' in parsed;
if (
!hasSpec &&
!('schemaVersion' in parsed) &&
typeof parsed.version === 'string' &&
parsed.version !== ''
) {
return parsed as unknown as DashboardtypesPostableDashboardV2DTO;
}
const legacyMeta = parsed.metadata as
| {
schemaVersion?: string;

View File

@@ -241,9 +241,12 @@ func (server *Server) PutAlerts(ctx context.Context, postableAlerts alertmanager
}
func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertmanagertypes.Config) error {
config := alertmanagerConfig.AlertmanagerConfig()
resolved, err := alertmanagerConfig.Resolved()
if err != nil {
return err
}
config := resolved.AlertmanagerConfig()
var err error
// Load SigNoz's alertmanager notification templates from the configured
// globs. The upstream default templates (default.tmpl, email.tmpl) are
// always loaded from the embedded alertmanager assets inside FromGlobs, so
@@ -275,7 +278,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
server.logger.InfoContext(ctx, "skipping creation of receiver not referenced by any route", slog.String("receiver", rcv.Name))
continue
}
extendedRcv, err := alertmanagerConfig.GetReceiver(rcv.Name)
extendedRcv, err := resolved.GetReceiver(rcv.Name)
if err != nil {
return err
}
@@ -350,7 +353,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
go server.dispatcher.Run()
go server.inhibitor.Run()
server.alertmanagerConfig = alertmanagerConfig
server.alertmanagerConfig = resolved
return nil
}

View File

@@ -167,6 +167,10 @@ func (provider *provider) UpdateChannelByReceiverAndID(ctx context.Context, orgI
return err
}
if err := config.SetGlobalConfig(provider.config.Signoz.Global); err != nil {
return err
}
if err := config.UpdateReceiver(receiver); err != nil {
return err
}
@@ -217,6 +221,10 @@ func (provider *provider) CreateChannel(ctx context.Context, orgID string, recei
return nil, err
}
if err := config.SetGlobalConfig(provider.config.Signoz.Global); err != nil {
return nil, err
}
if err := config.CreateReceiver(receiver); err != nil {
return nil, err
}

View File

@@ -0,0 +1,162 @@
package signozalertmanager
import (
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerserver"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/prometheus/alertmanager/config"
)
func newTestSQLStore(t *testing.T) sqlstore.SQLStore {
t.Helper()
store, err := sqlitesqlstore.New(t.Context(), factorytest.NewSettings(), sqlstore.Config{
Provider: "sqlite",
Connection: sqlstore.ConnectionConfig{
MaxOpenConns: 1,
MaxConnLifetime: 0,
},
Sqlite: sqlstore.SqliteConfig{
Path: filepath.Join(t.TempDir(), "test.db"),
Mode: "wal",
BusyTimeout: 5 * time.Second,
TransactionMode: "deferred",
},
})
require.NoError(t, err)
_, err = store.BunDB().NewCreateTable().
Model((*alertmanagertypes.StoreableConfig)(nil)).
IfNotExists().
Exec(t.Context())
require.NoError(t, err)
_, err = store.BunDB().ExecContext(t.Context(), "CREATE UNIQUE INDEX IF NOT EXISTS idx_alertmanager_config_org_id ON alertmanager_config(org_id)")
require.NoError(t, err)
_, err = store.BunDB().NewCreateTable().
Model((*alertmanagertypes.Channel)(nil)).
IfNotExists().
Exec(t.Context())
require.NoError(t, err)
return store
}
func newTestProvider(t *testing.T) (*provider, sqlstore.SQLStore) {
t.Helper()
serverConfig := alertmanagerserver.NewConfig()
serverConfig.Global.SMTPFrom = "alerts@example.com"
serverConfig.Global.SMTPSmarthost = config.HostPort{Host: "smtp.sendgrid.net", Port: "587"}
serverConfig.Global.SMTPAuthUsername = "apikey"
serverConfig.Global.SMTPAuthPassword = "operator-secret"
sqlStore := newTestSQLStore(t)
p, err := New(
factorytest.NewSettings(),
alertmanager.Config{
Provider: "signoz",
Signoz: alertmanager.Signoz{
PollInterval: time.Minute,
Config: serverConfig,
},
},
sqlStore,
nil,
nil,
nil,
)
require.NoError(t, err)
return p, sqlStore
}
func requireNoSMTPSettings(t *testing.T, payload string) {
t.Helper()
assert.NotContains(t, payload, "operator-secret")
assert.NotContains(t, payload, "smtp.sendgrid.net")
assert.NotContains(t, payload, "apikey")
assert.NotContains(t, payload, "auth_password")
assert.NotContains(t, payload, "smtp_auth_password")
}
func TestCreateEmailChannelWithStoredConfigWithoutSMTP(t *testing.T) {
p, _ := newTestProvider(t)
orgID := "test-org-1"
require.NoError(t, p.SetDefaultConfig(t.Context(), orgID))
seeded, err := p.GetConfig(t.Context(), orgID)
require.NoError(t, err)
requireNoSMTPSettings(t, seeded.StoreableConfig().Config)
receiver, err := alertmanagertypes.NewReceiver(`{"name":"email-receiver","email_configs":[{"to":"team@example.com"}]}`)
require.NoError(t, err)
channel, err := p.CreateChannel(t.Context(), orgID, receiver)
require.NoError(t, err)
requireNoSMTPSettings(t, channel.Data)
stored, err := p.GetChannelByID(t.Context(), orgID, channel.ID)
require.NoError(t, err)
assert.Contains(t, stored.Data, "team@example.com")
requireNoSMTPSettings(t, stored.Data)
cfg, err := p.GetConfig(t.Context(), orgID)
require.NoError(t, err)
requireNoSMTPSettings(t, cfg.StoreableConfig().Config)
require.NoError(t, cfg.SetGlobalConfig(p.config.Signoz.Global))
resolved, err := cfg.Resolved()
require.NoError(t, err)
resolvedReceiver, err := resolved.GetReceiver("email-receiver")
require.NoError(t, err)
require.Len(t, resolvedReceiver.EmailConfigs, 1)
assert.Equal(t, "smtp.sendgrid.net:587", resolvedReceiver.EmailConfigs[0].Smarthost.String())
assert.Equal(t, "operator-secret", string(resolvedReceiver.EmailConfigs[0].AuthPassword))
}
func TestUpdateStampedLegacyEmailChannel(t *testing.T) {
p, sqlStore := newTestProvider(t)
orgID := "test-org-2"
require.NoError(t, p.SetDefaultConfig(t.Context(), orgID))
receiver, err := alertmanagertypes.NewReceiver(`{"name":"email-receiver","email_configs":[{"to":"team@example.com"}]}`)
require.NoError(t, err)
channel, err := p.CreateChannel(t.Context(), orgID, receiver)
require.NoError(t, err)
stampedData := `{"name":"email-receiver","email_configs":[{"send_resolved":false,"to":"team@example.com","from":"old@example.com","hello":"localhost","smarthost":"email-smtp.us-east-1.amazonaws.com:587","auth_username":"AKIA000","auth_password":"old-ses-secret","require_tls":true}]}`
_, err = sqlStore.BunDB().ExecContext(t.Context(), "UPDATE notification_channel SET data = ? WHERE id = ?", stampedData, channel.ID.StringValue())
require.NoError(t, err)
updated, err := alertmanagertypes.NewReceiver(`{"name":"email-receiver","email_configs":[{"to":"new-team@example.com"}]}`)
require.NoError(t, err)
require.NoError(t, p.UpdateChannelByReceiverAndID(t.Context(), orgID, updated, channel.ID))
stored, err := p.GetChannelByID(t.Context(), orgID, channel.ID)
require.NoError(t, err)
assert.Contains(t, stored.Data, "new-team@example.com")
assert.NotContains(t, stored.Data, "old-ses-secret")
assert.NotContains(t, stored.Data, "amazonaws.com")
requireNoSMTPSettings(t, stored.Data)
cfg, err := p.GetConfig(t.Context(), orgID)
require.NoError(t, err)
assert.NotContains(t, cfg.StoreableConfig().Config, "old-ses-secret")
requireNoSMTPSettings(t, cfg.StoreableConfig().Config)
}

View File

@@ -2,82 +2,329 @@ package contextlinks
import (
"encoding/json"
"fmt"
"net/url"
"strconv"
"time"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
tracesV3 "github.com/SigNoz/signoz/pkg/query-service/app/traces/v3"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils"
)
// PrepareParamsForTracesV5 returns the traces explorer query params for the
// given range and filter; the traces explorer writes its time params in
// nanoseconds.
func PrepareParamsForTracesV5(start, end time.Time, whereClause string) url.Values {
return prepareExplorerParams("traces", start.UnixNano(), end.UnixNano(), whereClause)
}
func PrepareLinksToTraces(start, end time.Time, filterItems []v3.FilterItem) string {
// PrepareParamsForLogsV5 returns the logs explorer query params for the given
// range and filter; the logs explorer writes its time params in milliseconds.
func PrepareParamsForLogsV5(start, end time.Time, whereClause string) url.Values {
return prepareExplorerParams("logs", start.UnixMilli(), end.UnixMilli(), whereClause)
}
// Traces list view expects time in nanoseconds
tr := URLShareableTimeRange{
Start: start.UnixNano(),
End: end.UnixNano(),
PageSize: 100,
}
options := URLShareableOptions{
MaxLines: 2,
Format: "list",
SelectColumns: tracesV3.TracesListViewDefaultSelectedColumns,
}
period, _ := json.Marshal(tr)
urlEncodedTimeRange := url.QueryEscape(string(period))
builderQuery := v3.BuilderQuery{
DataSource: v3.DataSourceTraces,
QueryName: "A",
AggregateOperator: v3.AggregateOperatorNoOp,
AggregateAttribute: v3.AttributeKey{},
Filters: &v3.FilterSet{
Items: filterItems,
Operator: "AND",
},
Expression: "A",
Disabled: false,
Having: []v3.Having{},
StepInterval: 60,
OrderBy: []v3.OrderBy{
{
ColumnName: "timestamp",
Order: "desc",
},
},
}
// The end link is double encoded because otherwise a filter expression with `%` somewhere in it breaks.
func prepareExplorerParams(dataSource string, start, end int64, whereClause string) url.Values {
urlData := URLShareableCompositeQuery{
QueryType: "builder",
QueryType: string(v3.QueryTypeBuilder),
Builder: URLShareableBuilderQuery{
QueryData: []LinkQuery{{
DataSource: dataSource,
Filter: &FilterExpression{Expression: whereClause},
}},
QueryData: []LinkQuery{
{BuilderQuery: builderQuery},
},
QueryFormulas: make([]string, 0),
},
}
data, _ := json.Marshal(urlData)
compositeQuery := url.QueryEscape(url.QueryEscape(string(data)))
optionsData, _ := json.Marshal(options)
urlEncodedOptions := url.QueryEscape(string(optionsData))
return fmt.Sprintf("compositeQuery=%s&timeRange=%s&startTime=%d&endTime=%d&options=%s", compositeQuery, urlEncodedTimeRange, tr.Start, tr.End, urlEncodedOptions)
}
func PrepareLinksToLogs(start, end time.Time, filterItems []v3.FilterItem) string {
// Logs list view expects time in milliseconds
tr := URLShareableTimeRange{
Start: start.UnixMilli(),
End: end.UnixMilli(),
PageSize: 100,
}
options := URLShareableOptions{
MaxLines: 2,
Format: "list",
SelectColumns: []v3.AttributeKey{},
}
period, _ := json.Marshal(tr)
urlEncodedTimeRange := url.QueryEscape(string(period))
builderQuery := v3.BuilderQuery{
DataSource: v3.DataSourceLogs,
QueryName: "A",
AggregateOperator: v3.AggregateOperatorNoOp,
AggregateAttribute: v3.AttributeKey{},
Filters: &v3.FilterSet{
Items: filterItems,
Operator: "AND",
},
Expression: "A",
Disabled: false,
Having: []v3.Having{},
StepInterval: 60,
OrderBy: []v3.OrderBy{
{
ColumnName: "timestamp",
Order: "desc",
},
},
}
urlData := URLShareableCompositeQuery{
QueryType: string(v3.QueryTypeBuilder),
Builder: URLShareableBuilderQuery{
QueryData: []LinkQuery{
{BuilderQuery: builderQuery},
},
QueryFormulas: make([]string, 0),
},
}
data, _ := json.Marshal(urlData)
compositeQuery := url.QueryEscape(url.QueryEscape(string(data)))
optionsData, _ := json.Marshal(options)
urlEncodedOptions := url.QueryEscape(string(optionsData))
return fmt.Sprintf("compositeQuery=%s&timeRange=%s&startTime=%d&endTime=%d&options=%s", compositeQuery, urlEncodedTimeRange, tr.Start, tr.End, urlEncodedOptions)
}
// The following function is used to prepare the where clause for the query
// `lbls` contains the key value pairs of the labels from the result of the query
// We iterate over the where clause and replace the labels with the actual values
// There are two cases:
// 1. The label is present in the where clause
// 2. The label is not present in the where clause
//
// Example for case 2:
// Latency by serviceName without any filter
// In this case, for each service with latency > threshold we send a notification
// The expectation will be that clicking on the related traces for service A, will
// take us to the traces page with the filter serviceName=A
// So for all the missing labels in the where clause, we add them as key = value
//
// Example for case 1:
// Severity text IN (WARN, ERROR)
// In this case, the Severity text will appear in the `lbls` if it were part of the group
// by clause, in which case we replace it with the actual value for the notification
// i.e Severity text = WARN
// If the Severity text is not part of the group by clause, then we add it as it is.
func PrepareFilters(labels map[string]string, whereClauseItems []v3.FilterItem, groupByItems []v3.AttributeKey, keys map[string]v3.AttributeKey) []v3.FilterItem {
filterItems := make([]v3.FilterItem, 0)
//delete predefined alert labels
for _, label := range PredefinedAlertLabels {
delete(labels, label)
}
added := make(map[string]struct{})
for _, item := range whereClauseItems {
exists := false
for key, value := range labels {
if item.Key.Key == key {
// if the label is present in the where clause, replace it with key = value
filterItems = append(filterItems, v3.FilterItem{
Key: item.Key,
Operator: v3.FilterOperatorEqual,
Value: value,
})
exists = true
added[key] = struct{}{}
break
}
}
if !exists {
// if there is no label for the filter item, add it as it is
filterItems = append(filterItems, item)
}
}
// if there are labels which are not part of the where clause, but
// exist in the result, then they could be part of the group by clause
for key, value := range labels {
if _, ok := added[key]; !ok {
// start by taking the attribute key from the keys map, if not present, create a new one
var attributeKey v3.AttributeKey
var attrFound bool
// as of now this logic will only apply for logs
for _, tKey := range utils.GenerateEnrichmentKeys(v3.AttributeKey{Key: key}) {
if val, ok := keys[tKey]; ok {
attributeKey = val
attrFound = true
break
}
}
// check if the attribute key is directly present, as of now this will always be false for logs
// as for logs it will be satisfied in the condition above
if !attrFound {
attributeKey, attrFound = keys[key]
}
// if the attribute key is not present, create a new one
if !attrFound {
attributeKey = v3.AttributeKey{Key: key}
}
// if there is a group by item with the same key, use that instead
for _, groupByItem := range groupByItems {
if groupByItem.Key == key {
attributeKey = groupByItem
break
}
}
filterItems = append(filterItems, v3.FilterItem{
Key: attributeKey,
Operator: v3.FilterOperatorEqual,
Value: value,
})
}
}
return filterItems
}
func PrepareParamsForTracesV5(start, end time.Time, whereClause string) url.Values {
// Traces list view expects time in nanoseconds
tr := URLShareableTimeRange{
Start: start.UnixNano(),
End: end.UnixNano(),
PageSize: 100,
}
options := URLShareableOptions{}
period, _ := json.Marshal(tr)
linkQuery := LinkQuery{
BuilderQuery: v3.BuilderQuery{
DataSource: v3.DataSourceTraces,
QueryName: "A",
AggregateOperator: v3.AggregateOperatorNoOp,
AggregateAttribute: v3.AttributeKey{},
Expression: "A",
Disabled: false,
Having: []v3.Having{},
StepInterval: 60,
},
Filter: &FilterExpression{Expression: whereClause},
}
urlData := URLShareableCompositeQuery{
QueryType: string(v3.QueryTypeBuilder),
Builder: URLShareableBuilderQuery{
QueryData: []LinkQuery{
linkQuery,
},
QueryFormulas: make([]string, 0),
},
}
data, _ := json.Marshal(urlData)
compositeQuery := url.QueryEscape(string(data))
optionsData, _ := json.Marshal(options)
params := url.Values{}
params.Set("compositeQuery", url.QueryEscape(string(data)))
params.Set("startTime", strconv.FormatInt(start, 10))
params.Set("endTime", strconv.FormatInt(end, 10))
params.Set("compositeQuery", compositeQuery)
params.Set("timeRange", string(period))
params.Set("startTime", strconv.FormatInt(tr.Start, 10))
params.Set("endTime", strconv.FormatInt(tr.End, 10))
params.Set("options", string(optionsData))
return params
}
// BuilderQueryForSignal returns the filter expression and group-by keys of the
// builder query for the given signal, or found=false when the composite query
// has no builder query for it (e.g. PromQL or ClickHouse SQL alerts).
// TODO(srikanthccv): re-visit this and support multiple queries.
func BuilderQueryForSignal(queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
switch signal {
case telemetrytypes.SignalLogs:
return builderQueryForSignal[qbtypes.LogAggregation](queries, signal)
case telemetrytypes.SignalTraces:
return builderQueryForSignal[qbtypes.TraceAggregation](queries, signal)
}
return "", nil, false
}
func PrepareParamsForLogsV5(start, end time.Time, whereClause string) url.Values {
func builderQueryForSignal[T any](queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
var q qbtypes.QueryBuilderQuery[T]
found := false
for _, query := range queries {
if query.Type != qbtypes.QueryTypeBuilder {
continue
}
if spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[T]); ok {
q = spec
found = true
}
}
if !found || q.Signal != signal {
return "", nil, false
// Logs list view expects time in milliseconds
tr := URLShareableTimeRange{
Start: start.UnixMilli(),
End: end.UnixMilli(),
PageSize: 100,
}
filterExpr := ""
if q.Filter != nil {
filterExpr = q.Filter.Expression
options := URLShareableOptions{}
period, _ := json.Marshal(tr)
linkQuery := LinkQuery{
BuilderQuery: v3.BuilderQuery{
DataSource: v3.DataSourceLogs,
QueryName: "A",
AggregateOperator: v3.AggregateOperatorNoOp,
AggregateAttribute: v3.AttributeKey{},
Expression: "A",
Disabled: false,
Having: []v3.Having{},
StepInterval: 60,
},
Filter: &FilterExpression{Expression: whereClause},
}
return filterExpr, q.GroupBy, true
urlData := URLShareableCompositeQuery{
QueryType: string(v3.QueryTypeBuilder),
Builder: URLShareableBuilderQuery{
QueryData: []LinkQuery{
linkQuery,
},
QueryFormulas: make([]string, 0),
},
}
data, _ := json.Marshal(urlData)
compositeQuery := url.QueryEscape(string(data))
optionsData, _ := json.Marshal(options)
params := url.Values{}
params.Set("compositeQuery", compositeQuery)
params.Set("timeRange", string(period))
params.Set("startTime", strconv.FormatInt(tr.Start, 10))
params.Set("endTime", strconv.FormatInt(tr.End, 10))
params.Set("options", string(optionsData))
return params
}

View File

@@ -1,63 +0,0 @@
package contextlinks
import (
"testing"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuilderQueryForSignal(t *testing.T) {
logQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
Name: "A",
Signal: telemetrytypes.SignalLogs,
Filter: &qbtypes.Filter{Expression: "severity_text = 'ERROR'"},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
},
}
traceQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "B",
Signal: telemetrytypes.SignalTraces,
},
}
promQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{Name: "C"},
}
t.Run("logs query among mixed queries", func(t *testing.T) {
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery, logQuery, traceQuery}, telemetrytypes.SignalLogs)
require.True(t, found)
assert.Equal(t, "severity_text = 'ERROR'", filterExpr)
require.Len(t, groupBy, 1)
assert.Equal(t, "service.name", groupBy[0].Name)
})
t.Run("traces query without filter", func(t *testing.T) {
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery, traceQuery}, telemetrytypes.SignalTraces)
require.True(t, found)
assert.Empty(t, filterExpr)
assert.Empty(t, groupBy)
})
t.Run("no builder query for signal", func(t *testing.T) {
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{traceQuery}, telemetrytypes.SignalLogs)
assert.False(t, found)
})
t.Run("no builder queries at all", func(t *testing.T) {
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery}, telemetrytypes.SignalLogs)
assert.False(t, found)
})
t.Run("unsupported signal", func(t *testing.T) {
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery}, telemetrytypes.SignalMetrics)
assert.False(t, found)
})
}

View File

@@ -1,20 +1,29 @@
package contextlinks
import (
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
)
// TODO(srikanthccv): Fix the URL management.
type URLShareableTimeRange struct {
Start int64 `json:"start"`
End int64 `json:"end"`
PageSize int64 `json:"pageSize"`
}
type FilterExpression struct {
Expression string `json:"expression,omitempty"`
}
// LinkQuery carries the only fields the explorer pages read from a shared
// link; the frontend fills in the rest of the query shape with defaults.
type Aggregation struct {
Expression string `json:"expression,omitempty"`
}
type LinkQuery struct {
DataSource string `json:"dataSource"`
Filter *FilterExpression `json:"filter,omitempty"`
v3.BuilderQuery
Filter *FilterExpression `json:"filter,omitempty"`
Aggregations []*Aggregation `json:"aggregations,omitempty"`
}
type URLShareableBuilderQuery struct {
@@ -27,4 +36,10 @@ type URLShareableCompositeQuery struct {
Builder URLShareableBuilderQuery `json:"builder"`
}
type URLShareableOptions struct {
MaxLines int `json:"maxLines"`
Format string `json:"format"`
SelectColumns []v3.AttributeKey `json:"selectColumns"`
}
var PredefinedAlertLabels = []string{ruletypes.LabelThresholdName, ruletypes.LabelSeverityName, ruletypes.LabelLastSeen}

View File

@@ -11,6 +11,7 @@ var (
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")
FeatureEnableAIObservability = featuretypes.MustNewName("enable_ai_observability")
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
FeatureUseInfraMonitoringV2 = featuretypes.MustNewName("use_infra_monitoring_v2")
@@ -82,6 +83,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(),
},
&featuretypes.Feature{
Name: FeatureEnableAIObservability,
Kind: featuretypes.KindBoolean,

Some files were not shown because too many files have changed in this diff Show More