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
127 changed files with 77056 additions and 50379 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

@@ -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

@@ -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

@@ -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

@@ -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,33 +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"}',
['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

@@ -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

@@ -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,

View File

@@ -40,23 +40,20 @@ func (handler *handler) CreateV2(rw http.ResponseWriter, r *http.Request) {
return
}
var shadow struct {
SchemaVersion string `json:"schemaVersion"`
Version string `json:"version"`
}
_ = json.Unmarshal(body, &shadow)
var req dashboardtypes.PostableDashboardV2
if shadow.SchemaVersion == "" && shadow.Version != "" {
if err := binding.JSON.BindBody(bytes.NewReader(body), &req); err != nil {
// Fallback: the body may be a legacy v1 dashboard. Migrate it to v2 (same
// v4→v5 + v1→v2 pass the bulk migration runs) and retry; if it isn't a
// convertible v1 payload either, surface the original v2 binding error.
var data map[string]any
if err := json.Unmarshal(body, &data); err != nil {
render.Error(rw, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "%s", err.Error()))
if json.Unmarshal(body, &data) != nil {
render.Error(rw, err)
return
}
storable := dashboardtypes.StorableDashboard{Data: data, OrgID: orgID}
transition.NewDashboardMigrateV5(handler.providerSettings.Logger, nil, nil).Migrate(ctx, storable.Data)
v2, err := storable.ConvertV1ToV2()
if err != nil {
v2, convErr := storable.ConvertV1ToV2()
if convErr != nil {
render.Error(rw, err)
return
}
@@ -66,9 +63,6 @@ func (handler *handler) CreateV2(rw http.ResponseWriter, r *http.Request) {
Tags: tagtypes.NewPostableTagsFromTags(v2.Tags),
Spec: v2.Spec,
}
} else if err := binding.JSON.BindBody(bytes.NewReader(body), &req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.CreateV2(ctx, orgID, claims.Email, valuer.MustNewUUID(claims.IdentityID()), dashboardtypes.SourceUser, req)

View File

@@ -123,10 +123,6 @@ func newPromqlQuery(
}
func (q *promqlQuery) Fingerprint() string {
if q.requestType != qbv5.RequestTypeTimeSeries {
return ""
}
query, err := q.renderVars(q.query.Query, q.vars, q.tr.From, q.tr.To)
if err != nil {
q.logger.ErrorContext(context.TODO(), "failed render template variables", slog.String("query", q.query.Query))
@@ -365,37 +361,16 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
warnings, _ := res.Warnings.AsStrings(query, 10, 0)
tsData := &qbv5.TimeSeriesData{
QueryName: q.query.Name,
Aggregations: []*qbv5.AggregationBucket{
{
Series: series,
return &qbv5.Result{
Type: q.requestType,
Value: &qbv5.TimeSeriesData{
QueryName: q.query.Name,
Aggregations: []*qbv5.AggregationBucket{
{
Series: series,
},
},
},
}
var payload any = tsData
// Scalar requests must return scalar data; reduce each series to its
// last point. This approximates an instant query evaluated at the end
// of the window: the final range-eval step sees the same samples an
// instant query at that timestamp would, except a series that went
// stale mid-window still surfaces its most recent value instead of
// dropping out of the result.
// TODO(srikanthccv): "last" mirrors the instant-query semantics we
// have always followed for PromQL scalar requests, but it should be
// configurable on the PromQuery spec just like the builder reduceTo.
if q.requestType == qbv5.RequestTypeScalar {
for _, aggBucket := range tsData.Aggregations {
for i, s := range aggBucket.Series {
aggBucket.Series[i] = qbv5.FunctionReduceTo(s, qbv5.ReduceToLast)
}
}
payload = convertTimeSeriesDataToScalar(tsData, q.query.Name)
}
return &qbv5.Result{
Type: q.requestType,
Value: payload,
Warnings: warnings,
// TODO: map promql stats?
}, nil

View File

@@ -1669,6 +1669,15 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
Route: "",
})
useDashboardV2 := aH.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureUseDashboardV2, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureUseDashboardV2.String()),
Active: useDashboardV2,
Usage: 0,
UsageLimit: -1,
Route: "",
})
aiObservability := aH.Signoz.Flagger.BooleanOrEmpty(r.Context(), flagger.FeatureEnableAIObservability, evalCtx)
featureSet = append(featureSet, &licensetypes.Feature{
Name: valuer.NewString(flagger.FeatureEnableAIObservability.String()),

View File

@@ -151,15 +151,20 @@ func readBuiltInIntegration(dirpath string) (
func validateIntegration(i IntegrationDetails) error {
// Validate dashboard data
seenDashboardIds := map[string]struct{}{}
seenDashboardIds := map[string]interface{}{}
for _, dd := range i.Assets.Dashboards {
if dd.ID == "" {
return fmt.Errorf("id is required for dashboard %q in integration %s", dd.Definition.Spec.Display.Name, i.Id)
did, exists := dd["id"]
if !exists {
return fmt.Errorf("id is required. not specified in dashboard titled %v", dd["title"])
}
if _, seen := seenDashboardIds[dd.ID]; seen {
return fmt.Errorf("multiple dashboards found with id %s", dd.ID)
dashboardId, ok := did.(string)
if !ok {
return fmt.Errorf("id must be string in dashboard titled %v", dd["title"])
}
seenDashboardIds[dd.ID] = struct{}{}
if _, seen := seenDashboardIds[dashboardId]; seen {
return fmt.Errorf("multiple dashboards found with id %s", dashboardId)
}
seenDashboardIds[dashboardId] = nil
}
// TODO(Raj): Validate all parts of plugged in integrations

View File

@@ -31,10 +31,7 @@
"pipelines": []
},
"dashboards": [
{
"id": "overview",
"definition": "file://assets/dashboards/overview.json"
}
"file://assets/dashboards/overview.json"
],
"alerts": []
},

View File

@@ -31,14 +31,8 @@
"pipelines": []
},
"dashboards": [
{
"id": "overview",
"definition": "file://assets/dashboards/overview.json"
},
{
"id": "db_metrics_overview",
"definition": "file://assets/dashboards/db_metrics_overview.json"
}
"file://assets/dashboards/overview.json",
"file://assets/dashboards/db_metrics_overview.json"
],
"alerts": []
},

View File

@@ -31,14 +31,8 @@
"pipelines": []
},
"dashboards": [
{
"id": "overview",
"definition": "file://assets/dashboards/overview.json"
},
{
"id": "db_metrics_overview",
"definition": "file://assets/dashboards/db_metrics_overview.json"
}
"file://assets/dashboards/overview.json",
"file://assets/dashboards/db_metrics_overview.json"
],
"alerts": []
},

View File

@@ -35,10 +35,7 @@
"pipelines": []
},
"dashboards": [
{
"id": "overview",
"definition": "file://assets/dashboards/overview.json"
}
"file://assets/dashboards/overview.json"
],
"alerts": []
},

View File

@@ -31,10 +31,7 @@
"pipelines": []
},
"dashboards": [
{
"id": "overview",
"definition": "file://assets/dashboards/overview.json"
}
"file://assets/dashboards/overview.json"
],
"alerts": []
},

View File

@@ -31,10 +31,7 @@
"pipelines": []
},
"dashboards": [
{
"id": "overview",
"definition": "file://assets/dashboards/overview.json"
}
"file://assets/dashboards/overview.json"
],
"alerts": []
},

View File

@@ -31,10 +31,7 @@
"pipelines": []
},
"dashboards": [
{
"id": "overview",
"definition": "file://assets/dashboards/overview.json"
}
"file://assets/dashboards/overview.json"
],
"alerts": []
},

View File

