mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-20 11:40:27 +00:00
Compare commits
8 Commits
v0.116.1
...
fix/filter
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17e51ba580 | ||
|
|
b7d0c8b5a2 | ||
|
|
ce5499d5a7 | ||
|
|
4554a09a42 | ||
|
|
794a7f4ca6 | ||
|
|
fd3b1c5374 | ||
|
|
e52c5683dd | ||
|
|
90e3cb6775 |
@@ -198,7 +198,10 @@ func (provider *provider) Checkout(ctx context.Context, organizationID valuer.UU
|
||||
|
||||
response, err := provider.zeus.GetCheckoutURL(ctx, activeLicense.Key, body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to generate checkout session")
|
||||
if errors.Ast(err, errors.TypeAlreadyExists) {
|
||||
return nil, errors.WithAdditionalf(err, "checkout has already been completed for this account. Please click 'Refresh Status' to sync your subscription")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
|
||||
@@ -217,7 +220,7 @@ func (provider *provider) Portal(ctx context.Context, organizationID valuer.UUID
|
||||
|
||||
response, err := provider.zeus.GetPortalURL(ctx, activeLicense.Key, body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to generate portal session")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
|
||||
|
||||
@@ -101,7 +101,7 @@ func (provider *provider) WrapNotFoundErrf(err error, code errors.Code, format s
|
||||
|
||||
func (provider *provider) WrapAlreadyExistsErrf(err error, code errors.Code, format string, args ...any) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
if errors.As(err, &pgErr) && (pgErr.Code == "23505" || pgErr.Code == "23503") {
|
||||
return errors.Wrapf(err, errors.TypeAlreadyExists, code, format, args...)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ const config: Config.InitialOptions = {
|
||||
'<rootDir>/node_modules/@signozhq/icons/dist/index.esm.js',
|
||||
'^react-syntax-highlighter/dist/esm/(.*)$':
|
||||
'<rootDir>/node_modules/react-syntax-highlighter/dist/cjs/$1',
|
||||
'^@signozhq/([^/]+)$': '<rootDir>/node_modules/@signozhq/$1/dist/$1.js',
|
||||
'^@signozhq/(?!ui$)([^/]+)$':
|
||||
'<rootDir>/node_modules/@signozhq/$1/dist/$1.js',
|
||||
},
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
testMatch: ['<rootDir>/src/**/*?(*.)(test).(ts|js)?(x)'],
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
"@signozhq/table": "0.3.7",
|
||||
"@signozhq/toggle-group": "0.0.1",
|
||||
"@signozhq/tooltip": "0.0.2",
|
||||
"@signozhq/ui": "0.0.5",
|
||||
"@tanstack/react-table": "8.20.6",
|
||||
"@tanstack/react-virtual": "3.11.2",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
|
||||
@@ -81,7 +81,8 @@ export const interceptorRejected = async (
|
||||
response.config.url !== '/sessions/email_password' &&
|
||||
!(
|
||||
response.config.url === '/sessions' && response.config.method === 'delete'
|
||||
)
|
||||
) &&
|
||||
response.config.url !== '/authz/check'
|
||||
) {
|
||||
try {
|
||||
const accessToken = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN);
|
||||
|
||||
152
frontend/src/api/interceptors.test.ts
Normal file
152
frontend/src/api/interceptors.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import axios, { AxiosHeaders, AxiosResponse } from 'axios';
|
||||
|
||||
import { interceptorRejected } from './index';
|
||||
|
||||
jest.mock('api/browser/localstorage/get', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => 'mock-token'),
|
||||
}));
|
||||
|
||||
jest.mock('api/v2/sessions/rotate/post', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() =>
|
||||
Promise.resolve({
|
||||
data: { accessToken: 'new-token', refreshToken: 'new-refresh' },
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('AppRoutes/utils', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('axios', () => {
|
||||
const actualAxios = jest.requireActual('axios');
|
||||
const mockAxios = jest.fn().mockResolvedValue({ data: 'success' });
|
||||
|
||||
return {
|
||||
...actualAxios,
|
||||
default: Object.assign(mockAxios, {
|
||||
...actualAxios.default,
|
||||
isAxiosError: jest.fn().mockReturnValue(true),
|
||||
create: actualAxios.create,
|
||||
}),
|
||||
__esModule: true,
|
||||
};
|
||||
});
|
||||
|
||||
describe('interceptorRejected', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
((axios as unknown) as jest.Mock).mockResolvedValue({ data: 'success' });
|
||||
((axios.isAxiosError as unknown) as jest.Mock).mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should preserve array payload structure when retrying a 401 request', async () => {
|
||||
const arrayPayload = [
|
||||
{ relation: 'assignee', object: { resource: { name: 'role' } } },
|
||||
{ relation: 'assignee', object: { resource: { name: 'editor' } } },
|
||||
];
|
||||
|
||||
const error = ({
|
||||
response: {
|
||||
status: 401,
|
||||
config: {
|
||||
url: '/some-endpoint',
|
||||
method: 'POST',
|
||||
baseURL: 'http://localhost/',
|
||||
headers: new AxiosHeaders(),
|
||||
data: JSON.stringify(arrayPayload),
|
||||
},
|
||||
},
|
||||
config: {
|
||||
url: '/some-endpoint',
|
||||
method: 'POST',
|
||||
baseURL: 'http://localhost/',
|
||||
headers: new AxiosHeaders(),
|
||||
data: JSON.stringify(arrayPayload),
|
||||
},
|
||||
} as unknown) as AxiosResponse;
|
||||
|
||||
try {
|
||||
await interceptorRejected(error);
|
||||
} catch {
|
||||
// Expected to reject after retry
|
||||
}
|
||||
|
||||
const mockAxiosFn = (axios as unknown) as jest.Mock;
|
||||
expect(mockAxiosFn.mock.calls.length).toBe(1);
|
||||
const retryCallConfig = mockAxiosFn.mock.calls[0][0];
|
||||
expect(Array.isArray(JSON.parse(retryCallConfig.data))).toBe(true);
|
||||
expect(JSON.parse(retryCallConfig.data)).toEqual(arrayPayload);
|
||||
});
|
||||
|
||||
it('should preserve object payload structure when retrying a 401 request', async () => {
|
||||
const objectPayload = { key: 'value', nested: { data: 123 } };
|
||||
|
||||
const error = ({
|
||||
response: {
|
||||
status: 401,
|
||||
config: {
|
||||
url: '/some-endpoint',
|
||||
method: 'POST',
|
||||
baseURL: 'http://localhost/',
|
||||
headers: new AxiosHeaders(),
|
||||
data: JSON.stringify(objectPayload),
|
||||
},
|
||||
},
|
||||
config: {
|
||||
url: '/some-endpoint',
|
||||
method: 'POST',
|
||||
baseURL: 'http://localhost/',
|
||||
headers: new AxiosHeaders(),
|
||||
data: JSON.stringify(objectPayload),
|
||||
},
|
||||
} as unknown) as AxiosResponse;
|
||||
|
||||
try {
|
||||
await interceptorRejected(error);
|
||||
} catch {
|
||||
// Expected to reject after retry
|
||||
}
|
||||
|
||||
const mockAxiosFn = (axios as unknown) as jest.Mock;
|
||||
expect(mockAxiosFn.mock.calls.length).toBe(1);
|
||||
const retryCallConfig = mockAxiosFn.mock.calls[0][0];
|
||||
expect(JSON.parse(retryCallConfig.data)).toEqual(objectPayload);
|
||||
});
|
||||
|
||||
it('should handle undefined data gracefully when retrying', async () => {
|
||||
const error = ({
|
||||
response: {
|
||||
status: 401,
|
||||
config: {
|
||||
url: '/some-endpoint',
|
||||
method: 'GET',
|
||||
baseURL: 'http://localhost/',
|
||||
headers: new AxiosHeaders(),
|
||||
data: undefined,
|
||||
},
|
||||
},
|
||||
config: {
|
||||
url: '/some-endpoint',
|
||||
method: 'GET',
|
||||
baseURL: 'http://localhost/',
|
||||
headers: new AxiosHeaders(),
|
||||
data: undefined,
|
||||
},
|
||||
} as unknown) as AxiosResponse;
|
||||
|
||||
try {
|
||||
await interceptorRejected(error);
|
||||
} catch {
|
||||
// Expected to reject after retry
|
||||
}
|
||||
|
||||
const mockAxiosFn = (axios as unknown) as jest.Mock;
|
||||
expect(mockAxiosFn.mock.calls.length).toBe(1);
|
||||
const retryCallConfig = mockAxiosFn.mock.calls[0][0];
|
||||
expect(retryCallConfig.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,14 @@
|
||||
function UnAuthorized(): JSX.Element {
|
||||
function UnAuthorized({
|
||||
width = 137,
|
||||
height = 137,
|
||||
}: {
|
||||
height?: number;
|
||||
width?: number;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="137"
|
||||
height="137"
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox="0 0 137 137"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
1
frontend/src/auto-import-registry.d.ts
vendored
1
frontend/src/auto-import-registry.d.ts
vendored
@@ -30,3 +30,4 @@ import '@signozhq/switch';
|
||||
import '@signozhq/table';
|
||||
import '@signozhq/toggle-group';
|
||||
import '@signozhq/tooltip';
|
||||
import '@signozhq/ui';
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { createShortcutActions } from '../../constants/shortcutActions';
|
||||
import { useCmdK } from '../../providers/cmdKProvider';
|
||||
import { ROLES } from '../../types/roles';
|
||||
import { ShiftOverlay } from './ShiftOverlay';
|
||||
import { useShiftHoldOverlay } from './useShiftHoldOverlay';
|
||||
|
||||
type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
|
||||
export function ShiftHoldOverlayController({
|
||||
userRole,
|
||||
}: {
|
||||
userRole: UserRole;
|
||||
userRole: ROLES;
|
||||
}): JSX.Element | null {
|
||||
const { open: isCmdKOpen } = useCmdK();
|
||||
const noop = (): void => undefined;
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useMemo } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
import { formatShortcut } from './formatShortcut';
|
||||
|
||||
import './shiftOverlay.scss';
|
||||
|
||||
export type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
|
||||
export type CmdAction = {
|
||||
id: string;
|
||||
name: string;
|
||||
shortcut?: string[];
|
||||
keywords?: string;
|
||||
section?: string;
|
||||
roles?: UserRole[];
|
||||
roles?: ROLES[];
|
||||
perform: () => void;
|
||||
};
|
||||
|
||||
@@ -33,7 +33,7 @@ function Shortcut({ label, keyHint }: ShortcutProps): JSX.Element {
|
||||
interface ShiftOverlayProps {
|
||||
visible: boolean;
|
||||
actions: CmdAction[];
|
||||
userRole: UserRole;
|
||||
userRole: ROLES;
|
||||
}
|
||||
|
||||
export function ShiftOverlay({
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useThemeMode } from 'hooks/useDarkMode';
|
||||
import history from 'lib/history';
|
||||
import { ROLES as UserRole } from 'types/roles';
|
||||
|
||||
import { createShortcutActions } from '../../constants/shortcutActions';
|
||||
import { useCmdK } from '../../providers/cmdKProvider';
|
||||
@@ -28,7 +29,6 @@ type CmdAction = {
|
||||
perform: () => void;
|
||||
};
|
||||
|
||||
type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
|
||||
export function CmdKPalette({
|
||||
userRole,
|
||||
}: {
|
||||
|
||||
@@ -18,8 +18,7 @@ import {
|
||||
TowerControl,
|
||||
Workflow,
|
||||
} from 'lucide-react';
|
||||
|
||||
export type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
|
||||
import { ROLES } from 'types/roles';
|
||||
|
||||
export type CmdAction = {
|
||||
id: string;
|
||||
@@ -28,7 +27,7 @@ export type CmdAction = {
|
||||
keywords?: string;
|
||||
section?: string;
|
||||
icon?: React.ReactNode;
|
||||
roles?: UserRole[];
|
||||
roles?: ROLES[];
|
||||
perform: () => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,16 +3,14 @@ import { UseQueryResult } from 'react-query';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button, Card, Skeleton, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { useGetGraphCustomSeries } from 'components/CeleryTask/useGetGraphCustomSeries';
|
||||
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
|
||||
import Uplot from 'components/Uplot';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
getCustomFiltersForBarChart,
|
||||
getFormattedEndPointStatusCodeChartData,
|
||||
getStatusCodeBarChartWidgetData,
|
||||
statusCodeWidgetInfo,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
import { handleGraphClick } from 'container/GridCardLayout/GridCard/utils';
|
||||
import { useGraphClickToShowButton } from 'container/GridCardLayout/useGraphClickToShowButton';
|
||||
import useNavigateToExplorerPages from 'container/GridCardLayout/useNavigateToExplorerPages';
|
||||
@@ -20,15 +18,16 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { getStartAndEndTimesInMilliseconds } from 'pages/MessagingQueues/MessagingQueuesUtils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { Options } from 'uplot';
|
||||
|
||||
import ErrorState from './ErrorState';
|
||||
import { prepareStatusCodeBarChartsConfig } from './utils';
|
||||
|
||||
function StatusCodeBarCharts({
|
||||
endPointStatusCodeBarChartsDataQuery,
|
||||
@@ -67,13 +66,6 @@ function StatusCodeBarCharts({
|
||||
} = endPointStatusCodeLatencyBarChartsDataQuery;
|
||||
|
||||
const { startTime: minTime, endTime: maxTime } = timeRange;
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = useResizeObserver(graphRef);
|
||||
@@ -119,6 +111,7 @@ function StatusCodeBarCharts({
|
||||
|
||||
const navigateToExplorer = useNavigateToExplorer();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
const navigateToExplorerPages = useNavigateToExplorerPages();
|
||||
const { notifications } = useNotifications();
|
||||
@@ -134,12 +127,6 @@ function StatusCodeBarCharts({
|
||||
[],
|
||||
);
|
||||
|
||||
const { getCustomSeries } = useGetGraphCustomSeries({
|
||||
isDarkMode,
|
||||
drawStyle: 'bars',
|
||||
colorMapping,
|
||||
});
|
||||
|
||||
const widget = useMemo<Widgets>(
|
||||
() =>
|
||||
getStatusCodeBarChartWidgetData(domainName, {
|
||||
@@ -193,49 +180,36 @@ function StatusCodeBarCharts({
|
||||
],
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
getUPlotChartOptions({
|
||||
apiResponse:
|
||||
currentWidgetInfoIndex === 0
|
||||
? formattedEndPointStatusCodeBarChartsDataPayload
|
||||
: formattedEndPointStatusCodeLatencyBarChartsDataPayload,
|
||||
isDarkMode,
|
||||
dimensions,
|
||||
yAxisUnit: statusCodeWidgetInfo[currentWidgetInfoIndex].yAxisUnit,
|
||||
softMax: null,
|
||||
softMin: null,
|
||||
minTimeScale: minTime,
|
||||
maxTimeScale: maxTime,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
onClickHandler: graphClickHandler,
|
||||
customSeries: getCustomSeries,
|
||||
onDragSelect,
|
||||
colorMapping,
|
||||
query: currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
}),
|
||||
[
|
||||
minTime,
|
||||
maxTime,
|
||||
currentWidgetInfoIndex,
|
||||
dimensions,
|
||||
formattedEndPointStatusCodeBarChartsDataPayload,
|
||||
formattedEndPointStatusCodeLatencyBarChartsDataPayload,
|
||||
const config = useMemo(() => {
|
||||
const apiResponse =
|
||||
currentWidgetInfoIndex === 0
|
||||
? formattedEndPointStatusCodeBarChartsDataPayload
|
||||
: formattedEndPointStatusCodeLatencyBarChartsDataPayload;
|
||||
return prepareStatusCodeBarChartsConfig({
|
||||
timezone,
|
||||
isDarkMode,
|
||||
graphClickHandler,
|
||||
getCustomSeries,
|
||||
query: currentQuery,
|
||||
onDragSelect,
|
||||
onClick: graphClickHandler,
|
||||
apiResponse,
|
||||
minTimeScale: minTime,
|
||||
maxTimeScale: maxTime,
|
||||
yAxisUnit: statusCodeWidgetInfo[currentWidgetInfoIndex].yAxisUnit,
|
||||
colorMapping,
|
||||
currentQuery,
|
||||
],
|
||||
);
|
||||
});
|
||||
}, [
|
||||
currentQuery,
|
||||
isDarkMode,
|
||||
minTime,
|
||||
maxTime,
|
||||
graphClickHandler,
|
||||
onDragSelect,
|
||||
formattedEndPointStatusCodeBarChartsDataPayload,
|
||||
formattedEndPointStatusCodeLatencyBarChartsDataPayload,
|
||||
timezone,
|
||||
currentWidgetInfoIndex,
|
||||
colorMapping,
|
||||
]);
|
||||
|
||||
const renderCardContent = useCallback(
|
||||
(query: UseQueryResult<SuccessResponse<any>, unknown>): JSX.Element => {
|
||||
@@ -253,11 +227,20 @@ function StatusCodeBarCharts({
|
||||
!query.isLoading && !query?.data?.payload?.data?.result?.length,
|
||||
})}
|
||||
>
|
||||
<Uplot options={options as Options} data={chartData} />
|
||||
<BarChart
|
||||
config={config}
|
||||
data={chartData}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
timezone={timezone}
|
||||
legendConfig={{
|
||||
position: LegendPosition.BOTTOM,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[options, chartData],
|
||||
[config, chartData, dimensions, timezone],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ExecStats } from 'api/v5/v5';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { get } from 'lodash-es';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
export const prepareStatusCodeBarChartsConfig = ({
|
||||
timezone,
|
||||
isDarkMode,
|
||||
query,
|
||||
onDragSelect,
|
||||
onClick,
|
||||
apiResponse,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
yAxisUnit,
|
||||
colorMapping,
|
||||
}: {
|
||||
timezone: Timezone;
|
||||
isDarkMode: boolean;
|
||||
query: Query;
|
||||
onDragSelect: (startTime: number, endTime: number) => void;
|
||||
onClick?: OnClickPluginOpts['onClick'];
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
apiResponse: MetricRangePayloadProps;
|
||||
yAxisUnit?: string;
|
||||
colorMapping?: Record<string, string>;
|
||||
}): UPlotConfigBuilder => {
|
||||
const stepIntervals: ExecStats['stepIntervals'] = get(
|
||||
apiResponse,
|
||||
'data.newResult.meta.stepIntervals',
|
||||
{},
|
||||
);
|
||||
const minStepInterval = Math.min(...Object.values(stepIntervals));
|
||||
|
||||
const config = buildBaseConfig({
|
||||
id: v4(),
|
||||
yAxisUnit: yAxisUnit,
|
||||
apiResponse,
|
||||
isDarkMode,
|
||||
onDragSelect,
|
||||
timezone,
|
||||
onClick,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
stepInterval: minStepInterval,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
});
|
||||
|
||||
const seriesList: QueryData[] = apiResponse?.data?.result || [];
|
||||
seriesList.forEach((series) => {
|
||||
const baseLabelName = getLabelName(
|
||||
series.metric,
|
||||
series.queryName || '', // query
|
||||
series.legend || '',
|
||||
);
|
||||
|
||||
const label = query ? getLegend(series, query, baseLabelName) : baseLabelName;
|
||||
|
||||
const currentStepInterval = get(stepIntervals, series.queryName, undefined);
|
||||
|
||||
config.addSeries({
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
label: label,
|
||||
colorMapping: colorMapping ?? {},
|
||||
isDarkMode,
|
||||
stepInterval: currentStepInterval,
|
||||
});
|
||||
});
|
||||
|
||||
return config;
|
||||
};
|
||||
@@ -21,10 +21,15 @@ interface MockQueryResult {
|
||||
}
|
||||
|
||||
// Mocks
|
||||
jest.mock('components/Uplot', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(() => <div data-testid="uplot-mock" />),
|
||||
}));
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/charts/BarChart/BarChart',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: jest
|
||||
.fn()
|
||||
.mockImplementation(() => <div data-testid="bar-chart-mock" />),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('components/CeleryTask/useGetGraphCustomSeries', () => ({
|
||||
useGetGraphCustomSeries: (): { getCustomSeries: jest.Mock } => ({
|
||||
@@ -70,6 +75,24 @@ jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): { notifications: [] } => ({ notifications: [] }),
|
||||
}));
|
||||
|
||||
jest.mock('providers/Timezone', () => ({
|
||||
useTimezone: (): {
|
||||
timezone: {
|
||||
name: string;
|
||||
value: string;
|
||||
offset: string;
|
||||
searchIndex: string;
|
||||
};
|
||||
} => ({
|
||||
timezone: {
|
||||
name: 'UTC',
|
||||
value: 'UTC',
|
||||
offset: '+00:00',
|
||||
searchIndex: 'UTC',
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({
|
||||
getUPlotChartOptions: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
@@ -319,7 +342,7 @@ describe('StatusCodeBarCharts', () => {
|
||||
mockData.payload,
|
||||
'sum',
|
||||
);
|
||||
expect(screen.getByTestId('uplot-mock')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bar-chart-mock')).toBeInTheDocument();
|
||||
expect(screen.getByText('Number of calls')).toBeInTheDocument();
|
||||
expect(screen.getByText('Latency')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -337,31 +337,6 @@
|
||||
|
||||
.login-submit-btn {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 10px 16px;
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--bg-neutral-dark-50);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--primary);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--primary);
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { Form, Input, Select, Typography } from 'antd';
|
||||
import getVersion from 'api/v1/version/get';
|
||||
import get from 'api/v2/sessions/context/get';
|
||||
@@ -392,9 +392,9 @@ function Login(): JSX.Element {
|
||||
disabled={!isNextButtonEnabled}
|
||||
variant="solid"
|
||||
onClick={onNextHandler}
|
||||
data-testid="initiate_login"
|
||||
testId="initiate_login"
|
||||
className="login-submit-btn"
|
||||
suffixIcon={<ArrowRight size={12} />}
|
||||
suffix={<ArrowRight />}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
@@ -406,10 +406,10 @@ function Login(): JSX.Element {
|
||||
variant="solid"
|
||||
type="submit"
|
||||
color="primary"
|
||||
data-testid="callback_authn_submit"
|
||||
testId="callback_authn_submit"
|
||||
data-attr="signup"
|
||||
className="login-submit-btn"
|
||||
suffixIcon={<ArrowRight size={12} />}
|
||||
suffix={<ArrowRight />}
|
||||
>
|
||||
Sign in with SSO
|
||||
</Button>
|
||||
@@ -420,11 +420,11 @@ function Login(): JSX.Element {
|
||||
disabled={!isSubmitButtonEnabled}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
data-testid="password_authn_submit"
|
||||
testId="password_authn_submit"
|
||||
type="submit"
|
||||
data-attr="signup"
|
||||
className="login-submit-btn"
|
||||
suffixIcon={<ArrowRight size={12} />}
|
||||
suffix={<ArrowRight />}
|
||||
>
|
||||
Sign in with Password
|
||||
</Button>
|
||||
|
||||
2
frontend/src/hooks/useAuthZ/constants.ts
Normal file
2
frontend/src/hooks/useAuthZ/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const SINGLE_FLIGHT_WAIT_TIME_MS = 50;
|
||||
export const AUTHZ_CACHE_TIME = 20_000;
|
||||
18
frontend/src/hooks/useAuthZ/legacy.ts
Normal file
18
frontend/src/hooks/useAuthZ/legacy.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { buildPermission } from './utils';
|
||||
|
||||
export const IsAdminPermission = buildPermission(
|
||||
'assignee',
|
||||
'role:signoz-admin',
|
||||
);
|
||||
export const IsEditorPermission = buildPermission(
|
||||
'assignee',
|
||||
'role:signoz-editor',
|
||||
);
|
||||
export const IsViewerPermission = buildPermission(
|
||||
'assignee',
|
||||
'role:signoz-viewer',
|
||||
);
|
||||
export const IsAnonymousPermission = buildPermission(
|
||||
'assignee',
|
||||
'role:signoz-anonymous',
|
||||
);
|
||||
@@ -14,7 +14,7 @@ type ResourceTypeMap = {
|
||||
|
||||
type RelationName = keyof RelationsByType;
|
||||
|
||||
type ResourcesForRelation<R extends RelationName> = Extract<
|
||||
export type ResourcesForRelation<R extends RelationName> = Extract<
|
||||
Resource,
|
||||
{ type: RelationsByType[R][number] }
|
||||
>['name'];
|
||||
@@ -50,8 +50,26 @@ export type AuthZCheckResponse = Record<
|
||||
}
|
||||
>;
|
||||
|
||||
export type UseAuthZOptions = {
|
||||
/**
|
||||
* If false, the query/permissions will not be fetched.
|
||||
* Useful when you want to disable the query/permissions for a specific use case, like logout.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type UseAuthZResult = {
|
||||
/**
|
||||
* If query is cached, and refetch happens in background, this is false.
|
||||
*/
|
||||
isLoading: boolean;
|
||||
/**
|
||||
* If query is fetching, even if happens in background, this is true.
|
||||
*/
|
||||
isFetching: boolean;
|
||||
error: Error | null;
|
||||
permissions: AuthZCheckResponse | null;
|
||||
refetchPermissions: () => void;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import { authzCheck } from 'api/generated/services/authz';
|
||||
import type {
|
||||
@@ -6,7 +6,13 @@ import type {
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { AuthZCheckResponse, BrandedPermission, UseAuthZResult } from './types';
|
||||
import { AUTHZ_CACHE_TIME, SINGLE_FLIGHT_WAIT_TIME_MS } from './constants';
|
||||
import {
|
||||
AuthZCheckResponse,
|
||||
BrandedPermission,
|
||||
UseAuthZOptions,
|
||||
UseAuthZResult,
|
||||
} from './types';
|
||||
import {
|
||||
gettableTransactionToPermission,
|
||||
permissionToTransactionDto,
|
||||
@@ -14,8 +20,6 @@ import {
|
||||
|
||||
let ctx: Promise<AuthZCheckResponse> | null;
|
||||
let pendingPermissions: BrandedPermission[] = [];
|
||||
const SINGLE_FLIGHT_WAIT_TIME_MS = 50;
|
||||
const AUTHZ_CACHE_TIME = 20_000;
|
||||
|
||||
function dispatchPermission(
|
||||
permission: BrandedPermission,
|
||||
@@ -70,7 +74,12 @@ async function fetchManyPermissions(
|
||||
}, {} as AuthZCheckResponse);
|
||||
}
|
||||
|
||||
export function useAuthZ(permissions: BrandedPermission[]): UseAuthZResult {
|
||||
export function useAuthZ(
|
||||
permissions: BrandedPermission[],
|
||||
options?: UseAuthZOptions,
|
||||
): UseAuthZResult {
|
||||
const { enabled } = options ?? { enabled: true };
|
||||
|
||||
const queryResults = useQueries(
|
||||
permissions.map((permission) => {
|
||||
return {
|
||||
@@ -80,6 +89,7 @@ export function useAuthZ(permissions: BrandedPermission[]): UseAuthZResult {
|
||||
refetchIntervalInBackground: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: true,
|
||||
enabled,
|
||||
queryFn: async (): Promise<AuthZCheckResponse> => {
|
||||
const response = await dispatchPermission(permission);
|
||||
|
||||
@@ -96,6 +106,10 @@ export function useAuthZ(permissions: BrandedPermission[]): UseAuthZResult {
|
||||
const isLoading = useMemo(() => queryResults.some((q) => q.isLoading), [
|
||||
queryResults,
|
||||
]);
|
||||
const isFetching = useMemo(() => queryResults.some((q) => q.isFetching), [
|
||||
queryResults,
|
||||
]);
|
||||
|
||||
const error = useMemo(
|
||||
() =>
|
||||
!isLoading
|
||||
@@ -121,9 +135,17 @@ export function useAuthZ(permissions: BrandedPermission[]): UseAuthZResult {
|
||||
}, {} as AuthZCheckResponse);
|
||||
}, [isLoading, error, queryResults]);
|
||||
|
||||
const refetchPermissions = useCallback(() => {
|
||||
for (const query of queryResults) {
|
||||
query.refetch();
|
||||
}
|
||||
}, [queryResults]);
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
permissions: data ?? null,
|
||||
refetchPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import permissionsType from './permissions.type';
|
||||
import {
|
||||
AuthZObject,
|
||||
AuthZRelation,
|
||||
AuthZResource,
|
||||
BrandedPermission,
|
||||
ResourceName,
|
||||
ResourcesForRelation,
|
||||
ResourceType,
|
||||
} from './types';
|
||||
|
||||
@@ -19,11 +19,10 @@ export function buildPermission<R extends AuthZRelation>(
|
||||
return `${relation}${PermissionSeparator}${object}` as BrandedPermission;
|
||||
}
|
||||
|
||||
export function buildObjectString(
|
||||
resource: AuthZResource,
|
||||
objectId: string,
|
||||
): `${AuthZResource}${typeof ObjectSeparator}${string}` {
|
||||
return `${resource}${ObjectSeparator}${objectId}` as const;
|
||||
export function buildObjectString<
|
||||
R extends 'delete' | 'read' | 'update' | 'assignee'
|
||||
>(resource: ResourcesForRelation<R>, objectId: string): AuthZObject<R> {
|
||||
return `${resource}${ObjectSeparator}${objectId}` as AuthZObject<R>;
|
||||
}
|
||||
|
||||
export function parsePermission(
|
||||
|
||||
5
frontend/src/pages/UnAuthorized/index.styles.scss
Normal file
5
frontend/src/pages/UnAuthorized/index.styles.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.unauthorized-page {
|
||||
&__description {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,51 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Space, Typography } from 'antd';
|
||||
import UnAuthorized from 'assets/UnAuthorized';
|
||||
import { Button, Container } from 'components/NotFound/styles';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { Container } from 'components/NotFound/styles';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { handleContactSupport } from 'pages/Integrations/utils';
|
||||
|
||||
import { useAppContext } from '../../providers/App/App';
|
||||
import { USER_ROLES } from '../../types/roles';
|
||||
|
||||
import './index.styles.scss';
|
||||
|
||||
function UnAuthorizePage(): JSX.Element {
|
||||
return (
|
||||
<Container>
|
||||
<Space align="center" direction="vertical">
|
||||
<UnAuthorized />
|
||||
<Typography.Title level={3}>
|
||||
Oops.. you don't have permission to view this page
|
||||
</Typography.Title>
|
||||
const [debugCurrentRole] = useQueryState('currentRole');
|
||||
const { user } = useAppContext();
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
|
||||
<Button to={ROUTES.HOME} tabIndex={0} className="periscope-btn primary">
|
||||
Return To Home
|
||||
</Button>
|
||||
const userIsAnonymous =
|
||||
debugCurrentRole === USER_ROLES.ANONYMOUS ||
|
||||
user.role === USER_ROLES.ANONYMOUS;
|
||||
const mistakeMessage = userIsAnonymous
|
||||
? 'If you believe this is a mistake, please contact your administrator or'
|
||||
: 'Please contact your administrator.';
|
||||
|
||||
const handleContactSupportClick = useCallback((): void => {
|
||||
handleContactSupport(isCloudUserVal);
|
||||
}, [isCloudUserVal]);
|
||||
|
||||
return (
|
||||
<Container className="unauthorized-page">
|
||||
<Space align="center" direction="vertical">
|
||||
<UnAuthorized width={64} height={64} />
|
||||
<Typography.Title level={3}>Access Restricted</Typography.Title>
|
||||
|
||||
<p className="unauthorized-page__description">
|
||||
It looks like you don‘t have permission to view this page. <br />
|
||||
{mistakeMessage}
|
||||
{userIsAnonymous ? (
|
||||
<Typography.Link
|
||||
className="contact-support-link"
|
||||
onClick={handleContactSupportClick}
|
||||
>
|
||||
{' '}
|
||||
reach out to us.
|
||||
</Typography.Link>
|
||||
) : null}
|
||||
</p>
|
||||
</Space>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,12 @@ import getUserVersion from 'api/v1/version/get';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import dayjs from 'dayjs';
|
||||
import useActiveLicenseV3 from 'hooks/useActiveLicenseV3/useActiveLicenseV3';
|
||||
import {
|
||||
IsAdminPermission,
|
||||
IsEditorPermission,
|
||||
IsViewerPermission,
|
||||
} from 'hooks/useAuthZ/legacy';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { useGetFeatureFlag } from 'hooks/useGetFeatureFlag';
|
||||
import { useGlobalEventListener } from 'hooks/useGlobalEventListener';
|
||||
import { ChangelogSchema } from 'types/api/changelog/getChangelogByVersion';
|
||||
@@ -34,7 +40,7 @@ import {
|
||||
UserPreference,
|
||||
} from 'types/api/preferences/preference';
|
||||
import { Organization } from 'types/api/user/getOrganization';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { ROLES, USER_ROLES } from 'types/roles';
|
||||
|
||||
import { IAppContext, IUser } from './types';
|
||||
import { getUserDefaults } from './utils';
|
||||
@@ -43,7 +49,7 @@ export const AppContext = createContext<IAppContext | undefined>(undefined);
|
||||
|
||||
export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
// on load of the provider set the user defaults with access token , refresh token from local storage
|
||||
const [user, setUser] = useState<IUser>(() => getUserDefaults());
|
||||
const [defaultUser, setDefaultUser] = useState<IUser>(() => getUserDefaults());
|
||||
const [activeLicense, setActiveLicense] = useState<LicenseResModel | null>(
|
||||
null,
|
||||
);
|
||||
@@ -70,18 +76,51 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
// if logged out and trying to hit any route none of these calls will trigger
|
||||
const {
|
||||
data: userData,
|
||||
isFetching: isFetchingUser,
|
||||
error: userFetchError,
|
||||
isFetching: isFetchingUserData,
|
||||
error: userFetchDataError,
|
||||
} = useQuery({
|
||||
queryFn: get,
|
||||
queryKey: ['/api/v1/user/me'],
|
||||
enabled: isLoggedIn,
|
||||
});
|
||||
|
||||
const {
|
||||
permissions: permissionsResult,
|
||||
isFetching: isFetchingPermissions,
|
||||
error: errorOnPermissions,
|
||||
refetchPermissions,
|
||||
} = useAuthZ([IsAdminPermission, IsEditorPermission, IsViewerPermission], {
|
||||
enabled: isLoggedIn,
|
||||
});
|
||||
|
||||
const isFetchingUser = isFetchingUserData || isFetchingPermissions;
|
||||
const userFetchError = userFetchDataError || errorOnPermissions;
|
||||
|
||||
const userRole = useMemo(() => {
|
||||
if (permissionsResult?.[IsAdminPermission]?.isGranted) {
|
||||
return USER_ROLES.ADMIN;
|
||||
}
|
||||
if (permissionsResult?.[IsEditorPermission]?.isGranted) {
|
||||
return USER_ROLES.EDITOR;
|
||||
}
|
||||
if (permissionsResult?.[IsViewerPermission]?.isGranted) {
|
||||
return USER_ROLES.VIEWER;
|
||||
}
|
||||
// if none of the permissions, so anonymous
|
||||
return USER_ROLES.ANONYMOUS;
|
||||
}, [permissionsResult]);
|
||||
|
||||
const user: IUser = useMemo(() => {
|
||||
return {
|
||||
...defaultUser,
|
||||
role: userRole as ROLES,
|
||||
};
|
||||
}, [defaultUser, userRole]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetchingUser && userData && userData.data) {
|
||||
setLocalStorageApi(LOCALSTORAGE.LOGGED_IN_USER_EMAIL, userData.data.email);
|
||||
setUser((prev) => ({
|
||||
setDefaultUser((prev) => ({
|
||||
...prev,
|
||||
...userData.data,
|
||||
}));
|
||||
@@ -203,7 +242,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
}, [userPreferencesData, isFetchingUserPreferences, isLoggedIn]);
|
||||
|
||||
function updateUser(user: IUser): void {
|
||||
setUser((prev) => ({
|
||||
setDefaultUser((prev) => ({
|
||||
...prev,
|
||||
...user,
|
||||
}));
|
||||
@@ -244,7 +283,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
...org.slice(orgIndex + 1, org.length),
|
||||
];
|
||||
setOrg(updatedOrg);
|
||||
setUser((prev) => {
|
||||
setDefaultUser((prev) => {
|
||||
if (prev.orgId === orgId) {
|
||||
return {
|
||||
...prev,
|
||||
@@ -272,7 +311,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
// global event listener for AFTER_LOGIN event to start the user fetch post all actions are complete
|
||||
useGlobalEventListener('AFTER_LOGIN', (event) => {
|
||||
if (event.detail) {
|
||||
setUser((prev) => ({
|
||||
setDefaultUser((prev) => ({
|
||||
...prev,
|
||||
accessJwt: event.detail.accessJWT,
|
||||
refreshJwt: event.detail.refreshJWT,
|
||||
@@ -280,12 +319,14 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
|
||||
}));
|
||||
setIsLoggedIn(true);
|
||||
}
|
||||
|
||||
refetchPermissions();
|
||||
});
|
||||
|
||||
// global event listener for LOGOUT event to clean the app context state
|
||||
useGlobalEventListener('LOGOUT', () => {
|
||||
setIsLoggedIn(false);
|
||||
setUser(getUserDefaults());
|
||||
setDefaultUser(getUserDefaults());
|
||||
setActiveLicense(null);
|
||||
setTrialInfo(null);
|
||||
setFeatureFlags(null);
|
||||
|
||||
273
frontend/src/providers/App/__tests__/App.test.tsx
Normal file
273
frontend/src/providers/App/__tests__/App.test.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import {
|
||||
AuthtypesGettableTransactionDTO,
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { SINGLE_FLIGHT_WAIT_TIME_MS } from 'hooks/useAuthZ/constants';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { AppProvider, useAppContext } from '../App';
|
||||
|
||||
const AUTHZ_CHECK_URL = 'http://localhost/api/v1/authz/check';
|
||||
|
||||
jest.mock('constants/env', () => ({
|
||||
ENVIRONMENT: { baseURL: 'http://localhost', wsURL: '' },
|
||||
}));
|
||||
|
||||
/**
|
||||
* Since we are mocking the check permissions, this is needed
|
||||
*/
|
||||
const waitForSinglePreflightToFinish = async (): Promise<void> =>
|
||||
await new Promise((r) => setTimeout(r, SINGLE_FLIGHT_WAIT_TIME_MS));
|
||||
|
||||
function authzMockResponse(
|
||||
payload: AuthtypesTransactionDTO[],
|
||||
authorizedByIndex: boolean[],
|
||||
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
|
||||
return {
|
||||
data: payload.map((txn, i) => ({
|
||||
relation: txn.relation,
|
||||
object: txn.object,
|
||||
authorized: authorizedByIndex[i] ?? false,
|
||||
})),
|
||||
status: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function createWrapper(): ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactElement;
|
||||
}) => ReactElement {
|
||||
return function Wrapper({
|
||||
children,
|
||||
}: {
|
||||
children: ReactElement;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppProvider>{children}</AppProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
describe('AppProvider user.role from permissions', () => {
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
setLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN, 'true');
|
||||
});
|
||||
|
||||
it('sets user.role to ADMIN and hasEditPermission to true when admin permission is granted', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [true, false, false])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
await waitForSinglePreflightToFinish();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.user.role).toBe(USER_ROLES.ADMIN);
|
||||
expect(result.current.hasEditPermission).toBe(true);
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('sets user.role to EDITOR and hasEditPermission to true when only editor permission is granted', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [false, true, false])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
await waitForSinglePreflightToFinish();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.user.role).toBe(USER_ROLES.EDITOR);
|
||||
expect(result.current.hasEditPermission).toBe(true);
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('sets user.role to VIEWER and hasEditPermission to false when only viewer permission is granted', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [false, false, true])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
await waitForSinglePreflightToFinish();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.user.role).toBe(USER_ROLES.VIEWER);
|
||||
expect(result.current.hasEditPermission).toBe(false);
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('sets user.role to ANONYMOUS and hasEditPermission to false when no role permission is granted', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [false, false, false])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
await waitForSinglePreflightToFinish();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.user.role).toBe(USER_ROLES.ANONYMOUS);
|
||||
expect(result.current.hasEditPermission).toBe(false);
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* This is expected to not happen, but we'll test it just in case.
|
||||
*/
|
||||
describe('when multiple role permissions are granted', () => {
|
||||
it('prefers ADMIN over EDITOR and VIEWER when multiple role permissions are granted', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [true, true, true])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.user.role).toBe(USER_ROLES.ADMIN);
|
||||
expect(result.current.hasEditPermission).toBe(true);
|
||||
},
|
||||
{ timeout: 300 },
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers EDITOR over VIEWER when editor and viewer permissions are granted', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [false, true, true])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
await waitForSinglePreflightToFinish();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.user.role).toBe(USER_ROLES.EDITOR);
|
||||
expect(result.current.hasEditPermission).toBe(true);
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppProvider when authz/check fails', () => {
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
setLocalStorageApi(LOCALSTORAGE.IS_LOGGED_IN, 'true');
|
||||
});
|
||||
|
||||
it('sets userFetchError when authz/check returns 500 (same as user fetch error)', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ error: 'Internal Server Error' })),
|
||||
),
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
await waitForSinglePreflightToFinish();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.userFetchError).toBeTruthy();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('sets userFetchError when authz/check fails with network error (same as user fetch error)', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_, res) => res.networkError('Network error')),
|
||||
);
|
||||
|
||||
const wrapper = createWrapper();
|
||||
const { result } = renderHook(() => useAppContext(), { wrapper });
|
||||
|
||||
await waitForSinglePreflightToFinish();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(result.current.userFetchError).toBeTruthy();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,9 @@ export interface UserResponse {
|
||||
displayName: string;
|
||||
orgId: string;
|
||||
organization: string;
|
||||
/**
|
||||
* @deprecated This will be removed in the future releases in favor of new AuthZ framework
|
||||
*/
|
||||
role: ROLES;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,16 @@ export type ADMIN = 'ADMIN';
|
||||
export type VIEWER = 'VIEWER';
|
||||
export type EDITOR = 'EDITOR';
|
||||
export type AUTHOR = 'AUTHOR';
|
||||
export type ANONYMOUS = 'ANONYMOUS';
|
||||
|
||||
export type ROLES = ADMIN | VIEWER | EDITOR | AUTHOR;
|
||||
export type ROLES = ADMIN | VIEWER | EDITOR | AUTHOR | ANONYMOUS;
|
||||
|
||||
export const USER_ROLES = {
|
||||
ADMIN: 'ADMIN',
|
||||
VIEWER: 'VIEWER',
|
||||
EDITOR: 'EDITOR',
|
||||
AUTHOR: 'AUTHOR',
|
||||
ANONYMOUS: 'ANONYMOUS',
|
||||
};
|
||||
|
||||
export enum RoleType {
|
||||
|
||||
@@ -69,7 +69,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
ALERT_OVERVIEW: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LOGIN: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
FORGOT_PASSWORD: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR'],
|
||||
NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR', 'ANONYMOUS'],
|
||||
PASSWORD_RESET: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SERVICE_METRICS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
@@ -77,7 +77,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
TRACES_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
TRACE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
TRACE_DETAIL: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
UN_AUTHORIZED: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
UN_AUTHORIZED: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
USAGE_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
VERSION: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LOGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
@@ -101,7 +101,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
ROLE_DETAILS: ['ADMIN'],
|
||||
MEMBERS_SETTINGS: ['ADMIN'],
|
||||
BILLING: ['ADMIN'],
|
||||
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
LOGS_SAVE_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
TRACES_SAVE_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
|
||||
@@ -4506,6 +4506,19 @@
|
||||
"@radix-ui/react-use-callback-ref" "1.1.1"
|
||||
"@radix-ui/react-use-escape-keydown" "1.1.1"
|
||||
|
||||
"@radix-ui/react-dropdown-menu@^2.1.16":
|
||||
version "2.1.16"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz#5ee045c62bad8122347981c479d92b1ff24c7254"
|
||||
integrity sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.3"
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-id" "1.1.1"
|
||||
"@radix-ui/react-menu" "2.1.16"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-use-controllable-state" "1.2.2"
|
||||
|
||||
"@radix-ui/react-focus-guards@1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.0.tgz#339c1c69c41628c1a5e655f15f7020bf11aa01fa"
|
||||
@@ -4565,6 +4578,30 @@
|
||||
dependencies:
|
||||
"@radix-ui/react-use-layout-effect" "1.1.1"
|
||||
|
||||
"@radix-ui/react-menu@2.1.16":
|
||||
version "2.1.16"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-menu/-/react-menu-2.1.16.tgz#528a5a973c3a7413d3d49eb9ccd229aa52402911"
|
||||
integrity sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.3"
|
||||
"@radix-ui/react-collection" "1.1.7"
|
||||
"@radix-ui/react-compose-refs" "1.1.2"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-direction" "1.1.1"
|
||||
"@radix-ui/react-dismissable-layer" "1.1.11"
|
||||
"@radix-ui/react-focus-guards" "1.1.3"
|
||||
"@radix-ui/react-focus-scope" "1.1.7"
|
||||
"@radix-ui/react-id" "1.1.1"
|
||||
"@radix-ui/react-popper" "1.2.8"
|
||||
"@radix-ui/react-portal" "1.1.9"
|
||||
"@radix-ui/react-presence" "1.1.5"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-roving-focus" "1.1.11"
|
||||
"@radix-ui/react-slot" "1.2.3"
|
||||
"@radix-ui/react-use-callback-ref" "1.1.1"
|
||||
aria-hidden "^1.2.4"
|
||||
react-remove-scroll "^2.6.3"
|
||||
|
||||
"@radix-ui/react-popover@^1.1.15", "@radix-ui/react-popover@^1.1.2":
|
||||
version "1.1.15"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.1.15.tgz#9c852f93990a687ebdc949b2c3de1f37cdc4c5d5"
|
||||
@@ -4804,6 +4841,20 @@
|
||||
"@radix-ui/react-roving-focus" "1.0.4"
|
||||
"@radix-ui/react-use-controllable-state" "1.0.1"
|
||||
|
||||
"@radix-ui/react-tabs@^1.1.3":
|
||||
version "1.1.13"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz#3537ce379d7e7ff4eeb6b67a0973e139c2ac1f15"
|
||||
integrity sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.3"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-direction" "1.1.1"
|
||||
"@radix-ui/react-id" "1.1.1"
|
||||
"@radix-ui/react-presence" "1.1.5"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-roving-focus" "1.1.11"
|
||||
"@radix-ui/react-use-controllable-state" "1.2.2"
|
||||
|
||||
"@radix-ui/react-toggle-group@^1.1.7":
|
||||
version "1.1.11"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz#e513d6ffdb07509b400ab5b26f2523747c0d51c1"
|
||||
@@ -5675,6 +5726,42 @@
|
||||
tailwind-merge "^2.5.2"
|
||||
tailwindcss-animate "^1.0.7"
|
||||
|
||||
"@signozhq/ui@0.0.5":
|
||||
version "0.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/ui/-/ui-0.0.5.tgz#8badef53416b7ace0fe61ff01ff3da679a0e4ba5"
|
||||
integrity sha512-4vPvUh3rwpst068qXUZ26JfCQGv1vo1xMSwtKw6wTjiiq1Bf3geP84HWVXycNMIrIeVnUgDGnqe0D4doh+mL8A==
|
||||
dependencies:
|
||||
"@radix-ui/react-checkbox" "^1.2.3"
|
||||
"@radix-ui/react-dialog" "^1.1.11"
|
||||
"@radix-ui/react-dropdown-menu" "^2.1.16"
|
||||
"@radix-ui/react-icons" "^1.3.0"
|
||||
"@radix-ui/react-popover" "^1.1.15"
|
||||
"@radix-ui/react-radio-group" "^1.3.4"
|
||||
"@radix-ui/react-slot" "^1.2.3"
|
||||
"@radix-ui/react-switch" "^1.1.4"
|
||||
"@radix-ui/react-tabs" "^1.1.3"
|
||||
"@radix-ui/react-toggle" "^1.1.6"
|
||||
"@radix-ui/react-toggle-group" "^1.1.7"
|
||||
"@radix-ui/react-tooltip" "^1.2.6"
|
||||
"@tanstack/react-table" "^8.21.3"
|
||||
"@tanstack/react-virtual" "^3.13.9"
|
||||
"@types/lodash-es" "^4.17.12"
|
||||
class-variance-authority "^0.7.0"
|
||||
clsx "^2.1.1"
|
||||
cmdk "^1.1.1"
|
||||
date-fns "^4.1.0"
|
||||
dayjs "^1.11.10"
|
||||
lodash-es "^4.17.21"
|
||||
lucide-react "^0.445.0"
|
||||
lucide-solid "^0.510.0"
|
||||
motion "^11.11.17"
|
||||
next-themes "^0.4.6"
|
||||
nuqs "^2.8.9"
|
||||
react-day-picker "^9.8.1"
|
||||
react-resizable-panels "^4.7.1"
|
||||
sonner "^2.0.7"
|
||||
tailwind-merge "^3.5.0"
|
||||
|
||||
"@sinclair/typebox@^0.25.16":
|
||||
version "0.25.24"
|
||||
resolved "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.25.24.tgz"
|
||||
@@ -9573,6 +9660,11 @@ dayjs@^1.10.7, dayjs@^1.11.1:
|
||||
resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.7.tgz"
|
||||
integrity sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==
|
||||
|
||||
dayjs@^1.11.10:
|
||||
version "1.11.20"
|
||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.20.tgz#88d919fd639dc991415da5f4cb6f1b6650811938"
|
||||
integrity sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==
|
||||
|
||||
debounce@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
|
||||
@@ -11092,6 +11184,15 @@ fraction.js@^4.3.7:
|
||||
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7"
|
||||
integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==
|
||||
|
||||
framer-motion@^11.18.2:
|
||||
version "11.18.2"
|
||||
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-11.18.2.tgz#0c6bd05677f4cfd3b3bdead4eb5ecdd5ed245718"
|
||||
integrity sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==
|
||||
dependencies:
|
||||
motion-dom "^11.18.1"
|
||||
motion-utils "^11.18.1"
|
||||
tslib "^2.4.0"
|
||||
|
||||
framer-motion@^12.4.13:
|
||||
version "12.4.13"
|
||||
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-12.4.13.tgz#1efd954f95e6a54685b660929c00f5a61e35256a"
|
||||
@@ -15002,6 +15103,13 @@ moment@^2.29.4:
|
||||
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108"
|
||||
integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==
|
||||
|
||||
motion-dom@^11.18.1:
|
||||
version "11.18.1"
|
||||
resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-11.18.1.tgz#e7fed7b7dc6ae1223ef1cce29ee54bec826dc3f2"
|
||||
integrity sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==
|
||||
dependencies:
|
||||
motion-utils "^11.18.1"
|
||||
|
||||
motion-dom@^12.4.11:
|
||||
version "12.4.11"
|
||||
resolved "https://registry.yarnpkg.com/motion-dom/-/motion-dom-12.4.11.tgz#0419c8686cda4d523f08249deeb8fa6683a9b9d3"
|
||||
@@ -15009,6 +15117,11 @@ motion-dom@^12.4.11:
|
||||
dependencies:
|
||||
motion-utils "^12.4.10"
|
||||
|
||||
motion-utils@^11.18.1:
|
||||
version "11.18.1"
|
||||
resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-11.18.1.tgz#671227669833e991c55813cf337899f41327db5b"
|
||||
integrity sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==
|
||||
|
||||
motion-utils@^12.4.10:
|
||||
version "12.4.10"
|
||||
resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.4.10.tgz#3d93acea5454419eaaad8d5e5425cb71cbfa1e7f"
|
||||
@@ -15022,6 +15135,14 @@ motion@12.4.13:
|
||||
framer-motion "^12.4.13"
|
||||
tslib "^2.4.0"
|
||||
|
||||
motion@^11.11.17:
|
||||
version "11.18.2"
|
||||
resolved "https://registry.yarnpkg.com/motion/-/motion-11.18.2.tgz#17fb372f3ed94fc9ee1384a25a9068e9da1951e7"
|
||||
integrity sha512-JLjvFDuFr42NFtcVoMAyC2sEjnpA8xpy6qWPyzQvCloznAyQ8FIXioxWfHiLtgYhoVpfUqSWpn1h9++skj9+Wg==
|
||||
dependencies:
|
||||
framer-motion "^11.18.2"
|
||||
tslib "^2.4.0"
|
||||
|
||||
mri@^1.1.0:
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b"
|
||||
@@ -15292,6 +15413,13 @@ nuqs@2.8.8:
|
||||
dependencies:
|
||||
"@standard-schema/spec" "1.0.0"
|
||||
|
||||
nuqs@^2.8.9:
|
||||
version "2.8.9"
|
||||
resolved "https://registry.yarnpkg.com/nuqs/-/nuqs-2.8.9.tgz#e2c27d87c0dd0e3b4412fe867bcd0947cc4c998f"
|
||||
integrity sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ==
|
||||
dependencies:
|
||||
"@standard-schema/spec" "1.0.0"
|
||||
|
||||
nwsapi@^2.2.2:
|
||||
version "2.2.23"
|
||||
resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.23.tgz#59712c3a88e6de2bb0b6ccc1070397267019cf6c"
|
||||
@@ -16957,6 +17085,11 @@ react-resizable-panels@^3.0.5:
|
||||
resolved "https://registry.yarnpkg.com/react-resizable-panels/-/react-resizable-panels-3.0.5.tgz#50a20645263eed02344de4a70d1319bbc0014bbd"
|
||||
integrity sha512-3z1yN25DMTXLg2wfyFrW32r5k4WEcUa3F7cJ2EgtNK07lnOs4mpM8yWLGunCpkhcQRwJX4fqoLcIh/pHPxzlmQ==
|
||||
|
||||
react-resizable-panels@^4.7.1:
|
||||
version "4.7.3"
|
||||
resolved "https://registry.yarnpkg.com/react-resizable-panels/-/react-resizable-panels-4.7.3.tgz#4040aa0f5c5c4cc4bb685cb69973601ccda3b014"
|
||||
integrity sha512-PYcYMLtvJD+Pr0TQNeMvddcnLOwUa/Yb4iNwU7ThNLlHaQYEEC9MIBWHaBGODzYuXIkPRZ/OWe5sbzG1Rzq5ew==
|
||||
|
||||
react-resizable@3.0.4:
|
||||
version "3.0.4"
|
||||
resolved "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.4.tgz"
|
||||
@@ -18797,6 +18930,11 @@ tailwind-merge@^2.5.2:
|
||||
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz#ac5fb7e227910c038d458f396b7400d93a3142d5"
|
||||
integrity sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==
|
||||
|
||||
tailwind-merge@^3.5.0:
|
||||
version "3.5.0"
|
||||
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-3.5.0.tgz#06502f4496ba15151445d97d916a26564d50d1ca"
|
||||
integrity sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==
|
||||
|
||||
tailwindcss-animate@^1.0.7:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz#318b692c4c42676cc9e67b19b78775742388bef4"
|
||||
|
||||
@@ -115,7 +115,6 @@ func (r *Repo) GetLatestVersion(
|
||||
func (r *Repo) insertConfig(
|
||||
ctx context.Context, orgId valuer.UUID, userId valuer.UUID, c *opamptypes.AgentConfigVersion, elements []string,
|
||||
) error {
|
||||
|
||||
if c.ElementType.StringValue() == "" {
|
||||
return errors.NewInvalidInputf(CodeElementTypeRequired, "element type is required for creating agent config version")
|
||||
}
|
||||
@@ -229,6 +228,25 @@ func (r *Repo) updateDeployStatus(ctx context.Context,
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDeployStatusByHash returns the DeployStatus for the given config hash
|
||||
// (stored with orgId prefix). Returns DeployStatusUnknown when no matching row exists.
|
||||
func (r *Repo) GetDeployStatusByHash(ctx context.Context, orgId valuer.UUID, configHash string) (opamptypes.DeployStatus, error) {
|
||||
var version opamptypes.AgentConfigVersion
|
||||
err := r.store.BunDB().NewSelect().
|
||||
Model(&version).
|
||||
ColumnExpr("deploy_status").
|
||||
Where("hash = ?", configHash).
|
||||
Where("org_id = ?", orgId).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return opamptypes.DeployStatusUnknown, nil
|
||||
}
|
||||
return opamptypes.DeployStatusUnknown, errors.WrapInternalf(err, errors.CodeInternal, "failed to query deploy status by hash")
|
||||
}
|
||||
return version.DeployStatus, nil
|
||||
}
|
||||
|
||||
func (r *Repo) updateDeployStatusByHash(
|
||||
ctx context.Context, orgId valuer.UUID, confighash string, status string, result string,
|
||||
) error {
|
||||
|
||||
@@ -180,6 +180,12 @@ func (m *Manager) ReportConfigDeploymentStatus(
|
||||
}
|
||||
}
|
||||
|
||||
// Implements model.AgentConfigProvider
|
||||
func (m *Manager) GetDeployStatusByHash(ctx context.Context, orgId valuer.UUID, configHash string) (opamptypes.DeployStatus, error) {
|
||||
return m.Repo.GetDeployStatusByHash(ctx, orgId, configHash)
|
||||
}
|
||||
|
||||
|
||||
func GetLatestVersion(
|
||||
ctx context.Context, orgId valuer.UUID, elementType opamptypes.ElementType,
|
||||
) (*opamptypes.AgentConfigVersion, error) {
|
||||
|
||||
@@ -3945,53 +3945,67 @@ func (r *ClickHouseReader) GetLogAttributeKeys(ctx context.Context, req *v3.Filt
|
||||
instrumentationtypes.CodeNamespace: "clickhouse-reader",
|
||||
instrumentationtypes.CodeFunctionName: "GetLogAttributeKeys",
|
||||
})
|
||||
var query string
|
||||
var err error
|
||||
var rows driver.Rows
|
||||
var response v3.FilterAttributeKeyResponse
|
||||
|
||||
tagTypeFilter := `tag_type != 'logfield'`
|
||||
if req.TagType != "" {
|
||||
tagTypeFilter = fmt.Sprintf(`tag_type != 'logfield' and tag_type = '%s'`, req.TagType)
|
||||
}
|
||||
attributeKeysTable := r.logsDB + "." + r.logsAttributeKeys
|
||||
resourceAttrKeysTable := r.logsDB + "." + r.logsResourceKeys
|
||||
|
||||
if len(req.SearchText) != 0 {
|
||||
query = fmt.Sprintf("select distinct tag_key, tag_type, tag_data_type from %s.%s where %s and tag_key ILIKE $1 limit $2", r.logsDB, r.logsTagAttributeTableV2, tagTypeFilter)
|
||||
rows, err = r.db.Query(ctx, query, fmt.Sprintf("%%%s%%", req.SearchText), req.Limit)
|
||||
} else {
|
||||
query = fmt.Sprintf("select distinct tag_key, tag_type, tag_data_type from %s.%s where %s limit $1", r.logsDB, r.logsTagAttributeTableV2, tagTypeFilter)
|
||||
rows, err = r.db.Query(ctx, query, req.Limit)
|
||||
var tagTypes []string
|
||||
var tables []string
|
||||
switch req.TagType {
|
||||
case v3.TagTypeTag:
|
||||
tables, tagTypes = []string{attributeKeysTable}, []string{"tag"}
|
||||
case v3.TagTypeResource:
|
||||
tables, tagTypes = []string{resourceAttrKeysTable}, []string{"resource"}
|
||||
case "":
|
||||
tables, tagTypes = []string{attributeKeysTable, resourceAttrKeysTable}, []string{"tag", "resource"}
|
||||
default:
|
||||
return nil, errorsV2.Newf(errorsV2.TypeInvalidInput, errorsV2.CodeInvalidInput, "unsupported tag type: %s", req.TagType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
r.logger.Error("Error while executing query", "error", err)
|
||||
return nil, fmt.Errorf("error while executing query: %s", err.Error())
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
statements := []model.ShowCreateTableStatement{}
|
||||
query = fmt.Sprintf("SHOW CREATE TABLE %s.%s", r.logsDB, r.logsLocalTableName)
|
||||
err = r.db.Select(ctx, &statements, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error while fetching logs schema: %s", err.Error())
|
||||
stmtQuery := fmt.Sprintf("SHOW CREATE TABLE %s.%s", r.logsDB, r.logsLocalTableName)
|
||||
if err := r.db.Select(ctx, &statements, stmtQuery); err != nil {
|
||||
return nil, errorsV2.Wrapf(err, errorsV2.TypeInternal, errorsV2.CodeInternal, "error while fetching logs schema")
|
||||
}
|
||||
|
||||
var attributeKey string
|
||||
var attributeDataType string
|
||||
var tagType string
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(&attributeKey, &tagType, &attributeDataType); err != nil {
|
||||
return nil, fmt.Errorf("error while scanning rows: %s", err.Error())
|
||||
for i, table := range tables {
|
||||
tagType := tagTypes[i]
|
||||
var query string
|
||||
if len(req.SearchText) != 0 {
|
||||
query = fmt.Sprintf("select distinct name, lower(datatype) from %s where name ILIKE $1 limit $2", table)
|
||||
} else {
|
||||
query = fmt.Sprintf("select distinct name, lower(datatype) from %s limit $1", table)
|
||||
}
|
||||
|
||||
key := v3.AttributeKey{
|
||||
Key: attributeKey,
|
||||
DataType: v3.AttributeKeyDataType(attributeDataType),
|
||||
Type: v3.AttributeKeyType(tagType),
|
||||
IsColumn: isColumn(statements[0].Statement, tagType, attributeKey, attributeDataType),
|
||||
var rows driver.Rows
|
||||
var err error
|
||||
if len(req.SearchText) != 0 {
|
||||
rows, err = r.db.Query(ctx, query, fmt.Sprintf("%%%s%%", req.SearchText), req.Limit)
|
||||
} else {
|
||||
rows, err = r.db.Query(ctx, query, req.Limit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errorsV2.Wrapf(err, errorsV2.TypeInternal, errorsV2.CodeInternal, "error while executing query")
|
||||
}
|
||||
|
||||
response.AttributeKeys = append(response.AttributeKeys, key)
|
||||
for rows.Next() {
|
||||
var keyName string
|
||||
var datatype string
|
||||
if err := rows.Scan(&keyName, &datatype); err != nil {
|
||||
rows.Close()
|
||||
return nil, errorsV2.Wrapf(err, errorsV2.TypeInternal, errorsV2.CodeInternal, "error while scanning rows")
|
||||
}
|
||||
|
||||
key := v3.AttributeKey{
|
||||
Key: keyName,
|
||||
DataType: v3.AttributeKeyDataType(datatype),
|
||||
Type: v3.AttributeKeyType(tagType),
|
||||
IsColumn: isColumn(statements[0].Statement, tagType, keyName, datatype),
|
||||
}
|
||||
response.AttributeKeys = append(response.AttributeKeys, key)
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
|
||||
// add other attributes only when the tagType is not specified
|
||||
|
||||
@@ -177,7 +177,7 @@ func (r *cloudProviderAccountsSQLRepository) upsert(
|
||||
onConflictClause := ""
|
||||
if len(onConflictSetStmts) > 0 {
|
||||
onConflictClause = fmt.Sprintf(
|
||||
"conflict(id, provider, org_id) do update SET\n%s",
|
||||
"conflict(id) do update SET\n%s",
|
||||
strings.Join(onConflictSetStmts, ",\n"),
|
||||
)
|
||||
}
|
||||
@@ -202,6 +202,8 @@ func (r *cloudProviderAccountsSQLRepository) upsert(
|
||||
Exec(ctx)
|
||||
|
||||
if dbErr != nil {
|
||||
// for now returning internal error even if there is a conflict,
|
||||
// will be handled better in the future iteration
|
||||
return nil, model.InternalError(fmt.Errorf(
|
||||
"could not upsert cloud account record: %w", dbErr,
|
||||
))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package opamp
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
|
||||
)
|
||||
|
||||
// Interface for a source of otel collector config recommendations.
|
||||
type AgentConfigProvider interface {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"net"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/opamptypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/google/uuid"
|
||||
"github.com/knadh/koanf"
|
||||
@@ -127,6 +128,11 @@ func (ta *MockAgentConfigProvider) HasReportedDeploymentStatus(orgID valuer.UUID
|
||||
return exists
|
||||
}
|
||||
|
||||
// AgentConfigProvider interface
|
||||
func (ta *MockAgentConfigProvider) GetDeployStatusByHash(_ context.Context, _ valuer.UUID, _ string) (opamptypes.DeployStatus, error) {
|
||||
return opamptypes.DeployStatusUnknown, nil
|
||||
}
|
||||
|
||||
// AgentConfigProvider interface
|
||||
func (ta *MockAgentConfigProvider) SubscribeToConfigUpdates(callback func()) func() {
|
||||
subscriberId := uuid.NewString()
|
||||
|
||||
@@ -111,90 +111,99 @@ func ExtractLbFlag(agentDescr *protobufs.AgentDescription) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (agent *Agent) updateAgentDescription(newStatus *protobufs.AgentToServer) (agentDescrChanged bool) {
|
||||
prevStatus := agent.Status
|
||||
|
||||
if agent.Status == nil {
|
||||
// First time this Agent reports a status, remember it.
|
||||
agent.Status = newStatus
|
||||
agentDescrChanged = true
|
||||
} else {
|
||||
// Not a new Agent. Update the Status.
|
||||
agent.Status.SequenceNum = newStatus.SequenceNum
|
||||
|
||||
// Check what's changed in the AgentDescription.
|
||||
if newStatus.AgentDescription != nil {
|
||||
// If the AgentDescription field is set it means the Agent tells us
|
||||
// something is changed in the field since the last status report
|
||||
// (or this is the first report).
|
||||
// Make full comparison of previous and new descriptions to see if it
|
||||
// really is different.
|
||||
if prevStatus != nil && proto.Equal(prevStatus.AgentDescription, newStatus.AgentDescription) {
|
||||
// Agent description didn't change.
|
||||
agentDescrChanged = false
|
||||
} else {
|
||||
// Yes, the description is different, update it.
|
||||
agent.Status.AgentDescription = newStatus.AgentDescription
|
||||
agentDescrChanged = true
|
||||
}
|
||||
} else {
|
||||
// AgentDescription field is not set, which means description didn't change.
|
||||
agentDescrChanged = false
|
||||
}
|
||||
|
||||
// Update remote config status if it is included and is different from what we have.
|
||||
if newStatus.RemoteConfigStatus != nil &&
|
||||
!proto.Equal(agent.Status.RemoteConfigStatus, newStatus.RemoteConfigStatus) {
|
||||
agent.Status.RemoteConfigStatus = newStatus.RemoteConfigStatus
|
||||
|
||||
// todo: need to address multiple agent scenario here
|
||||
// for now, the first response will be sent back to the UI
|
||||
if agent.Status.RemoteConfigStatus.Status == protobufs.RemoteConfigStatuses_RemoteConfigStatuses_APPLIED {
|
||||
onConfigSuccess(agent.OrgID, agent.AgentID, string(agent.Status.RemoteConfigStatus.LastRemoteConfigHash))
|
||||
}
|
||||
|
||||
if agent.Status.RemoteConfigStatus.Status == protobufs.RemoteConfigStatuses_RemoteConfigStatuses_FAILED {
|
||||
onConfigFailure(agent.OrgID, agent.AgentID, string(agent.Status.RemoteConfigStatus.LastRemoteConfigHash), agent.Status.RemoteConfigStatus.ErrorMessage)
|
||||
}
|
||||
}
|
||||
// agentDescriptionChanged returns true when the agent sends updated properties
|
||||
// (e.g. capability flag, version) mid-connection, signalling the server to
|
||||
// recompute and push a new RemoteConfig.
|
||||
//
|
||||
// On reconnect this always returns false: handleFirstStatus pre-copies
|
||||
// AgentDescription into agent.Status so no diff is detected, avoiding a
|
||||
// redundant config recompute.
|
||||
func (agent *Agent) agentDescriptionChanged(newStatus *protobufs.AgentToServer) bool {
|
||||
// nil AgentDescription means no change per OpAMP protocol.
|
||||
if newStatus.AgentDescription == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if agentDescrChanged {
|
||||
agent.CanLB = ExtractLbFlag(newStatus.AgentDescription)
|
||||
if proto.Equal(agent.Status.AgentDescription, newStatus.AgentDescription) {
|
||||
return false
|
||||
}
|
||||
|
||||
return agentDescrChanged
|
||||
agent.CanLB = ExtractLbFlag(newStatus.AgentDescription)
|
||||
return true
|
||||
}
|
||||
|
||||
func (agent *Agent) updateHealth(newStatus *protobufs.AgentToServer) {
|
||||
if newStatus.Health == nil {
|
||||
// updateRemoteConfigStatus updates the stored RemoteConfigStatus and notifies
|
||||
// subscribers if the status has changed relative to what we have stored.
|
||||
func (agent *Agent) updateRemoteConfigStatus(newStatus *protobufs.AgentToServer) {
|
||||
if newStatus.RemoteConfigStatus == nil ||
|
||||
proto.Equal(agent.Status.RemoteConfigStatus, newStatus.RemoteConfigStatus) {
|
||||
return
|
||||
}
|
||||
|
||||
agent.Status.Health = newStatus.Health
|
||||
|
||||
if agent.Status != nil && agent.Status.Health != nil && agent.Status.Health.Healthy {
|
||||
agent.TimeAuditable.UpdatedAt = time.Unix(0, int64(agent.Status.Health.StartTimeUnixNano)).UTC()
|
||||
// todo: need to address multiple agent scenario here
|
||||
// for now, the first response will be sent back to the UI
|
||||
hash := string(newStatus.RemoteConfigStatus.LastRemoteConfigHash)
|
||||
switch newStatus.RemoteConfigStatus.Status {
|
||||
case protobufs.RemoteConfigStatuses_RemoteConfigStatuses_APPLIED:
|
||||
onConfigSuccess(agent.OrgID, agent.AgentID, hash)
|
||||
case protobufs.RemoteConfigStatuses_RemoteConfigStatuses_FAILED:
|
||||
onConfigFailure(agent.OrgID, agent.AgentID, hash, newStatus.RemoteConfigStatus.ErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (agent *Agent) updateRemoteConfigStatus(newStatus *protobufs.AgentToServer) {
|
||||
// Update remote config status if it is included and is different from what we have.
|
||||
if newStatus.RemoteConfigStatus != nil {
|
||||
agent.Status.RemoteConfigStatus = newStatus.RemoteConfigStatus
|
||||
// handleFirstStatus initializes agent.Status on the first message received from
|
||||
// this agent instance. It is a no-op for all subsequent messages.
|
||||
func (agent *Agent) handleFirstStatus(newStatus *protobufs.AgentToServer, configProvider AgentConfigProvider) {
|
||||
if agent.Status != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize with a clean slate.
|
||||
agent.Status = &protobufs.AgentToServer{
|
||||
RemoteConfigStatus: &protobufs.RemoteConfigStatus{
|
||||
Status: protobufs.RemoteConfigStatuses_RemoteConfigStatuses_UNSET,
|
||||
},
|
||||
}
|
||||
|
||||
if newStatus.RemoteConfigStatus == nil ||
|
||||
newStatus.RemoteConfigStatus.Status == protobufs.RemoteConfigStatuses_RemoteConfigStatuses_UNSET {
|
||||
// Agent just started fresh — no prior deployment to reconcile with the DB.
|
||||
return
|
||||
}
|
||||
|
||||
// Since the server's connection is restarted;
|
||||
// copy the agent description; so no change is detected by agentDescriptionChanged
|
||||
agent.Status.AgentDescription = newStatus.AgentDescription
|
||||
|
||||
// Server reconnected while the agent was already running.
|
||||
// Reconcile deployment status with DB; DB is the source of truth.
|
||||
// If DB says in_progress but agent now reports APPLIED/FAILED,
|
||||
// updateRemoteConfigStatus will detect the transition and notify subscribers.
|
||||
rawHash := string(newStatus.RemoteConfigStatus.LastRemoteConfigHash)
|
||||
deployStatus, err := configProvider.GetDeployStatusByHash(context.Background(), agent.OrgID, agent.OrgID.String()+rawHash)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
agent.Status.RemoteConfigStatus.Status = opamptypes.DeployStatusToProtoStatus[deployStatus]
|
||||
|
||||
// If the deployment is still in-flight, rehydrate the subscriber so that
|
||||
// updateRemoteConfigStatus can fire onConfigSuccess/onConfigFailure when
|
||||
// the agent next reports a terminal status.
|
||||
if deployStatus != opamptypes.Deployed && deployStatus != opamptypes.DeployFailed {
|
||||
ListenToConfigUpdate(agent.OrgID, agent.AgentID, rawHash, configProvider.ReportConfigDeploymentStatus)
|
||||
}
|
||||
}
|
||||
|
||||
func (agent *Agent) updateStatusField(newStatus *protobufs.AgentToServer) (agentDescrChanged bool) {
|
||||
if agent.Status == nil {
|
||||
// First time this Agent reports a status, remember it.
|
||||
agent.Status = newStatus
|
||||
agentDescrChanged = true
|
||||
func (agent *Agent) updateStatusField(newStatus *protobufs.AgentToServer, configProvider AgentConfigProvider) bool {
|
||||
agent.handleFirstStatus(newStatus, configProvider)
|
||||
agentDescrChanged := agent.agentDescriptionChanged(newStatus)
|
||||
// record healthy timestamp
|
||||
if newStatus.Health != nil && newStatus.Health.Healthy {
|
||||
agent.TimeAuditable.UpdatedAt = time.Unix(0, int64(newStatus.Health.StartTimeUnixNano)).UTC()
|
||||
}
|
||||
|
||||
agentDescrChanged = agent.updateAgentDescription(newStatus) || agentDescrChanged
|
||||
// notify subscribers first; this will update the status in the DB
|
||||
agent.updateRemoteConfigStatus(newStatus)
|
||||
agent.updateHealth(newStatus)
|
||||
// update local reference in last.
|
||||
agent.Status = newStatus
|
||||
return agentDescrChanged
|
||||
}
|
||||
|
||||
@@ -237,7 +246,7 @@ func (agent *Agent) processStatusUpdate(
|
||||
// current status is not up-to-date.
|
||||
lostPreviousUpdate := (agent.Status == nil) || (agent.Status != nil && agent.Status.SequenceNum+1 != newStatus.SequenceNum)
|
||||
|
||||
agentDescrChanged := agent.updateStatusField(newStatus)
|
||||
agentDescrChanged := agent.updateStatusField(newStatus, configProvider)
|
||||
|
||||
// Check if any fields were omitted in the status report.
|
||||
effectiveConfigOmitted := newStatus.EffectiveConfig == nil &&
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
package model
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/valuer"
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/opamptypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// Interface for source of otel collector config recommendations.
|
||||
type AgentConfigProvider interface {
|
||||
@@ -20,4 +25,10 @@ type AgentConfigProvider interface {
|
||||
configId string,
|
||||
err error,
|
||||
)
|
||||
|
||||
// GetDeployStatusByHash returns the DeployStatus for the given config hash
|
||||
// (with orgId prefix as stored in the DB). Returns DeployStatusUnknown when
|
||||
// no matching row exists. Used by the agent's first-connect handler to
|
||||
// determine whether the reported RemoteConfigStatus resolves a pending deployment.
|
||||
GetDeployStatusByHash(ctx context.Context, orgId valuer.UUID, configHash string) (opamptypes.DeployStatus, error)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ func ListenToConfigUpdate(orgId valuer.UUID, agentId string, hash string, ss OnC
|
||||
defer coordinator.mutex.Unlock()
|
||||
|
||||
key := getSubscriberKey(orgId, hash)
|
||||
|
||||
if subs, ok := coordinator.subscribers[key]; ok {
|
||||
subs = append(subs, ss)
|
||||
coordinator.subscribers[key] = subs
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
@@ -135,7 +136,7 @@ func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UU
|
||||
Where("id = ?", id.StringValue()).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return r.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "cannot delete planned maintenance because it is referenced by associated rules, remove the rules from the planned maintenance first")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
@@ -75,7 +76,7 @@ func (r *rule) DeleteRule(ctx context.Context, id valuer.UUID, cb func(context.C
|
||||
Where("id = ?", id.StringValue()).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
return r.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "cannot delete rule because it is referenced by a planned maintenance, remove the rule from the planned maintenance first")
|
||||
}
|
||||
|
||||
return cb(ctx)
|
||||
|
||||
@@ -175,6 +175,8 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewMigrateRulesV4ToV5Factory(sqlstore, telemetryStore),
|
||||
sqlmigration.NewAddStatusUserFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewDeprecateUserInviteFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewUpdateCloudIntegrationUniqueIndexFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewUpdatePlannedMaintenanceRuleFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
255
pkg/sqlmigration/069_update_cloud_integration_index.go
Normal file
255
pkg/sqlmigration/069_update_cloud_integration_index.go
Normal file
@@ -0,0 +1,255 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type updateCloudIntegrationUniqueIndex struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
func NewUpdateCloudIntegrationUniqueIndexFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("update_cloud_integration_index"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &updateCloudIntegrationUniqueIndex{
|
||||
sqlstore: sqlstore,
|
||||
sqlschema: sqlschema,
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (migration *updateCloudIntegrationUniqueIndex) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type cloudIntegrationRow struct {
|
||||
bun.BaseModel `bun:"table:cloud_integration"`
|
||||
|
||||
ID string `bun:"id"`
|
||||
AccountID string `bun:"account_id"`
|
||||
Provider string `bun:"provider"`
|
||||
OrgID string `bun:"org_id"`
|
||||
Config string `bun:"config"`
|
||||
UpdatedAt time.Time `bun:"updated_at"`
|
||||
}
|
||||
|
||||
type cloudIntegrationAccountConfig struct {
|
||||
Regions []string `json:"regions"`
|
||||
}
|
||||
|
||||
// duplicateGroup holds the keeper (first element) and losers (rest) for a duplicate (account_id, provider, org_id) group.
|
||||
type duplicateGroup struct {
|
||||
keeper *cloudIntegrationRow
|
||||
losers []*cloudIntegrationRow
|
||||
}
|
||||
|
||||
func (migration *updateCloudIntegrationUniqueIndex) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
sqls := [][]byte{}
|
||||
|
||||
// Step 1: Drop the wrong index on (id, provider, org_id)
|
||||
dropSqls := migration.sqlschema.Operator().DropIndex(
|
||||
(&sqlschema.UniqueIndex{
|
||||
TableName: "cloud_integration",
|
||||
ColumnNames: []sqlschema.ColumnName{"id", "provider", "org_id"},
|
||||
}).Named("unique_cloud_integration"),
|
||||
)
|
||||
sqls = append(sqls, dropSqls...)
|
||||
|
||||
// Step 2: Normalize empty-string account_id to NULL
|
||||
// Older table structure could store "" instead of NULL for unconnected accounts.
|
||||
// Empty strings would violate the partial unique index since '' = '' (unlike NULL != NULL).
|
||||
_, err = tx.NewUpdate().
|
||||
TableExpr("cloud_integration").
|
||||
Set("account_id = NULL").
|
||||
Where("account_id = ''").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 3: Fetch all active rows with non-null account_id, ordered for grouping
|
||||
var activeRows []*cloudIntegrationRow
|
||||
err = tx.NewSelect().
|
||||
Model(&activeRows).
|
||||
Where("removed_at IS NULL").
|
||||
Where("account_id IS NOT NULL").
|
||||
OrderExpr("account_id, provider, org_id, updated_at DESC").
|
||||
Scan(ctx)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Group by (account_id, provider, org_id)
|
||||
groups := groupCloudIntegrationRows(activeRows)
|
||||
|
||||
now := time.Now()
|
||||
var loserIDs []string
|
||||
|
||||
for _, group := range groups {
|
||||
if len(group.losers) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 4: Merge config from losers into keeper
|
||||
if err = mergeCloudIntegrationConfigs(ctx, tx, group); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 5: Reassign non-conflicting cloud_integration_service rows to keeper
|
||||
for _, loser := range group.losers {
|
||||
_, err = tx.NewUpdate().
|
||||
TableExpr("cloud_integration_service").
|
||||
Set("cloud_integration_id = ?", group.keeper.ID).
|
||||
Where("cloud_integration_id = ?", loser.ID).
|
||||
Where("type NOT IN (?)",
|
||||
tx.NewSelect().
|
||||
TableExpr("cloud_integration_service").
|
||||
Column("type").
|
||||
Where("cloud_integration_id = ?", group.keeper.ID),
|
||||
).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
loserIDs = append(loserIDs, loser.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Soft-delete all loser rows
|
||||
if len(loserIDs) > 0 {
|
||||
_, err = tx.NewUpdate().
|
||||
TableExpr("cloud_integration").
|
||||
Set("removed_at = ?", now).
|
||||
Set("updated_at = ?", now).
|
||||
Where("id IN (?)", bun.In(loserIDs)).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Create the correct partial unique index on (account_id, provider, org_id) WHERE removed_at IS NULL
|
||||
createSqls := migration.sqlschema.Operator().CreateIndex(
|
||||
&sqlschema.PartialUniqueIndex{
|
||||
TableName: "cloud_integration",
|
||||
ColumnNames: []sqlschema.ColumnName{"account_id", "provider", "org_id"},
|
||||
Where: "removed_at IS NULL",
|
||||
},
|
||||
)
|
||||
sqls = append(sqls, createSqls...)
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err = tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *updateCloudIntegrationUniqueIndex) Down(ctx context.Context, db *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// groupCloudIntegrationRows groups rows by (account_id, provider, org_id).
|
||||
// Rows must be pre-sorted by account_id, provider, org_id, updated_at DESC
|
||||
// so the first row in each group is the keeper (most recently updated).
|
||||
func groupCloudIntegrationRows(rows []*cloudIntegrationRow) []duplicateGroup {
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var groups []duplicateGroup
|
||||
var current duplicateGroup
|
||||
current.keeper = rows[0]
|
||||
|
||||
for i := 1; i < len(rows); i++ {
|
||||
row := rows[i]
|
||||
if row.AccountID == current.keeper.AccountID &&
|
||||
row.Provider == current.keeper.Provider &&
|
||||
row.OrgID == current.keeper.OrgID {
|
||||
current.losers = append(current.losers, row)
|
||||
} else {
|
||||
groups = append(groups, current)
|
||||
current = duplicateGroup{keeper: row}
|
||||
}
|
||||
}
|
||||
groups = append(groups, current)
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// mergeCloudIntegrationConfigs unions the EnabledRegions from all rows in the group into the keeper's config and updates
|
||||
func mergeCloudIntegrationConfigs(ctx context.Context, tx bun.Tx, group duplicateGroup) error {
|
||||
regionSet := make(map[string]struct{})
|
||||
|
||||
// Parse keeper's config
|
||||
parseRegions(group.keeper.Config, regionSet)
|
||||
|
||||
// Parse each loser's config
|
||||
for _, loser := range group.losers {
|
||||
parseRegions(loser.Config, regionSet)
|
||||
}
|
||||
|
||||
// Build merged config
|
||||
mergedRegions := make([]string, 0, len(regionSet))
|
||||
for region := range regionSet {
|
||||
mergedRegions = append(mergedRegions, region)
|
||||
}
|
||||
|
||||
merged := cloudIntegrationAccountConfig{Regions: mergedRegions}
|
||||
mergedJSON, err := json.Marshal(merged)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update keeper's config
|
||||
_, err = tx.NewUpdate().
|
||||
TableExpr("cloud_integration").
|
||||
Set("config = ?", string(mergedJSON)).
|
||||
Where("id = ?", group.keeper.ID).
|
||||
Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
// parseRegions unmarshals a config JSON string and adds its regions to the set.
|
||||
func parseRegions(configJSON string, regionSet map[string]struct{}) {
|
||||
if configJSON == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var config cloudIntegrationAccountConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, region := range config.Regions {
|
||||
regionSet[region] = struct{}{}
|
||||
}
|
||||
}
|
||||
132
pkg/sqlmigration/070_update_planned_maintenance_rule.go
Normal file
132
pkg/sqlmigration/070_update_planned_maintenance_rule.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type updatePlannedMaintenanceRule struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
type plannedMaintenanceRuleRow struct {
|
||||
bun.BaseModel `bun:"table:planned_maintenance_rule"`
|
||||
|
||||
ID string `bun:"id"`
|
||||
PlannedMaintenanceID string `bun:"planned_maintenance_id"`
|
||||
RuleID string `bun:"rule_id"`
|
||||
}
|
||||
|
||||
func NewUpdatePlannedMaintenanceRuleFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("update_planned_maintenance_rule"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &updatePlannedMaintenanceRule{
|
||||
sqlstore: sqlstore,
|
||||
sqlschema: sqlschema,
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (migration *updatePlannedMaintenanceRule) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *updatePlannedMaintenanceRule) Up(ctx context.Context, db *bun.DB) error {
|
||||
table, _, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("planned_maintenance_rule"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := migration.sqlschema.ToggleFKEnforcement(ctx, db, false); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
// Read all existing rows
|
||||
var rows []*plannedMaintenanceRuleRow
|
||||
err = tx.NewSelect().Model(&rows).Scan(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Drop the existing table
|
||||
dropTableSQLs := migration.sqlschema.Operator().DropTable(table)
|
||||
for _, sql := range dropTableSQLs {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Create the table fresh without CASCADE constraints
|
||||
newTable := &sqlschema.Table{
|
||||
Name: sqlschema.TableName("planned_maintenance_rule"),
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "planned_maintenance_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "rule_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
},
|
||||
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
|
||||
ColumnNames: []sqlschema.ColumnName{"id"},
|
||||
},
|
||||
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
|
||||
{
|
||||
ReferencingColumnName: "planned_maintenance_id",
|
||||
ReferencedTableName: "planned_maintenance",
|
||||
ReferencedColumnName: "id",
|
||||
},
|
||||
{
|
||||
ReferencingColumnName: "rule_id",
|
||||
ReferencedTableName: "rule",
|
||||
ReferencedColumnName: "id",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
createTableSQLs := migration.sqlschema.Operator().CreateTable(newTable)
|
||||
for _, sql := range createTableSQLs {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Re-insert the data
|
||||
if len(rows) > 0 {
|
||||
_, err = tx.NewInsert().Model(&rows).Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := migration.sqlschema.ToggleFKEnforcement(ctx, db, true); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *updatePlannedMaintenanceRule) Down(ctx context.Context, db *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -100,7 +100,7 @@ func (provider *provider) WrapNotFoundErrf(err error, code errors.Code, format s
|
||||
|
||||
func (provider *provider) WrapAlreadyExistsErrf(err error, code errors.Code, format string, args ...any) error {
|
||||
if sqlite3Err, ok := err.(*sqlite.Error); ok {
|
||||
if sqlite3Err.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE || sqlite3Err.Code() == sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY {
|
||||
if sqlite3Err.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE || sqlite3Err.Code() == sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY || sqlite3Err.Code() == sqlite3.SQLITE_CONSTRAINT_FOREIGNKEY {
|
||||
return errors.Wrapf(err, errors.TypeAlreadyExists, code, format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/open-telemetry/opamp-go/protobufs"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
@@ -17,6 +18,15 @@ const (
|
||||
AgentStatusDisconnected
|
||||
)
|
||||
|
||||
var DeployStatusToProtoStatus = map[DeployStatus]protobufs.RemoteConfigStatuses{
|
||||
PendingDeploy: protobufs.RemoteConfigStatuses_RemoteConfigStatuses_UNSET,
|
||||
Deploying: protobufs.RemoteConfigStatuses_RemoteConfigStatuses_APPLYING,
|
||||
Deployed: protobufs.RemoteConfigStatuses_RemoteConfigStatuses_APPLIED,
|
||||
DeployInitiated: protobufs.RemoteConfigStatuses_RemoteConfigStatuses_APPLYING,
|
||||
DeployFailed: protobufs.RemoteConfigStatuses_RemoteConfigStatuses_FAILED,
|
||||
DeployStatusUnknown: protobufs.RemoteConfigStatuses_RemoteConfigStatuses_UNSET,
|
||||
}
|
||||
|
||||
type StorableAgent struct {
|
||||
bun.BaseModel `bun:"table:agent"`
|
||||
|
||||
@@ -30,16 +40,6 @@ type StorableAgent struct {
|
||||
Config string `bun:"config,type:text,notnull"`
|
||||
}
|
||||
|
||||
func NewStorableAgent(store sqlstore.SQLStore, orgID valuer.UUID, agentID string, status AgentStatus) StorableAgent {
|
||||
return StorableAgent{
|
||||
OrgID: orgID,
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
AgentID: agentID,
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
type ElementType struct{ valuer.String }
|
||||
|
||||
var (
|
||||
@@ -49,24 +49,6 @@ var (
|
||||
ElementTypeLbExporter = ElementType{valuer.NewString("lb_exporter")}
|
||||
)
|
||||
|
||||
// NewElementType creates a new ElementType from a string value.
|
||||
// Returns the corresponding ElementType constant if the string matches,
|
||||
// otherwise returns an empty ElementType.
|
||||
func NewElementType(value string) ElementType {
|
||||
switch valuer.NewString(value) {
|
||||
case ElementTypeSamplingRules.String:
|
||||
return ElementTypeSamplingRules
|
||||
case ElementTypeDropRules.String:
|
||||
return ElementTypeDropRules
|
||||
case ElementTypeLogPipelines.String:
|
||||
return ElementTypeLogPipelines
|
||||
case ElementTypeLbExporter.String:
|
||||
return ElementTypeLbExporter
|
||||
default:
|
||||
return ElementType{valuer.NewString("")}
|
||||
}
|
||||
}
|
||||
|
||||
type DeployStatus struct{ valuer.String }
|
||||
|
||||
var (
|
||||
@@ -98,6 +80,26 @@ type AgentConfigVersion struct {
|
||||
Config string `json:"config" bun:"config,type:text"`
|
||||
}
|
||||
|
||||
type AgentConfigElement struct {
|
||||
bun.BaseModel `bun:"table:agent_config_element"`
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
ElementID string `bun:"element_id,type:text,notnull,unique:element_type_version_idx"`
|
||||
ElementType string `bun:"element_type,type:text,notnull,unique:element_type_version_idx"`
|
||||
VersionID valuer.UUID `bun:"version_id,type:text,notnull,unique:element_type_version_idx"`
|
||||
}
|
||||
|
||||
func NewStorableAgent(store sqlstore.SQLStore, orgID valuer.UUID, agentID string, status AgentStatus) StorableAgent {
|
||||
return StorableAgent{
|
||||
OrgID: orgID,
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
AgentID: agentID,
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: time.Now(), UpdatedAt: time.Now()},
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
func NewAgentConfigVersion(orgId valuer.UUID, userId valuer.UUID, elementType ElementType) *AgentConfigVersion {
|
||||
return &AgentConfigVersion{
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
@@ -118,12 +120,20 @@ func (a *AgentConfigVersion) IncrementVersion(lastVersion int) {
|
||||
a.Version = lastVersion + 1
|
||||
}
|
||||
|
||||
type AgentConfigElement struct {
|
||||
bun.BaseModel `bun:"table:agent_config_element"`
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
ElementID string `bun:"element_id,type:text,notnull,unique:element_type_version_idx"`
|
||||
ElementType string `bun:"element_type,type:text,notnull,unique:element_type_version_idx"`
|
||||
VersionID valuer.UUID `bun:"version_id,type:text,notnull,unique:element_type_version_idx"`
|
||||
// NewElementType creates a new ElementType from a string value.
|
||||
// Returns the corresponding ElementType constant if the string matches,
|
||||
// otherwise returns an empty ElementType.
|
||||
func NewElementType(value string) ElementType {
|
||||
switch valuer.NewString(value) {
|
||||
case ElementTypeSamplingRules.String:
|
||||
return ElementTypeSamplingRules
|
||||
case ElementTypeDropRules.String:
|
||||
return ElementTypeDropRules
|
||||
case ElementTypeLogPipelines.String:
|
||||
return ElementTypeLogPipelines
|
||||
case ElementTypeLbExporter.String:
|
||||
return ElementTypeLbExporter
|
||||
default:
|
||||
return ElementType{valuer.NewString("")}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Fixtures for cloud integration tests."""
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import Callable, Optional
|
||||
from typing import Callable
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
@@ -18,14 +18,12 @@ def create_cloud_integration_account(
|
||||
request: pytest.FixtureRequest,
|
||||
signoz: types.SigNoz,
|
||||
) -> Callable[[str, str], dict]:
|
||||
created_account_id: Optional[str] = None
|
||||
cloud_provider_used: Optional[str] = None
|
||||
created_accounts: list[tuple[str, str]] = []
|
||||
|
||||
def _create(
|
||||
admin_token: str,
|
||||
cloud_provider: str = "aws",
|
||||
) -> dict:
|
||||
nonlocal created_account_id, cloud_provider_used
|
||||
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/accounts/generate-connection-url"
|
||||
|
||||
request_payload = {
|
||||
@@ -52,35 +50,31 @@ def create_cloud_integration_account(
|
||||
), f"Failed to create test account: {response.status_code}"
|
||||
|
||||
data = response.json().get("data", response.json())
|
||||
created_account_id = data.get("account_id")
|
||||
cloud_provider_used = cloud_provider
|
||||
created_accounts.append((data.get("account_id"), cloud_provider))
|
||||
|
||||
return data
|
||||
|
||||
def _disconnect(admin_token: str, cloud_provider: str) -> requests.Response:
|
||||
assert created_account_id
|
||||
disconnect_endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/accounts/{created_account_id}/disconnect"
|
||||
return requests.post(
|
||||
signoz.self.host_configs["8080"].get(disconnect_endpoint),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
# Yield factory to the test
|
||||
yield _create
|
||||
|
||||
# Post-test cleanup: generate admin token and disconnect the created account
|
||||
if created_account_id and cloud_provider_used:
|
||||
# Post-test cleanup: disconnect all created accounts
|
||||
if created_accounts:
|
||||
get_token = request.getfixturevalue("get_token")
|
||||
try:
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
r = _disconnect(admin_token, cloud_provider_used)
|
||||
if r.status_code != HTTPStatus.OK:
|
||||
logger.info(
|
||||
"Disconnect cleanup returned %s for account %s",
|
||||
r.status_code,
|
||||
created_account_id,
|
||||
for account_id, cloud_provider in created_accounts:
|
||||
disconnect_endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/accounts/{account_id}/disconnect"
|
||||
r = requests.post(
|
||||
signoz.self.host_configs["8080"].get(disconnect_endpoint),
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
timeout=10,
|
||||
)
|
||||
logger.info("Cleaned up test account: %s", created_account_id)
|
||||
if r.status_code != HTTPStatus.OK:
|
||||
logger.info(
|
||||
"Disconnect cleanup returned %s for account %s",
|
||||
r.status_code,
|
||||
account_id,
|
||||
)
|
||||
logger.info("Cleaned up test account: %s", account_id)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.info("Post-test disconnect cleanup failed: %s", exc)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Fixtures for cloud integration tests."""
|
||||
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
@@ -16,7 +14,7 @@ def simulate_agent_checkin(
|
||||
cloud_provider: str,
|
||||
account_id: str,
|
||||
cloud_account_id: str,
|
||||
) -> dict:
|
||||
) -> requests.Response:
|
||||
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/agent-check-in"
|
||||
|
||||
checkin_payload = {
|
||||
@@ -32,16 +30,11 @@ def simulate_agent_checkin(
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code != HTTPStatus.OK:
|
||||
if not response.ok:
|
||||
logger.error(
|
||||
"Agent check-in failed: %s, response: %s",
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Agent check-in failed: {response.status_code}"
|
||||
|
||||
response_data = response.json()
|
||||
return response_data.get("data", response_data)
|
||||
return response
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import uuid
|
||||
from http import HTTPStatus
|
||||
from typing import Callable
|
||||
|
||||
@@ -5,6 +6,8 @@ import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.cloudintegrations import create_cloud_integration_account
|
||||
from fixtures.cloudintegrationsutils import simulate_agent_checkin
|
||||
from fixtures.logger import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -142,3 +145,42 @@ def test_generate_connection_url_unsupported_provider(
|
||||
assert (
|
||||
"unsupported cloud provider" in response_data["error"].lower()
|
||||
), "Error message should indicate unsupported provider"
|
||||
|
||||
|
||||
def test_duplicate_cloud_account_checkins(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
create_cloud_integration_account: Callable,
|
||||
) -> None:
|
||||
"""Test that two accounts cannot check in with the same cloud_account_id."""
|
||||
|
||||
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
cloud_provider = "aws"
|
||||
same_cloud_account_id = str(uuid.uuid4())
|
||||
|
||||
# Create two separate cloud integration accounts via generate-connection-url
|
||||
account1 = create_cloud_integration_account(admin_token, cloud_provider)
|
||||
account1_id = account1["account_id"]
|
||||
|
||||
account2 = create_cloud_integration_account(admin_token, cloud_provider)
|
||||
account2_id = account2["account_id"]
|
||||
|
||||
assert account1_id != account2_id, "Two accounts should have different internal IDs"
|
||||
|
||||
# First check-in succeeds: account1 claims cloud_account_id
|
||||
response = simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account1_id, same_cloud_account_id
|
||||
)
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200 for first check-in, got {response.status_code}: {response.text}"
|
||||
#
|
||||
# Second check-in should fail: account2 tries to use the same cloud_account_id
|
||||
response = simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account2_id, same_cloud_account_id
|
||||
)
|
||||
|
||||
assert (
|
||||
response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
|
||||
), f"Expected 500 for duplicate cloud_account_id, got {response.status_code}: {response.text}"
|
||||
|
||||
@@ -57,9 +57,12 @@ def test_list_connected_accounts_with_account(
|
||||
|
||||
# Simulate agent check-in to mark as connected
|
||||
cloud_account_id = str(uuid.uuid4())
|
||||
simulate_agent_checkin(
|
||||
response = simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account_id, cloud_account_id
|
||||
)
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200 for agent check-in, got {response.status_code}: {response.text}"
|
||||
|
||||
# List accounts
|
||||
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/accounts"
|
||||
@@ -161,9 +164,12 @@ def test_update_account_config(
|
||||
|
||||
# Simulate agent check-in to mark as connected
|
||||
cloud_account_id = str(uuid.uuid4())
|
||||
simulate_agent_checkin(
|
||||
response = simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account_id, cloud_account_id
|
||||
)
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200 for agent check-in, got {response.status_code}: {response.text}"
|
||||
|
||||
# Update account configuration
|
||||
endpoint = (
|
||||
@@ -226,9 +232,12 @@ def test_disconnect_account(
|
||||
|
||||
# Simulate agent check-in to mark as connected
|
||||
cloud_account_id = str(uuid.uuid4())
|
||||
simulate_agent_checkin(
|
||||
response = simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account_id, cloud_account_id
|
||||
)
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200 for agent check-in, got {response.status_code}: {response.text}"
|
||||
|
||||
# Disconnect the account
|
||||
endpoint = (
|
||||
|
||||
@@ -61,9 +61,12 @@ def test_list_services_with_account(
|
||||
account_id = account_data["account_id"]
|
||||
|
||||
cloud_account_id = str(uuid.uuid4())
|
||||
simulate_agent_checkin(
|
||||
response = simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account_id, cloud_account_id
|
||||
)
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200 for agent check-in, got {response.status_code}: {response.text}"
|
||||
|
||||
# List services for the account
|
||||
endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services?cloud_account_id={cloud_account_id}"
|
||||
@@ -152,9 +155,12 @@ def test_get_service_details_with_account(
|
||||
account_id = account_data["account_id"]
|
||||
|
||||
cloud_account_id = str(uuid.uuid4())
|
||||
simulate_agent_checkin(
|
||||
response = simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account_id, cloud_account_id
|
||||
)
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200 for agent check-in, got {response.status_code}: {response.text}"
|
||||
|
||||
# Get list of services first
|
||||
list_endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services"
|
||||
@@ -253,9 +259,12 @@ def test_update_service_config(
|
||||
account_id = account_data["account_id"]
|
||||
|
||||
cloud_account_id = str(uuid.uuid4())
|
||||
simulate_agent_checkin(
|
||||
response = simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account_id, cloud_account_id
|
||||
)
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200 for agent check-in, got {response.status_code}: {response.text}"
|
||||
|
||||
# Get list of services to pick a valid service ID
|
||||
list_endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services"
|
||||
@@ -365,9 +374,12 @@ def test_update_service_config_invalid_service(
|
||||
account_id = account_data["account_id"]
|
||||
|
||||
cloud_account_id = str(uuid.uuid4())
|
||||
simulate_agent_checkin(
|
||||
response = simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account_id, cloud_account_id
|
||||
)
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200 for agent check-in, got {response.status_code}: {response.text}"
|
||||
|
||||
# Try to update config for invalid service
|
||||
fake_service_id = "non-existent-service"
|
||||
@@ -409,9 +421,12 @@ def test_update_service_config_disable_service(
|
||||
account_id = account_data["account_id"]
|
||||
|
||||
cloud_account_id = str(uuid.uuid4())
|
||||
simulate_agent_checkin(
|
||||
response = simulate_agent_checkin(
|
||||
signoz, admin_token, cloud_provider, account_id, cloud_account_id
|
||||
)
|
||||
assert (
|
||||
response.status_code == HTTPStatus.OK
|
||||
), f"Expected 200 for agent check-in, got {response.status_code}: {response.text}"
|
||||
|
||||
# Get a valid service
|
||||
list_endpoint = f"/api/v1/cloud-integrations/{cloud_provider}/services"
|
||||
|
||||
Reference in New Issue
Block a user