mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-26 16:00:36 +01:00
Compare commits
1 Commits
nv/sql-das
...
email-aler
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8179d1a027 |
2
.github/workflows/integrationci.yaml
vendored
2
.github/workflows/integrationci.yaml
vendored
@@ -39,6 +39,8 @@ jobs:
|
||||
matrix:
|
||||
suite:
|
||||
- alerts
|
||||
- alertmanager
|
||||
- alertmanagerrotation
|
||||
- basepath
|
||||
- callbackauthn
|
||||
- cloudintegrations
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
10
frontend/src/hooks/useIsDashboardV2.ts
Normal file
10
frontend/src/hooks/useIsDashboardV2.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
162
pkg/alertmanager/signozalertmanager/provider_test.go
Normal file
162
pkg/alertmanager/signozalertmanager/provider_test.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,10 +31,7 @@
|
||||
"pipelines": []
|
||||
},
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
"file://assets/dashboards/overview.json"
|
||||
],
|
||||
"alerts": []
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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": []
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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": []
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,10 +35,7 @@
|
||||
"pipelines": []
|
||||
},
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
"file://assets/dashboards/overview.json"
|
||||
],
|
||||
"alerts": []
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,10 +31,7 @@
|
||||
"pipelines": []
|
||||
},
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
"file://assets/dashboards/overview.json"
|
||||
],
|
||||
"alerts": []
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,10 +31,7 @@
|
||||
"pipelines": []
|
||||
},
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
"file://assets/dashboards/overview.json"
|
||||
],
|
||||
"alerts": []
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,10 +31,7 @@
|
||||
"pipelines": []
|
||||
},
|
||||
"dashboards": [
|
||||
{
|
||||
"id": "overview",
|
||||
"definition": "file://assets/dashboards/overview.json"
|
||||
}
|
||||
"file://assets/dashboards/overview.json"
|
||||
],
|
||||
"alerts": []
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
263
pkg/sqlmigration/103_scrub_email_channel_transport.go
Normal file
263
pkg/sqlmigration/103_scrub_email_channel_transport.go
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"}]}`)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -22,6 +22,7 @@ pytest_plugins = [
|
||||
"fixtures.keycloak",
|
||||
"fixtures.idp",
|
||||
"fixtures.notification_channel",
|
||||
"fixtures.maildev",
|
||||
"fixtures.alerts",
|
||||
"fixtures.cloudintegrations",
|
||||
"fixtures.jsontypes",
|
||||
|
||||
145
tests/fixtures/alerts.py
vendored
145
tests/fixtures/alerts.py
vendored
@@ -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
|
||||
|
||||
2
tests/fixtures/auth.py
vendored
2
tests/fixtures/auth.py
vendored
@@ -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
Reference in New Issue
Block a user