@@ -34,21 +34,12 @@ type IntegrationSummary struct {
}
type IntegrationAssets struct {
Logs LogsAssets `json:"logs"`
Dashboards []Dashboard `json:"dashboards"`
Logs LogsAssets `json:"logs"`
Dashboards []dashboardtypes.StorableDashboardData `json:"dashboards"`
Alerts []ruletypes.PostableRule `json:"alerts"`
}
// Dashboard pairs a built-in dashboard's stable id with its v2 definition. The id
// lives in integration.json alongside the definition file reference (mirroring the
// cloud integration model) and drives the install slug, so it stays stable even
// though the v2 definition blob no longer carries a top-level id.
type Dashboard struct {
ID string `json:"id"`
Definition dashboardtypes.PostableDashboardV2 `json:"definition"`
}
type LogsAssets struct {
Pipelines []pipelinetypes.PostablePipeline `json:"pipelines"`
}
@@ -426,22 +417,23 @@ func (m *Manager) provisionDashboards(
) error {
bareIntegrationID := strings.TrimPrefix(integrationID, "builtin-")
for _, dd := range integration.Assets.Dashboards {
if dd.ID == "" {
dashID, _ := dd["id"].(string)
if dashID == "" {
continue
}
slug := cloudintegrationtypes.InstalledIntegrationDashboardSlug(bareIntegrationID, dd.ID)
slug := cloudintegrationtypes.InstalledIntegrationDashboardSlug(bareIntegrationID, dashID)
existing, err := m.installedIntegrationsRepo.getIntegrationDashboardBySlug(ctx, orgID.StringValue(), slug)
if err == nil && existing != nil {
continue
}
createdDashboard, err := m.dashboardModule.CreateV2(ctx, orgID, createdBy, creator, dashboardtypes.SourceIntegration, dd.Definition)
createdDashboard, err := m.dashboardModule.Create(ctx, orgID, createdBy, creator, dashboardtypes.SourceIntegration, dashboardtypes.PostableDashboard(dd))
if err != nil {
return fmt.Errorf("could not create dashboard for slug %s: %w", slug, err)
}
row := cloudintegrationtypes.NewStorableIntegrationDashboard(createdDashboard.ID.StringValue(), cloudintegrationtypes.IntegrationDashboardInstalledIntegrationProvider, slug)
row := cloudintegrationtypes.NewStorableIntegrationDashboard(createdDashboard.ID, cloudintegrationtypes.IntegrationDashboardInstalledIntegrationProvider, slug)
if err := m.installedIntegrationsRepo.createIntegrationDashboard(ctx, row); err != nil {
return fmt.Errorf("could not create integration_dashboard row for slug %s: %w", slug, err)
}

View File

@@ -37,7 +37,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/promote/implpromote"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/session/implsession"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/pprof"
@@ -67,7 +66,6 @@ import (
"github.com/SigNoz/signoz/pkg/tokenizer/opaquetokenizer"
"github.com/SigNoz/signoz/pkg/tokenizer/tokenizerstore/sqltokenizerstore"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/web"
@@ -120,8 +118,6 @@ func NewSQLMigrationProviderFactories(
sqlschema sqlschema.SQLSchema,
telemetryStore telemetrystore.TelemetryStore,
providerSettings factory.ProviderSettings,
dashboardStore dashboardtypes.Store,
tagModule tag.Module,
) factory.NamedMap[factory.ProviderFactory[sqlmigration.SQLMigration, sqlmigration.Config]] {
return factory.MustNewNamedMap(
sqlmigration.NewAddDataMigrationsFactory(),
@@ -226,7 +222,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddTagUniqueIndexFactory(sqlstore, sqlschema),
sqlmigration.NewAddTelemetryTuplesFactory(sqlstore),
sqlmigration.NewAddTagRelationRankFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateDashboardsV1ToV2Factory(sqlschema, dashboardStore, tagModule),
sqlmigration.NewScrubEmailChannelTransportFactory(sqlstore),
)
}

View File

@@ -12,9 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlschema/sqlschematest"
@@ -50,14 +48,11 @@ func TestNewProviderFactories(t *testing.T) {
})
assert.NotPanics(t, func() {
store := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
NewSQLMigrationProviderFactories(
store,
sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual),
sqlschematest.New(map[string]*sqlschema.Table{}, map[string][]*sqlschema.UniqueConstraint{}, map[string]sqlschema.Index{}),
telemetrystoretest.New(telemetrystore.Config{Provider: "clickhouse"}, sqlmock.QueryMatcherEqual),
instrumentationtest.New().ToProviderSettings(),
impldashboard.NewStore(store),
impltag.NewModule(impltag.NewStore(store)),
)
})

View File

@@ -27,7 +27,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/authdomain/implauthdomain"
"github.com/SigNoz/signoz/pkg/modules/cloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/metricreductionrule"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
@@ -277,20 +276,12 @@ func New(
return nil, err
}
// Initialize tag module — shared across modules that link entities to tags
// (currently dashboard; future: alerts, RBAC). Built once here and injected
// where needed.
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
// Dashboard store, injected into the migrations that reshape stored dashboards.
dashboardStore := impldashboard.NewStore(sqlstore)
// Run migrations on the sqlstore
sqlmigrations, err := sqlmigration.New(
ctx,
providerSettings,
config.SQLMigration,
NewSQLMigrationProviderFactories(sqlstore, sqlschema, telemetrystore, providerSettings, dashboardStore, tagModule),
NewSQLMigrationProviderFactories(sqlstore, sqlschema, telemetrystore, providerSettings),
)
if err != nil {
return nil, err
@@ -347,6 +338,11 @@ func New(
// Initialize query parser (needed for dashboard module)
queryParser := queryparser.New(providerSettings)
// Initialize tag module — shared across modules that link entities to tags
// (currently dashboard; future: alerts, RBAC). Built once here and injected
// where needed.
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
// Initialize dashboard module
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing, tagModule)

View File

@@ -1,153 +0,0 @@
package sqlmigration
import (
"context"
"log/slog"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/transition"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type migrateDashboardsV1ToV2 struct {
sqlschema sqlschema.SQLSchema
dashboardStore dashboardtypes.Store
tagModule tag.Module
settings factory.ProviderSettings
}
func NewMigrateDashboardsV1ToV2Factory(sqlschema sqlschema.SQLSchema, dashboardStore dashboardtypes.Store, tagModule tag.Module) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("migrate_dashboards_v1_to_v2"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &migrateDashboardsV1ToV2{sqlschema: sqlschema, dashboardStore: dashboardStore, tagModule: tagModule, settings: ps}, nil
},
)
}
func (migration *migrateDashboardsV1ToV2) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
// Up converts every v1 dashboard in every org to the v2 schema in place. Each
// dashboard — in its own transaction — runs the v4→v5 query migration, is
// converted to v2, has its stored data overwritten, and its tags synced.
// Dashboards already in v2 are skipped, and per-dashboard conversion failures are
// logged rather than aborting the run, so one irrecoverably malformed dashboard
// can't wedge startup.
func (migration *migrateDashboardsV1ToV2) Up(ctx context.Context, db *bun.DB) error {
var orgIDs []string
if err := db.NewSelect().Model((*types.Organization)(nil)).Column("id").Scan(ctx, &orgIDs); err != nil {
return err
}
for _, id := range orgIDs {
orgID, err := valuer.NewUUID(id)
if err != nil {
return err
}
if err := migration.convertOrg(ctx, orgID); err != nil {
return err
}
}
// Every dashboard now has a name (migrated, or backfilled from the v1 title), so a
// v2 dashboard's (org_id, name) can be made unique.
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
sqls := migration.sqlschema.Operator().CreateIndex(&sqlschema.UniqueIndex{
TableName: "dashboard",
ColumnNames: []sqlschema.ColumnName{"org_id", "name"},
})
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
// convertOrg converts every v1 dashboard in the org, skipping v2 dashboards and
// recording (not propagating) per-dashboard failures.
func (migration *migrateDashboardsV1ToV2) convertOrg(ctx context.Context, orgID valuer.UUID) error {
storables, err := migration.dashboardStore.List(ctx, orgID)
if err != nil {
return err
}
logger := migration.settings.Logger
// The v1→v2 conversion assumes v5-shaped widget queries, so run the v4→v5
// migration first (in place) — same pass the create path runs.
v5 := transition.NewDashboardMigrateV5(logger, nil, nil)
var migrated, skipped, failed int
for _, storable := range storables {
if storable.IsV2() {
skipped++
continue
}
v5.Migrate(ctx, storable.Data)
if err := convertOneDashboardV1ToV2(ctx, migration.dashboardStore, migration.tagModule, orgID, storable); err != nil {
failed++
logger.WarnContext(ctx, "failed to convert dashboard from v1 to v2", slog.String("org_id", orgID.String()), slog.String("dashboard_id", storable.ID.String()), slog.Any("error", err))
// Backfill the name column from the v1 title even though the data couldn't
// migrate, so the dashboard stays findable by name. Only when it's unset, so a
// retry doesn't regenerate a different (randomly-suffixed) name. Best-effort.
if storable.Name == "" {
if nameErr := migration.dashboardStore.UpdateName(ctx, orgID, storable.ID, storable.V1Name()); nameErr != nil {
logger.ErrorContext(ctx, "failed to backfill name for unmigrated dashboard", slog.String("dashboard_id", storable.ID.String()), slog.Any("error", nameErr))
}
}
continue
}
migrated++
}
logger.InfoContext(ctx, "converted dashboards from v1 to v2", slog.String("org_id", orgID.String()), slog.Int("total", len(storables)), slog.Int("migrated", migrated), slog.Int("skipped", skipped), slog.Int("failed", failed))
return nil
}
// convertOneDashboardV1ToV2 converts a single v1 dashboard and, in one
// transaction, syncs its tags and overwrites its stored data with the v2 shape.
func convertOneDashboardV1ToV2(ctx context.Context, store dashboardtypes.Store, tagModule tag.Module, orgID valuer.UUID, storable *dashboardtypes.StorableDashboard) error {
v2, err := storable.ConvertV1ToV2()
if err != nil {
return err
}
return store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, v2.ID, tagtypes.NewPostableTagsFromTags(v2.Tags))
if err != nil {
return err
}
v2.Tags = resolvedTags
v2Storable, err := v2.ToStorableDashboard()
if err != nil {
return err
}
return store.Update(ctx, orgID, v2Storable)
})
}
func (migration *migrateDashboardsV1ToV2) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,263 @@
package sqlmigration
import (
"context"
"crypto/md5"
"encoding/json"
"fmt"
"log/slog"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type scrubEmailChannelTransport struct {
sqlstore sqlstore.SQLStore
logger *slog.Logger
}
type alertmanagerConfigScrubRow struct {
bun.BaseModel `bun:"table:alertmanager_config"`
ID string `bun:"id"`
Config string `bun:"config"`
}
type notificationChannelScrubRow struct {
bun.BaseModel `bun:"table:notification_channel"`
ID string `bun:"id"`
Data string `bun:"data"`
}
var emailTransportKeys = []string{
"from",
"hello",
"smarthost",
"auth_username",
"auth_password",
"auth_password_file",
"auth_secret",
"auth_secret_file",
"auth_identity",
"require_tls",
"tls_config",
"force_implicit_tls",
}
var globalSMTPKeys = []string{
"smtp_from",
"smtp_hello",
"smtp_smarthost",
"smtp_auth_username",
"smtp_auth_password",
"smtp_auth_password_file",
"smtp_auth_secret",
"smtp_auth_secret_file",
"smtp_auth_identity",
"smtp_require_tls",
"smtp_tls_config",
"smtp_force_implicit_tls",
}
func NewScrubEmailChannelTransportFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("scrub_email_channel_transport"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &scrubEmailChannelTransport{sqlstore: sqlstore, logger: ps.Logger}, nil
},
)
}
func (migration *scrubEmailChannelTransport) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
func (migration *scrubEmailChannelTransport) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
if err := migration.scrubConfigs(ctx, tx); err != nil {
return err
}
if err := migration.scrubChannels(ctx, tx); err != nil {
return err
}
return tx.Commit()
}
func (migration *scrubEmailChannelTransport) scrubConfigs(ctx context.Context, tx bun.Tx) error {
rows := make([]*alertmanagerConfigScrubRow, 0)
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
for _, row := range rows {
cfg := make(map[string]json.RawMessage)
if err := json.Unmarshal([]byte(row.Config), &cfg); err != nil {
migration.logger.WarnContext(ctx, "skipping alertmanager config with unreadable config", slog.String("config_id", row.ID), errors.Attr(err))
continue
}
changed := false
if globalRaw, ok := cfg["global"]; ok && string(globalRaw) != "null" {
global := make(map[string]json.RawMessage)
if err := json.Unmarshal(globalRaw, &global); err != nil {
migration.logger.WarnContext(ctx, "skipping alertmanager config with unreadable global", slog.String("config_id", row.ID), errors.Attr(err))
continue
}
if deleteKeys(global, globalSMTPKeys) {
newGlobal, err := json.Marshal(global)
if err != nil {
return err
}
cfg["global"] = newGlobal
changed = true
}
}
if receiversRaw, ok := cfg["receivers"]; ok && string(receiversRaw) != "null" {
receivers := make([]map[string]json.RawMessage, 0)
if err := json.Unmarshal(receiversRaw, &receivers); err != nil {
migration.logger.WarnContext(ctx, "skipping alertmanager config with unreadable receivers", slog.String("config_id", row.ID), errors.Attr(err))
continue
}
receiversChanged := false
for _, receiver := range receivers {
scrubbed, err := scrubEmailConfigs(receiver)
if err != nil {
return err
}
receiversChanged = receiversChanged || scrubbed
}
if receiversChanged {
newReceivers, err := json.Marshal(receivers)
if err != nil {
return err
}
cfg["receivers"] = newReceivers
changed = true
}
}
if !changed {
continue
}
newConfig, err := json.Marshal(cfg)
if err != nil {
return err
}
if _, err := tx.NewUpdate().
Model((*alertmanagerConfigScrubRow)(nil)).
Set("config = ?", string(newConfig)).
Set("hash = ?", fmt.Sprintf("%x", md5.Sum(newConfig))).
Where("id = ?", row.ID).
Exec(ctx); err != nil {
return err
}
}
return nil
}
func (migration *scrubEmailChannelTransport) scrubChannels(ctx context.Context, tx bun.Tx) error {
rows := make([]*notificationChannelScrubRow, 0)
if err := tx.NewSelect().Model(&rows).Where("type = ?", "email").Scan(ctx); err != nil {
return err
}
for _, row := range rows {
receiver := make(map[string]json.RawMessage)
if err := json.Unmarshal([]byte(row.Data), &receiver); err != nil {
migration.logger.WarnContext(ctx, "skipping notification channel with unreadable data", slog.String("channel_id", row.ID), errors.Attr(err))
continue
}
scrubbed, err := scrubEmailConfigs(receiver)
if err != nil {
return err
}
if !scrubbed {
continue
}
newData, err := json.Marshal(receiver)
if err != nil {
return err
}
if _, err := tx.NewUpdate().
Model((*notificationChannelScrubRow)(nil)).
Set("data = ?", string(newData)).
Where("id = ?", row.ID).
Exec(ctx); err != nil {
return err
}
}
return nil
}
func scrubEmailConfigs(receiver map[string]json.RawMessage) (bool, error) {
emailConfigsRaw, ok := receiver["email_configs"]
if !ok || string(emailConfigsRaw) == "null" {
return false, nil
}
emailConfigs := make([]map[string]json.RawMessage, 0)
if err := json.Unmarshal(emailConfigsRaw, &emailConfigs); err != nil {
return false, err
}
changed := false
for _, emailConfig := range emailConfigs {
changed = deleteKeys(emailConfig, emailTransportKeys) || changed
}
if !changed {
return false, nil
}
newEmailConfigs, err := json.Marshal(emailConfigs)
if err != nil {
return false, err
}
receiver["email_configs"] = newEmailConfigs
return true, nil
}
func deleteKeys(m map[string]json.RawMessage, keys []string) bool {
deleted := false
for _, key := range keys {
if _, ok := m[key]; ok {
delete(m, key)
deleted = true
}
}
return deleted
}
func (migration *scrubEmailChannelTransport) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -35,12 +35,8 @@ func TestNewConfigFromChannels(t *testing.T) {
"email_configs": []any{map[string]any{
"send_resolved": false,
"to": "test@example.com",
"from": "alerts@example.com",
"hello": "localhost",
"smarthost": "smtp.example.com:587",
"require_tls": true,
"smarthost": "",
"html": "{{ template \"email.default.html\" . }}",
"tls_config": map[string]any{"insecure_skip_verify": false},
"threading": map[string]any{},
}},
},
@@ -63,7 +59,6 @@ func TestNewConfigFromChannels(t *testing.T) {
"slack_configs": []any{map[string]any{
"send_resolved": true,
"api_url": "https://slack.com/api/test",
"app_url": "https://slack.com/api/chat.postMessage",
"channel": "#alerts",
"callback_id": "{{ template \"slack.default.callbackid\" . }}",
"color": "{{ if eq .Status \"firing\" }}danger{{ else }}good{{ end }}",
@@ -77,12 +72,6 @@ func TestNewConfigFromChannels(t *testing.T) {
"title": "{{ template \"slack.default.title\" . }}",
"title_link": "{{ template \"slack.default.titlelink\" . }}",
"username": "{{ template \"slack.default.username\" . }}",
"http_config": map[string]any{
"tls_config": map[string]any{"insecure_skip_verify": false},
"follow_redirects": true,
"enable_http2": true,
"proxy_url": nil,
},
}},
},
},
@@ -104,7 +93,6 @@ func TestNewConfigFromChannels(t *testing.T) {
"pagerduty_configs": []any{map[string]any{
"send_resolved": false,
"service_key": "test",
"url": "https://events.pagerduty.com/v2/enqueue",
"client": "{{ template \"pagerduty.default.client\" . }}",
"client_url": "{{ template \"pagerduty.default.clientURL\" . }}",
"description": "{{ template \"pagerduty.default.description\" .}}",
@@ -116,12 +104,6 @@ func TestNewConfigFromChannels(t *testing.T) {
"num_resolved": "{{ .Alerts.Resolved | len }}",
"resolved": "{{ .Alerts.Resolved | toJson }}",
},
"http_config": map[string]any{
"tls_config": map[string]any{"insecure_skip_verify": false},
"follow_redirects": true,
"enable_http2": true,
"proxy_url": nil,
},
}},
},
},
@@ -148,7 +130,6 @@ func TestNewConfigFromChannels(t *testing.T) {
"pagerduty_configs": []any{map[string]any{
"send_resolved": false,
"service_key": "test",
"url": "https://events.pagerduty.com/v2/enqueue",
"client": "{{ template \"pagerduty.default.client\" . }}",
"client_url": "{{ template \"pagerduty.default.clientURL\" . }}",
"description": "{{ template \"pagerduty.default.description\" .}}",
@@ -160,12 +141,6 @@ func TestNewConfigFromChannels(t *testing.T) {
"num_resolved": "{{ .Alerts.Resolved | len }}",
"resolved": "{{ .Alerts.Resolved | toJson }}",
},
"http_config": map[string]any{
"tls_config": map[string]any{"insecure_skip_verify": false},
"follow_redirects": true,
"enable_http2": true,
"proxy_url": nil,
},
}},
},
{
@@ -173,7 +148,6 @@ func TestNewConfigFromChannels(t *testing.T) {
"slack_configs": []any{map[string]any{
"send_resolved": true,
"api_url": "https://slack.com/api/test",
"app_url": "https://slack.com/api/chat.postMessage",
"channel": "#alerts",
"callback_id": "{{ template \"slack.default.callbackid\" . }}",
"color": "{{ if eq .Status \"firing\" }}danger{{ else }}good{{ end }}",
@@ -187,12 +161,6 @@ func TestNewConfigFromChannels(t *testing.T) {
"title": "{{ template \"slack.default.title\" . }}",
"title_link": "{{ template \"slack.default.titlelink\" . }}",
"username": "{{ template \"slack.default.username\" . }}",
"http_config": map[string]any{
"tls_config": map[string]any{"insecure_skip_verify": false},
"follow_redirects": true,
"enable_http2": true,
"proxy_url": nil,
},
}},
},
},

View File

@@ -174,7 +174,7 @@ func newConfigFromString(s string) (*config.Config, map[string]customReceiverCon
return amConfig, customConfigs, nil
}
func newRawFromConfig(c *config.Config, customConfigs map[string]customReceiverConfigs) []byte {
func extendedReceivers(c *config.Config, customConfigs map[string]customReceiverConfigs) []*Receiver {
receivers := make([]*Receiver, len(c.Receivers))
for i := range c.Receivers {
base := c.Receivers[i]
@@ -185,7 +185,14 @@ func newRawFromConfig(c *config.Config, customConfigs map[string]customReceiverC
}
}
b, err := json.Marshal(storedConfig{Config: c, Receivers: receivers})
return receivers
}
func newRawFromConfig(c *config.Config, customConfigs map[string]customReceiverConfigs) []byte {
persistable := *c
persistable.Global = persistableGlobal(c.Global)
b, err := json.Marshal(storedConfig{Config: &persistable, Receivers: extendedReceivers(c, customConfigs)})
if err != nil {
// Taking inspiration from the upstream. This is never expected to happen.
return []byte(fmt.Sprintf("<error creating config string: %s>", err))
@@ -194,6 +201,28 @@ func newRawFromConfig(c *config.Config, customConfigs map[string]customReceiverC
return b
}
func persistableGlobal(g *config.GlobalConfig) *config.GlobalConfig {
if g == nil {
return nil
}
stripped := *g
stripped.SMTPFrom = ""
stripped.SMTPHello = ""
stripped.SMTPSmarthost = config.HostPort{}
stripped.SMTPAuthUsername = ""
stripped.SMTPAuthPassword = ""
stripped.SMTPAuthPasswordFile = ""
stripped.SMTPAuthSecret = ""
stripped.SMTPAuthSecretFile = ""
stripped.SMTPAuthIdentity = ""
stripped.SMTPRequireTLS = false
stripped.SMTPTLSConfig = nil
stripped.SMTPForceImplicitTLS = nil
return &stripped
}
func newConfigHash(s string) [16]byte {
return md5.Sum([]byte(s))
}
@@ -206,6 +235,37 @@ func (c *Config) flush() {
c.storeableConfig.UpdatedAt = time.Now()
}
func (c *Config) Resolved() (*Config, error) {
raw, err := json.Marshal(storedConfig{Config: c.alertmanagerConfig, Receivers: extendedReceivers(c.alertmanagerConfig, c.customConfigs)})
if err != nil {
return nil, err
}
alertmanagerConfig, customConfigs, err := newConfigFromString(string(raw))
if err != nil {
return nil, err
}
if err := alertmanagerConfig.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
return nil, err
}
storeableConfig := *c.storeableConfig
resolved := &Config{
alertmanagerConfig: alertmanagerConfig,
customConfigs: customConfigs,
storeableConfig: &storeableConfig,
}
resolved.applyNativeDefaults()
return resolved, nil
}
func (c *Config) validate() error {
_, err := c.Resolved()
return err
}
func (c *Config) CopyWithReset() (*Config, error) {
newConfig, err := NewDefaultConfig(
*c.alertmanagerConfig.Global,
@@ -271,6 +331,15 @@ func (c *Config) StoreableConfig() *StoreableConfig {
return c.storeableConfig
}
func cloneReceiver(receiver *Receiver) (*Receiver, error) {
raw, err := json.Marshal(receiver)
if err != nil {
return nil, err
}
return NewReceiver(string(raw))
}
func (c *Config) CreateReceiver(receiver *Receiver) error {
// check that receiver name is not already used
for _, existingReceiver := range c.alertmanagerConfig.Receivers {
@@ -279,16 +348,21 @@ func (c *Config) CreateReceiver(receiver *Receiver) error {
}
}
route, err := NewRouteFromReceiver(receiver)
owned, err := cloneReceiver(receiver)
if err != nil {
return err
}
route, err := NewRouteFromReceiver(owned)
if err != nil {
return err
}
c.alertmanagerConfig.Route.Routes = append(c.alertmanagerConfig.Route.Routes, route)
c.alertmanagerConfig.Receivers = append(c.alertmanagerConfig.Receivers, *receiver.Receiver)
c.setCustomConfigs(receiver)
c.alertmanagerConfig.Receivers = append(c.alertmanagerConfig.Receivers, *owned.Receiver)
c.setCustomConfigs(owned)
if err := c.alertmanagerConfig.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
if err := c.validate(); err != nil {
return err
}
c.applyNativeDefaults()
@@ -313,16 +387,21 @@ func (c *Config) GetReceiver(name string) (*Receiver, error) {
}
func (c *Config) UpdateReceiver(receiver *Receiver) error {
owned, err := cloneReceiver(receiver)
if err != nil {
return err
}
// find and update receiver
for i, existingReceiver := range c.alertmanagerConfig.Receivers {
if existingReceiver.Name == receiver.Name {
c.alertmanagerConfig.Receivers[i] = *receiver.Receiver
c.setCustomConfigs(receiver)
if existingReceiver.Name == owned.Name {
c.alertmanagerConfig.Receivers[i] = *owned.Receiver
c.setCustomConfigs(owned)
break
}
}
if err := c.alertmanagerConfig.UnmarshalYAML(func(i interface{}) error { return nil }); err != nil {
if err := c.validate(); err != nil {
return err
}
c.applyNativeDefaults()

View File

@@ -330,6 +330,120 @@ func TestSetGlobalConfigPreservesSMTPRequireTLS(t *testing.T) {
}
}
func newSMTPGlobalConfig() GlobalConfig {
return GlobalConfig{
SMTPFrom: "alerts@example.com",
SMTPHello: "example.com",
SMTPSmarthost: config.HostPort{Host: "smtp.sendgrid.net", Port: "587"},
SMTPAuthUsername: "apikey",
SMTPAuthPassword: "operator-secret",
SMTPRequireTLS: true,
}
}
func newEmailTestConfig(t *testing.T) *Config {
t.Helper()
cfg, err := NewDefaultConfig(
newSMTPGlobalConfig(),
RouteConfig{GroupInterval: time.Minute, GroupWait: time.Minute, RepeatInterval: time.Minute},
"1",
)
require.NoError(t, err)
receiver, err := NewReceiver(`{"name":"email-receiver","email_configs":[{"to":"team@example.com"}]}`)
require.NoError(t, err)
require.NoError(t, cfg.CreateReceiver(receiver))
return cfg
}
func TestStoreableConfigCarriesNoSMTPSettings(t *testing.T) {
cfg := newEmailTestConfig(t)
raw := cfg.StoreableConfig().Config
assert.NotContains(t, raw, "operator-secret")
assert.NotContains(t, raw, "smtp.sendgrid.net")
assert.NotContains(t, raw, "apikey")
assert.NotContains(t, raw, "alerts@example.com")
assert.Equal(t, "operator-secret", string(cfg.alertmanagerConfig.Global.SMTPAuthPassword))
}
func TestResolvedFillsEmailTransportFromGlobal(t *testing.T) {
cfg := newEmailTestConfig(t)
resolved, err := cfg.Resolved()
require.NoError(t, err)
receiver, err := resolved.GetReceiver("email-receiver")
require.NoError(t, err)
require.Len(t, receiver.EmailConfigs, 1)
got := receiver.EmailConfigs[0]
assert.Equal(t, "team@example.com", got.To)
assert.Equal(t, "smtp.sendgrid.net:587", got.Smarthost.String())
assert.Equal(t, "alerts@example.com", got.From)
assert.Equal(t, "apikey", got.AuthUsername)
assert.Equal(t, "operator-secret", string(got.AuthPassword))
require.NotNil(t, got.RequireTLS)
assert.True(t, *got.RequireTLS)
stored, err := cfg.GetReceiver("email-receiver")
require.NoError(t, err)
assert.Empty(t, stored.EmailConfigs[0].Smarthost.String())
assert.NotContains(t, cfg.StoreableConfig().Config, "operator-secret")
}
func TestStaleStoredSMTPSettingsAreReplacedOnLoad(t *testing.T) {
stored := &StoreableConfig{
Config: `{"global":{"resolve_timeout":"5m","smtp_from":"old@example.com","smtp_hello":"localhost","smtp_smarthost":"email-smtp.us-east-1.amazonaws.com:587","smtp_auth_username":"old-user","smtp_auth_password":"old-secret","smtp_require_tls":true},"route":{"receiver":"default-receiver","group_by":["ruleId"],"routes":[{"receiver":"email-receiver","continue":true,"matchers":["ruleId=~\"-1\""]}],"group_wait":"30s","group_interval":"5m","repeat_interval":"4h"},"receivers":[{"name":"default-receiver"},{"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":"old-user","auth_password":"old-secret","require_tls":true}]}]}`,
OrgID: "1",
}
cfg, err := NewConfigFromStoreableConfig(stored)
require.NoError(t, err)
loaded, err := cfg.GetReceiver("email-receiver")
require.NoError(t, err)
require.Len(t, loaded.EmailConfigs, 1)
assert.Empty(t, loaded.EmailConfigs[0].Smarthost.String())
assert.Empty(t, string(loaded.EmailConfigs[0].AuthPassword))
require.NoError(t, cfg.SetGlobalConfig(newSMTPGlobalConfig()))
resolved, err := cfg.Resolved()
require.NoError(t, err)
receiver, err := resolved.GetReceiver("email-receiver")
require.NoError(t, err)
got := receiver.EmailConfigs[0]
assert.Equal(t, "smtp.sendgrid.net:587", got.Smarthost.String())
assert.Equal(t, "operator-secret", string(got.AuthPassword))
assert.Equal(t, "alerts@example.com", got.From)
assert.NotContains(t, cfg.StoreableConfig().Config, "old-secret")
assert.NotContains(t, cfg.StoreableConfig().Config, "amazonaws.com")
assert.NotContains(t, cfg.StoreableConfig().Config, "operator-secret")
}
func TestCreateReceiverDoesNotMutateCaller(t *testing.T) {
cfg := newEmailTestConfig(t)
resolved, err := cfg.Resolved()
require.NoError(t, err)
receiver, err := resolved.GetReceiver("email-receiver")
require.NoError(t, err)
require.Equal(t, "smtp.sendgrid.net:587", receiver.EmailConfigs[0].Smarthost.String())
throwaway, err := cfg.CopyWithReset()
require.NoError(t, err)
require.NoError(t, throwaway.CreateReceiver(receiver))
assert.Equal(t, "smtp.sendgrid.net:587", receiver.EmailConfigs[0].Smarthost.String())
assert.Equal(t, "operator-secret", string(receiver.EmailConfigs[0].AuthPassword))
}
// Round-trip: create → serialize → reload → GetReceiver still has the configs.
func TestConfigPreservesGoogleChatConfigs(t *testing.T) {
webhookURL, err := url.Parse("https://chat.googleapis.com/v1/spaces/test/messages")

View File

@@ -37,6 +37,7 @@ func NewReceiver(input string) (*Receiver, error) {
if err != nil {
return nil, err
}
stripEmailTransport(withDefaults)
receiver.Receiver = withDefaults
// Extend this block when adding another native notifier type.
@@ -51,6 +52,23 @@ func NewReceiver(input string) (*Receiver, error) {
return receiver, nil
}
func stripEmailTransport(base *config.Receiver) {
for _, ec := range base.EmailConfigs {
ec.From = ""
ec.Hello = ""
ec.Smarthost = config.HostPort{}
ec.AuthUsername = ""
ec.AuthPassword = ""
ec.AuthPasswordFile = ""
ec.AuthSecret = ""
ec.AuthSecretFile = ""
ec.AuthIdentity = ""
ec.RequireTLS = nil
ec.TLSConfig = nil
ec.ForceImplicitTLS = nil
}
}
func defaultedBaseReceiver(base *config.Receiver) (*config.Receiver, error) {
bytes, err := yaml.Marshal(base)
if err != nil {
@@ -100,7 +118,12 @@ func TestReceiver(ctx context.Context, receiver *Receiver, receiverIntegrationsF
return err
}
defaultedReceiver, err := testConfig.GetReceiver(receiver.Name)
resolvedConfig, err := testConfig.Resolved()
if err != nil {
return err
}
defaultedReceiver, err := resolvedConfig.GetReceiver(receiver.Name)
if err != nil {
return err
}

View File

@@ -46,6 +46,31 @@ func TestNewReceiver(t *testing.T) {
}
}
func TestNewReceiverStripsEmailTransport(t *testing.T) {
receiver, err := NewReceiver(`{"name":"email","email_configs":[{"to":"team@example.com","from":"attacker@example.com","hello":"example.com","smarthost":"smtp.example.com:587","auth_username":"user","auth_password":"supersecret","auth_secret":"alsosecret","auth_identity":"id","require_tls":false,"headers":{"Subject":"custom"}}]}`)
require.NoError(t, err)
require.Len(t, receiver.EmailConfigs, 1)
got := receiver.EmailConfigs[0]
assert.Equal(t, "team@example.com", got.To)
assert.Equal(t, map[string]string{"Subject": "custom"}, got.Headers)
assert.Empty(t, got.From)
assert.Empty(t, got.Hello)
assert.Empty(t, got.Smarthost.String())
assert.Empty(t, got.AuthUsername)
assert.Empty(t, string(got.AuthPassword))
assert.Empty(t, string(got.AuthSecret))
assert.Empty(t, got.AuthIdentity)
assert.Nil(t, got.RequireTLS)
assert.Nil(t, got.TLSConfig)
bytes, err := json.Marshal(receiver)
require.NoError(t, err)
assert.NotContains(t, string(bytes), "supersecret")
assert.NotContains(t, string(bytes), "smtp.example.com")
}
// Omitted fields fall back to DefaultGoogleChatReceiverConfig.
func TestNewReceiverGoogleChatAppliesDefaults(t *testing.T) {
receiver, err := NewReceiver(`{"name":"googlechat","googlechat_configs":[{"webhook_url":"https://chat.googleapis.com/v1/spaces/test/messages"}]}`)

View File

@@ -125,10 +125,10 @@ type CollectedMetric struct {
// This is used to show available pre-made dashboards for a service,
// hence has additional fields like id, title and description.
type Dashboard struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Definition dashboardtypes.PostableDashboardV2 `json:"definition"`
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Definition dashboardtypes.StorableDashboardData `json:"definition,omitempty"`
}
type ServiceDashboard struct {

View File

@@ -51,7 +51,7 @@ func resolveV1Image(image string) string {
if path, ok := v1Base64IconToAssetPath[image]; ok {
return path
}
if reencoded, ok := ReencodeInlineSVGImage(image); ok {
if reencoded, ok := reencodeInlineSVGImage(image); ok {
image = reencoded
}
if (DashboardV2MetadataBase{Image: image}).validateImage() == nil {
@@ -60,10 +60,10 @@ func resolveV1Image(image string) string {
return defaultDashboardIconPath
}
// ReencodeInlineSVGImage rewrites a percent-encoded inline SVG data URI into the
// reencodeInlineSVGImage rewrites a percent-encoded inline SVG data URI into the
// base64 form the v2 schema accepts, preserving an icon that would otherwise fail
// validation. Returns (image, false) unchanged for anything else.
func ReencodeInlineSVGImage(image string) (string, bool) {
func reencodeInlineSVGImage(image string) (string, bool) {
const mediaType = "data:image/svg+xml"
if !strings.HasPrefix(image, mediaType) {
return image, false

View File

@@ -174,7 +174,7 @@ func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind Pan
queries := d.readObjects(builder, "queryData")
assignQueryDataNames(queries)
for _, q := range queries {
normalizePreV5QueryData(q, widgetType, panelKind)
normalizePreV5QueryData(q, widgetType)
normalizePreV5SelectColumns(q)
normalizePreV5GroupBy(q)
normalizePreV5PageSize(q, rowLimitPanel)
@@ -197,7 +197,7 @@ func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind Pan
formulas := d.readObjects(builder, "queryFormulas")
assignMissingFormulaNames(formulas)
for _, f := range formulas {
normalizePreV5QueryData(f, widgetType, panelKind)
normalizePreV5QueryData(f, widgetType)
name := d.readString(f, "queryName")
env := qb.WrapInV5Envelope(name, f, string(qb.QueryTypeFormula.StringValue()))
backfillFormulaFields(env, f)
@@ -216,7 +216,7 @@ func (d *v1Decoder) collectV1QueryEnvelopes(widget map[string]any, panelKind Pan
if expression == "" {
continue
}
normalizePreV5QueryData(op, widgetType, panelKind)
normalizePreV5QueryData(op, widgetType)
normalizePreV5GroupBy(op)
normalizeOrderByKeys(op)
name := d.readString(op, "queryName")

View File

@@ -30,16 +30,13 @@ var preV5Migrator = transition.NewDashboardMigrateV5(slog.New(slog.DiscardHandle
// normalizePreV5QueryData upgrades one builder queryData/formula in place: the
// shared migrator, then a reshape of any existing aggregations[] it leaves alone.
func normalizePreV5QueryData(query map[string]any, widgetType string, panelKind PanelPluginKind) {
func normalizePreV5QueryData(query map[string]any, widgetType string) {
dropLegacyFilter(query)
normalizeFilterItemOps(query)
foldReduceToIntoMetricAggregations(query)
preV5Migrator.MigrateQueryDataShapeSafe(context.Background(), query, widgetType)
normalizePreV5LogTraceAggregations(query)
normalizeMetricAggregations(query)
// After the migrator has built aggregations from flat fields and the reshape above
// has settled them; the caller's ensureDefaultAggregation only injects for logs/traces.
ensureMetricReduceTo(query, panelKind)
normalizeFunctionArgs(query)
dropInvalidFunctions(query)
// normalizeOrderByKeys runs in the caller, after ensureDefaultAggregation: a
@@ -333,51 +330,6 @@ func foldReduceToIntoMetricAggregations(query map[string]any) {
}
}
// ensureMetricReduceTo fills a metric aggregation's reduceTo on scalar panels, which
// reject one without it (QueryBuilderQuery.Validate) where v1 never required it. The
// shared migrator only derives a reduceTo for v3-shaped table widgets, so value/pie
// panels and bodies already carrying aggregations[] arrive empty. A valid reduceTo is
// left alone.
func ensureMetricReduceTo(query map[string]any, panelKind PanelPluginKind) {
if requestTypeForPanel(panelKind) != qb.RequestTypeScalar {
return
}
if signalFromDataSource(query["dataSource"]) != telemetrytypes.SignalMetrics {
return
}
aggs, ok := query["aggregations"].([]any)
if !ok {
return
}
for _, a := range aggs {
agg, ok := a.(map[string]any)
if !ok {
continue
}
reduceTo, _ := agg["reduceTo"].(string)
if (qb.ReduceTo{String: valuer.NewString(reduceTo)}).IsValid() {
continue
}
agg["reduceTo"] = deriveReduceToForMetricAggregation(agg).StringValue()
}
}
// deriveReduceToForMetricAggregation reads the metric's kind off its aggregation: a
// percentile space aggregation is a histogram, a rate/increase time aggregation is a
// counter, anything else a gauge.
func deriveReduceToForMetricAggregation(agg map[string]any) qb.ReduceTo {
spaceAgg, _ := agg["spaceAggregation"].(string)
if (metrictypes.SpaceAggregation{String: valuer.NewString(spaceAgg)}).IsPercentile() {
return qb.ReduceToAvg
}
timeAgg, _ := agg["timeAggregation"].(string)
switch (metrictypes.TimeAggregation{String: valuer.NewString(timeAgg)}) {
case metrictypes.TimeAggregationRate, metrictypes.TimeAggregationIncrease:
return qb.ReduceToSum
}
return qb.ReduceToLast
}
// normalizePreV5LogTraceAggregations reshapes an existing logs/traces aggregations[]
// via parseAggregations (extract func(args), lift inline "as alias", split
// multi-part, drop metric-only fields; empty → count()). Covers the case the

View File

@@ -498,7 +498,7 @@ func TestReencodeInlineSVGImage(t *testing.T) {
for _, testCase := range testCases {
t.Run(testCase.scenario, func(t *testing.T) {
got, rewritten := ReencodeInlineSVGImage(testCase.image)
got, rewritten := reencodeInlineSVGImage(testCase.image)
assert.Equal(t, testCase.wantRewritten, rewritten)
assert.Equal(t, testCase.want, got)
})
@@ -544,102 +544,6 @@ func TestFoldReduceToIntoMetricAggregations(t *testing.T) {
assert.Equal(t, "avg", query["aggregations"].([]any)[0].(map[string]any)["reduceTo"])
}
func TestEnsureMetricReduceTo(t *testing.T) {
testCases := []struct {
scenario string
panelKind PanelPluginKind
queryData map[string]any
expectedReduceTo any
}{
{
scenario: "counter queried as a rate reduces to sum",
panelKind: PanelKindNumber,
queryData: map[string]any{
"dataSource": "metrics",
"aggregations": []any{map[string]any{"metricName": "m", "timeAggregation": "rate", "spaceAggregation": "sum"}},
},
expectedReduceTo: "sum",
},
{
scenario: "counter queried as an increase reduces to sum",
panelKind: PanelKindTable,
queryData: map[string]any{
"dataSource": "metrics",
"aggregations": []any{map[string]any{"metricName": "m", "timeAggregation": "increase", "spaceAggregation": "sum"}},
},
expectedReduceTo: "sum",
},
{
scenario: "percentile space aggregation marks a histogram and reduces to avg",
panelKind: PanelKindNumber,
queryData: map[string]any{
"dataSource": "metrics",
"aggregations": []any{map[string]any{"metricName": "m", "timeAggregation": "rate", "spaceAggregation": "p95"}},
},
expectedReduceTo: "avg",
},
{
scenario: "gauge reduces to last",
panelKind: PanelKindPieChart,
queryData: map[string]any{
"dataSource": "metrics",
"aggregations": []any{map[string]any{"metricName": "m", "timeAggregation": "avg", "spaceAggregation": "avg"}},
},
expectedReduceTo: "last",
},
{
scenario: "a reduceTo the author already chose survives",
panelKind: PanelKindNumber,
queryData: map[string]any{
"dataSource": "metrics",
"aggregations": []any{map[string]any{"metricName": "m", "timeAggregation": "rate", "spaceAggregation": "sum", "reduceTo": "median"}},
},
expectedReduceTo: "median",
},
{
scenario: "a reduceTo outside the enum is replaced",
panelKind: PanelKindNumber,
queryData: map[string]any{
"dataSource": "metrics",
"aggregations": []any{map[string]any{"metricName": "m", "timeAggregation": "avg", "spaceAggregation": "sum", "reduceTo": "not-an-operator"}},
},
expectedReduceTo: "last",
},
{
scenario: "a time series panel needs no reduceTo",
panelKind: PanelKindTimeSeries,
queryData: map[string]any{
"dataSource": "metrics",
"aggregations": []any{map[string]any{"metricName": "m", "timeAggregation": "rate", "spaceAggregation": "sum"}},
},
expectedReduceTo: nil,
},
{
scenario: "reduceTo is metrics-only, so a logs query is untouched",
panelKind: PanelKindNumber,
queryData: map[string]any{
"dataSource": "logs",
"aggregations": []any{map[string]any{"expression": "count()"}},
},
expectedReduceTo: nil,
},
}
for _, testCase := range testCases {
t.Run(testCase.scenario, func(t *testing.T) {
ensureMetricReduceTo(testCase.queryData, testCase.panelKind)
aggs, ok := testCase.queryData["aggregations"].([]any)
require.True(t, ok)
require.Len(t, aggs, 1)
agg, ok := aggs[0].(map[string]any)
require.True(t, ok)
assert.Equal(t, testCase.expectedReduceTo, agg["reduceTo"])
})
}
}
func TestMapV1SelectFieldsPicksBySignal(t *testing.T) {
d := &v1Decoder{}
// A list widget carrying both arrays; the query's signal must decide which wins.

View File

@@ -322,10 +322,6 @@ func (ReduceTo) Enum() []any {
}
}
func (r ReduceTo) IsValid() bool {
return slices.ContainsFunc(r.Enum(), func(v any) bool { return v == r })
}
// FunctionReduceTo applies the reduceTo operator to a time series and returns a new series with the reduced value
// reduceTo can be one of: last, sum, avg, min, max, count, median
// if reduceTo is not recognized, the function returns the original series.

View File

@@ -78,7 +78,6 @@ type validationConfig struct {
skipSelectFieldValidation bool
skipGroupByValidation bool
withTimestampGroupByValidation bool
withReduceToValidation bool
}
func applyValidationOptions(opts []ValidationOption) validationConfig {
@@ -144,15 +143,6 @@ func WithTimestampGroupByValidation() ValidationOption {
}
}
// WithReduceToValidation enables validation that metric aggregations carry a
// valid reduceTo operator. Used for scalar request types, where the time
// series produced by a metric query must be reduced to a single value.
func WithReduceToValidation() ValidationOption {
return func(cfg *validationConfig) {
cfg.withReduceToValidation = true
}
}
// Validate performs preliminary validation on QueryBuilderQuery.
func (q *QueryBuilderQuery[T]) Validate(opts ...ValidationOption) error {
cfg := applyValidationOptions(opts)
@@ -289,29 +279,6 @@ func (q *QueryBuilderQuery[T]) validateAggregations(cfg validationConfig) error
"invalid space aggregation, should be one of the following: [`sum`, `avg`, `min`, `max`, `count`, `p50`, `p75`, `p90`, `p95`, `p99`]",
)
}
if cfg.withReduceToValidation && !v.ReduceTo.IsValid() {
aggId := fmt.Sprintf("aggregation #%d", i+1)
if q.Name != "" {
aggId = fmt.Sprintf("aggregation #%d in query '%s'", i+1, q.Name)
}
if v.ReduceTo == ReduceToUnknown {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"reduceTo is required for %s for the scalar request type",
aggId,
).WithAdditional(
"Metric queries produce a time series; scalar requests must specify how to reduce it to a single value. Valid values are: sum, count, avg, min, max, last, median",
)
}
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid reduceTo `%s` for %s",
v.ReduceTo.StringValue(),
aggId,
).WithAdditional(
"Valid values are: sum, count, avg, min, max, last, median",
)
}
case TraceAggregation:
if v.Expression == "" {
aggId := fmt.Sprintf("aggregation #%d", i+1)
@@ -823,7 +790,7 @@ func GetValidationOptions(requestType RequestType) []ValidationOption {
case RequestTypeTimeSeries:
return []ValidationOption{WithSkipSelectFieldValidation(), WithTimestampGroupByValidation()}
case RequestTypeScalar:
return []ValidationOption{WithSkipSelectFieldValidation(), WithReduceToValidation()}
return []ValidationOption{WithSkipSelectFieldValidation()}
case RequestTypeRaw, RequestTypeRawStream, RequestTypeTrace:
return []ValidationOption{WithSkipAggregationValidation(), WithSkipHavingValidation(), WithSkipAggregationOrderBy(), WithSkipGroupByValidation()}
default:

View File

@@ -6,7 +6,6 @@ import (
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func contains(s, substr string) bool {
@@ -770,100 +769,6 @@ func TestQueryRangeRequest_ValidateCompositeQuery(t *testing.T) {
},
wantErr: false,
},
{
name: "scalar request with metric query without reduceTo should return error",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeScalar,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []MetricAggregation{
{MetricName: "test_metric", SpaceAggregation: metrictypes.SpaceAggregationSum},
},
},
},
},
},
},
wantErr: true,
errMsg: "reduceTo is required",
},
{
name: "scalar request with metric query with invalid reduceTo should return error",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeScalar,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []MetricAggregation{
{MetricName: "test_metric", SpaceAggregation: metrictypes.SpaceAggregationSum, ReduceTo: ReduceTo{valuer.NewString("p99")}},
},
},
},
},
},
},
wantErr: true,
errMsg: "invalid reduceTo",
},
{
name: "scalar request with metric query with reduceTo should pass",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeScalar,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []MetricAggregation{
{MetricName: "test_metric", SpaceAggregation: metrictypes.SpaceAggregationSum, ReduceTo: ReduceToLast},
},
},
},
},
},
},
wantErr: false,
},
{
name: "timeseries request with metric query without reduceTo should pass",
request: QueryRangeRequest{
Start: 1640995200000,
End: 1640998800000,
RequestType: RequestTypeTimeSeries,
CompositeQuery: CompositeQuery{
Queries: []QueryEnvelope{
{
Type: QueryTypeBuilder,
Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []MetricAggregation{
{MetricName: "test_metric", SpaceAggregation: metrictypes.SpaceAggregationSum},
},
},
},
},
},
},
wantErr: false,
},
}
for _, tt := range tests {

View File

@@ -22,6 +22,7 @@ pytest_plugins = [
"fixtures.keycloak",
"fixtures.idp",
"fixtures.notification_channel",
"fixtures.maildev",
"fixtures.alerts",
"fixtures.cloudintegrations",
"fixtures.jsontypes",

View File

@@ -1,9 +1,11 @@
import base64
import json
import re
import time
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from urllib.parse import urlparse
import pytest
import requests
@@ -13,6 +15,7 @@ from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.fs import get_testdata_file_path
from fixtures.logger import setup_logger
from fixtures.logs import Logs
from fixtures.maildev import get_all_mails, verify_email_received
from fixtures.metrics import Metrics
from fixtures.traces import Traces
@@ -218,3 +221,145 @@ def update_rule_channel_name(rule_data: dict, channel_name: str):
# loop over all the sepcs and update the channels
for spec in thresholds["spec"]:
spec["channels"] = [channel_name]
def _is_json_subset(subset, superset) -> bool:
"""Check if subset is contained within superset recursively.
- For dicts: all keys in subset must exist in superset with matching values
- For lists: all items in subset must be present in superset
- For scalars: exact equality
"""
if isinstance(subset, dict):
if not isinstance(superset, dict):
return False
return all(key in superset and _is_json_subset(value, superset[key]) for key, value in subset.items())
if isinstance(subset, list):
if not isinstance(superset, list):
return False
return all(any(_is_json_subset(sub_item, sup_item) for sup_item in superset) for sub_item in subset)
if isinstance(subset, re.Pattern):
return isinstance(superset, str) and subset.search(superset) is not None
return subset == superset
def verify_webhook_notification_expectation(
notification_channel: types.TestContainerDocker,
validation_data: dict,
) -> bool:
"""Check if wiremock received a request at the given path
whose JSON body is a superset of the expected json_body."""
path = validation_data["path"]
json_body = validation_data["json_body"]
url = notification_channel.host_configs["8080"].get("__admin/requests/find")
try:
res = requests.post(url, json={"method": "POST", "url": path}, timeout=10)
except requests.exceptions.RequestException:
return False
if res.status_code != HTTPStatus.OK:
return False
for req in res.json()["requests"]:
body = json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8"))
if _is_json_subset(json_body, body):
return True
return False
def _check_notification_validation(
validation: types.NotificationValidation,
notification_channel: types.TestContainerDocker,
maildev: types.TestContainerDocker,
) -> bool:
"""Dispatch a single validation check to the appropriate verifier."""
if validation.destination_type == "webhook":
return verify_webhook_notification_expectation(notification_channel, validation.validation_data)
if validation.destination_type == "email":
return verify_email_received(maildev, validation.validation_data)
raise ValueError(f"Invalid destination type: {validation.destination_type}")
def verify_notification_expectation(
notification_channel: types.TestContainerDocker,
maildev: types.TestContainerDocker,
expected_notification: types.AMNotificationExpectation,
) -> bool:
"""Poll for expected notifications across webhook and email channels."""
time_to_wait = datetime.now() + timedelta(seconds=expected_notification.wait_time_seconds)
while datetime.now() < time_to_wait:
all_found = all(_check_notification_validation(v, notification_channel, maildev) for v in expected_notification.notification_validations)
if expected_notification.should_notify and all_found:
logger.info("All expected notifications found")
return True
time.sleep(1)
# Timeout reached
if not expected_notification.should_notify:
# Verify no notifications were received
for validation in expected_notification.notification_validations:
found = _check_notification_validation(validation, notification_channel, maildev)
assert not found, f"Expected no notification but found one for {validation.destination_type} with data {validation.validation_data}"
logger.info("No notifications found, as expected")
return True
missing = [v for v in expected_notification.notification_validations if not _check_notification_validation(v, notification_channel, maildev)]
assert len(missing) == 0, f"Expected all notifications to be found but missing: {missing}, received: {_received_notifications(notification_channel, maildev, missing)}"
return True
def _received_notifications(
notification_channel: types.TestContainerDocker,
maildev: types.TestContainerDocker,
missing: list[types.NotificationValidation],
) -> dict:
received = {}
if any(v.destination_type == "webhook" for v in missing):
webhook_bodies = []
for validation in missing:
if validation.destination_type != "webhook":
continue
url = notification_channel.host_configs["8080"].get("__admin/requests/find")
try:
res = requests.post(url, json={"method": "POST", "url": validation.validation_data["path"]}, timeout=10)
webhook_bodies.extend(json.loads(base64.b64decode(req["bodyAsBase64"]).decode("utf-8")) for req in res.json()["requests"])
except requests.exceptions.RequestException as exc:
webhook_bodies.append(f"<failed to fetch wiremock journal: {exc}>")
received["webhook"] = webhook_bodies
if any(v.destination_type == "email" for v in missing):
received["email"] = get_all_mails(maildev)
return received
def update_raw_channel_config(
channel_config: dict,
channel_name: str,
notification_channel: types.TestContainerDocker,
) -> dict:
"""
Updates the channel config to point to the given wiremock
notification_channel container to receive notifications.
"""
config = channel_config.copy()
config["name"] = channel_name
url_field_map = {
"slack_configs": "api_url",
"msteamsv2_configs": "webhook_url",
"webhook_configs": "url",
"pagerduty_configs": "url",
"opsgenie_configs": "api_url",
}
for config_key, url_field in url_field_map.items():
if config_key in config:
for entry in config[config_key]:
if url_field in entry:
original_url = entry[url_field]
path = urlparse(original_url).path
entry[url_field] = notification_channel.container_configs["8080"].get(path)
return config

View File

@@ -77,7 +77,7 @@ def register_admin(
timeout=5,
)
assert response.status_code == HTTPStatus.OK
assert response.status_code == HTTPStatus.OK, f"failed to register admin: {response.status_code} {response.text}"
return types.Operation(name="create_user_admin")

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