Compare commits

..

1 Commits

Author SHA1 Message Date
Piyush Singariya
099e7a98c5 fix: prepend normalize pipeline 2026-03-18 14:55:40 +05:30
166 changed files with 1200 additions and 5131 deletions

View File

@@ -49,7 +49,6 @@ jobs:
- ttl
- alerts
- ingestionkeys
- rootuser
sqlstore-provider:
- postgres
- sqlite

View File

@@ -328,18 +328,15 @@ user:
##################### IdentN #####################
identn:
tokenizer:
# toggle tokenizer identN
# toggle the identN resolver
enabled: true
# headers to use for tokenizer identN resolver
headers:
- Authorization
- Sec-WebSocket-Protocol
apikey:
# toggle apikey identN
# toggle the identN resolver
enabled: true
# headers to use for apikey identN resolver
headers:
- SIGNOZ-API-KEY
impersonation:
# toggle impersonation identN, when enabled, all requests will impersonate the root user
enabled: false

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.116.1
image: signoz/signoz:v0.116.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.116.1
image: signoz/signoz:v0.116.0
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.116.1}
image: signoz/signoz:${VERSION:-v0.116.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.116.1}
image: signoz/signoz:${VERSION:-v0.116.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -598,39 +598,6 @@ components:
required:
- config
type: object
GlobaltypesAPIKeyConfig:
properties:
enabled:
type: boolean
type: object
GlobaltypesConfig:
properties:
external_url:
type: string
identN:
$ref: '#/components/schemas/GlobaltypesIdentNConfig'
ingestion_url:
type: string
type: object
GlobaltypesIdentNConfig:
properties:
apikey:
$ref: '#/components/schemas/GlobaltypesAPIKeyConfig'
impersonation:
$ref: '#/components/schemas/GlobaltypesImpersonationConfig'
tokenizer:
$ref: '#/components/schemas/GlobaltypesTokenizerConfig'
type: object
GlobaltypesImpersonationConfig:
properties:
enabled:
type: boolean
type: object
GlobaltypesTokenizerConfig:
properties:
enabled:
type: boolean
type: object
MetricsexplorertypesListMetric:
properties:
description:
@@ -2063,6 +2030,13 @@ components:
required:
- id
type: object
TypesGettableGlobalConfig:
properties:
external_url:
type: string
ingestion_url:
type: string
type: object
TypesIdentifiable:
properties:
id:
@@ -2087,11 +2061,6 @@ components:
type: string
role:
type: string
roles:
items:
type: string
nullable: true
type: array
token:
type: string
updatedAt:
@@ -2174,11 +2143,6 @@ components:
type: string
role:
type: string
roles:
items:
type: string
nullable: true
type: array
type: object
TypesPostableResetPassword:
properties:
@@ -2245,11 +2209,6 @@ components:
type: string
role:
type: string
roles:
items:
type: string
nullable: true
type: array
status:
type: string
updatedAt:
@@ -3296,7 +3255,7 @@ paths:
schema:
properties:
data:
$ref: '#/components/schemas/GlobaltypesConfig'
$ref: '#/components/schemas/TypesGettableGlobalConfig'
status:
type: string
required:
@@ -3304,12 +3263,29 @@ paths:
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Get global config
tags:
- global
@@ -5838,9 +5814,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- ADMIN
- tokenizer:
- EDITOR
- ADMIN
summary: Get ingestion keys for workspace
tags:
- gateway
@@ -5888,9 +5864,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- ADMIN
- tokenizer:
- EDITOR
- ADMIN
summary: Create ingestion key for workspace
tags:
- gateway
@@ -5928,9 +5904,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- ADMIN
- tokenizer:
- EDITOR
- ADMIN
summary: Delete ingestion key for workspace
tags:
- gateway
@@ -5972,9 +5948,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- ADMIN
- tokenizer:
- EDITOR
- ADMIN
summary: Update ingestion key for workspace
tags:
- gateway
@@ -6029,9 +6005,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- ADMIN
- tokenizer:
- EDITOR
- ADMIN
summary: Create limit for the ingestion key
tags:
- gateway
@@ -6069,9 +6045,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- ADMIN
- tokenizer:
- EDITOR
- ADMIN
summary: Delete limit for the ingestion key
tags:
- gateway
@@ -6113,9 +6089,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- ADMIN
- tokenizer:
- EDITOR
- ADMIN
summary: Update limit for the ingestion key
tags:
- gateway
@@ -6173,9 +6149,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- ADMIN
- tokenizer:
- EDITOR
- ADMIN
summary: Search ingestion keys for workspace
tags:
- gateway

View File

@@ -198,10 +198,7 @@ func (provider *provider) Checkout(ctx context.Context, organizationID valuer.UU
response, err := provider.zeus.GetCheckoutURL(ctx, activeLicense.Key, body)
if err != nil {
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 nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to generate checkout session")
}
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil
@@ -220,7 +217,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, err
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to generate portal session")
}
return &licensetypes.GettableSubscription{RedirectURL: gjson.GetBytes(response, "url").String()}, nil

View File

@@ -10,8 +10,6 @@ import (
"strings"
"time"
"log/slog"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/user"
@@ -20,6 +18,7 @@ import (
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
"log/slog"
)
type CloudIntegrationConnectionParamsResponse struct {
@@ -170,7 +169,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser(
cloudIntegrationUserName := fmt.Sprintf("%s-integration", cloudProvider)
email := valuer.MustNewEmail(fmt.Sprintf("%s@signoz.io", cloudIntegrationUserName))
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, []string{authtypes.SigNozViewerRoleName}, valuer.MustNewUUID(orgId), types.UserStatusActive)
cloudIntegrationUser, err := types.NewUser(cloudIntegrationUserName, email, types.RoleViewer, valuer.MustNewUUID(orgId), types.UserStatusActive)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration user: %w", err))
}

View File

@@ -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" || pgErr.Code == "23503") {
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return errors.Wrapf(err, errors.TypeAlreadyExists, code, format, args...)
}

View File

@@ -193,16 +193,6 @@ module.exports = {
],
},
],
'no-restricted-syntax': [
'error',
{
selector:
// TODO: Make this generic on removal of redux
"CallExpression[callee.property.name='getState'][callee.object.name=/^use/]",
message:
'Avoid calling .getState() directly. Export a standalone action from the store instead.',
},
],
},
overrides: [
{
@@ -227,13 +217,5 @@ module.exports = {
'@typescript-eslint/no-unused-vars': 'warn',
},
},
{
// Store definition files are the only place .getState() is permitted —
// they are the canonical source for standalone action exports.
files: ['**/*Store.{ts,tsx}'],
rules: {
'no-restricted-syntax': 'off',
},
},
],
};

View File

@@ -24,8 +24,7 @@ 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/(?!ui$)([^/]+)$':
'<rootDir>/node_modules/@signozhq/$1/dist/$1.js',
'^@signozhq/([^/]+)$': '<rootDir>/node_modules/@signozhq/$1/dist/$1.js',
},
extensionsToTreatAsEsm: ['.ts'],
testMatch: ['<rootDir>/src/**/*?(*.)(test).(ts|js)?(x)'],

View File

@@ -67,7 +67,6 @@
"@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",

View File

@@ -776,45 +776,6 @@ export interface GatewaytypesUpdatableIngestionKeyLimitDTO {
tags?: string[] | null;
}
export interface GlobaltypesAPIKeyConfigDTO {
/**
* @type boolean
*/
enabled?: boolean;
}
export interface GlobaltypesConfigDTO {
/**
* @type string
*/
external_url?: string;
identN?: GlobaltypesIdentNConfigDTO;
/**
* @type string
*/
ingestion_url?: string;
}
export interface GlobaltypesIdentNConfigDTO {
apikey?: GlobaltypesAPIKeyConfigDTO;
impersonation?: GlobaltypesImpersonationConfigDTO;
tokenizer?: GlobaltypesTokenizerConfigDTO;
}
export interface GlobaltypesImpersonationConfigDTO {
/**
* @type boolean
*/
enabled?: boolean;
}
export interface GlobaltypesTokenizerConfigDTO {
/**
* @type boolean
*/
enabled?: boolean;
}
export interface MetricsexplorertypesListMetricDTO {
/**
* @type string
@@ -2441,6 +2402,17 @@ export interface TypesGettableAPIKeyDTO {
userId?: string;
}
export interface TypesGettableGlobalConfigDTO {
/**
* @type string
*/
external_url?: string;
/**
* @type string
*/
ingestion_url?: string;
}
export interface TypesIdentifiableDTO {
/**
* @type string
@@ -2478,11 +2450,6 @@ export interface TypesInviteDTO {
* @type string
*/
role?: string;
/**
* @type array
* @nullable true
*/
roles?: string[] | null;
/**
* @type string
*/
@@ -2602,11 +2569,6 @@ export interface TypesPostableInviteDTO {
* @type string
*/
role?: string;
/**
* @type array
* @nullable true
*/
roles?: string[] | null;
}
export interface TypesPostableResetPasswordDTO {
@@ -2715,11 +2677,6 @@ export interface TypesUserDTO {
* @type string
*/
role?: string;
/**
* @type array
* @nullable true
*/
roles?: string[] | null;
/**
* @type string
*/
@@ -3069,7 +3026,7 @@ export type GetResetPasswordToken200 = {
};
export type GetGlobalConfig200 = {
data: GlobaltypesConfigDTO;
data: TypesGettableGlobalConfigDTO;
/**
* @type string
*/

View File

@@ -81,8 +81,7 @@ 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);

View File

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

View File

@@ -1,14 +1,8 @@
function UnAuthorized({
width = 137,
height = 137,
}: {
height?: number;
width?: number;
}): JSX.Element {
function UnAuthorized(): JSX.Element {
return (
<svg
width={width}
height={height}
width="137"
height="137"
viewBox="0 0 137 137"
fill="none"
xmlns="http://www.w3.org/2000/svg"

View File

@@ -30,4 +30,3 @@ import '@signozhq/switch';
import '@signozhq/table';
import '@signozhq/toggle-group';
import '@signozhq/tooltip';
import '@signozhq/ui';

View File

@@ -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: ROLES;
userRole: UserRole;
}): JSX.Element | null {
const { open: isCmdKOpen } = useCmdK();
const noop = (): void => undefined;

View File

@@ -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?: ROLES[];
roles?: UserRole[];
perform: () => void;
};
@@ -33,7 +33,7 @@ function Shortcut({ label, keyHint }: ShortcutProps): JSX.Element {
interface ShiftOverlayProps {
visible: boolean;
actions: CmdAction[];
userRole: ROLES;
userRole: UserRole;
}
export function ShiftOverlay({

View File

@@ -11,7 +11,6 @@ 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';
@@ -29,6 +28,7 @@ type CmdAction = {
perform: () => void;
};
type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
export function CmdKPalette({
userRole,
}: {

View File

@@ -18,7 +18,8 @@ import {
TowerControl,
Workflow,
} from 'lucide-react';
import { ROLES } from 'types/roles';
export type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
export type CmdAction = {
id: string;
@@ -27,7 +28,7 @@ export type CmdAction = {
keywords?: string;
section?: string;
icon?: React.ReactNode;
roles?: ROLES[];
roles?: UserRole[];
perform: () => void;
};

View File

@@ -3,14 +3,16 @@ 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';
@@ -18,16 +20,15 @@ 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,
@@ -66,6 +67,13 @@ 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);
@@ -111,7 +119,6 @@ function StatusCodeBarCharts({
const navigateToExplorer = useNavigateToExplorer();
const { currentQuery } = useQueryBuilder();
const { timezone } = useTimezone();
const navigateToExplorerPages = useNavigateToExplorerPages();
const { notifications } = useNotifications();
@@ -127,6 +134,12 @@ function StatusCodeBarCharts({
[],
);
const { getCustomSeries } = useGetGraphCustomSeries({
isDarkMode,
drawStyle: 'bars',
colorMapping,
});
const widget = useMemo<Widgets>(
() =>
getStatusCodeBarChartWidgetData(domainName, {
@@ -180,36 +193,49 @@ function StatusCodeBarCharts({
],
);
const config = useMemo(() => {
const apiResponse =
currentWidgetInfoIndex === 0
? formattedEndPointStatusCodeBarChartsDataPayload
: formattedEndPointStatusCodeLatencyBarChartsDataPayload;
return prepareStatusCodeBarChartsConfig({
timezone,
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,
isDarkMode,
query: currentQuery,
graphClickHandler,
getCustomSeries,
onDragSelect,
onClick: graphClickHandler,
apiResponse,
minTimeScale: minTime,
maxTimeScale: maxTime,
yAxisUnit: statusCodeWidgetInfo[currentWidgetInfoIndex].yAxisUnit,
colorMapping,
});
}, [
currentQuery,
isDarkMode,
minTime,
maxTime,
graphClickHandler,
onDragSelect,
formattedEndPointStatusCodeBarChartsDataPayload,
formattedEndPointStatusCodeLatencyBarChartsDataPayload,
timezone,
currentWidgetInfoIndex,
colorMapping,
]);
currentQuery,
],
);
const renderCardContent = useCallback(
(query: UseQueryResult<SuccessResponse<any>, unknown>): JSX.Element => {
@@ -227,20 +253,11 @@ function StatusCodeBarCharts({
!query.isLoading && !query?.data?.payload?.data?.result?.length,
})}
>
<BarChart
config={config}
data={chartData}
width={dimensions.width}
height={dimensions.height}
timezone={timezone}
legendConfig={{
position: LegendPosition.BOTTOM,
}}
/>
<Uplot options={options as Options} data={chartData} />
</div>
);
},
[config, chartData, dimensions, timezone],
[options, chartData],
);
return (

View File

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

View File

@@ -21,15 +21,10 @@ interface MockQueryResult {
}
// Mocks
jest.mock(
'container/DashboardContainer/visualization/charts/BarChart/BarChart',
() => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(() => <div data-testid="bar-chart-mock" />),
}),
);
jest.mock('components/Uplot', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => <div data-testid="uplot-mock" />),
}));
jest.mock('components/CeleryTask/useGetGraphCustomSeries', () => ({
useGetGraphCustomSeries: (): { getCustomSeries: jest.Mock } => ({
@@ -75,24 +70,6 @@ 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({}),
}));
@@ -342,7 +319,7 @@ describe('StatusCodeBarCharts', () => {
mockData.payload,
'sum',
);
expect(screen.getByTestId('bar-chart-mock')).toBeInTheDocument();
expect(screen.getByTestId('uplot-mock')).toBeInTheDocument();
expect(screen.getByText('Number of calls')).toBeInTheDocument();
expect(screen.getByText('Latency')).toBeInTheDocument();
});

View File

@@ -5,7 +5,7 @@ import {
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import UPlotChart from 'lib/uPlotV2/components/UPlotChart/UPlotChart';
import UPlotChart from 'lib/uPlotV2/components/UPlotChart';
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
import noop from 'lodash-es/noop';

View File

@@ -123,7 +123,7 @@ export const prepareUPlotConfig = ({
drawStyle: hasSingleValidPoint ? DrawStyle.Points : DrawStyle.Line,
label: label,
colorMapping: widget.customLegendColors ?? {},
spanGaps: widget.spanGaps ?? true,
spanGaps: true,
lineStyle: widget.lineStyle || LineStyle.Solid,
lineInterpolation: widget.lineInterpolation || LineInterpolation.Spline,
showPoints:

View File

@@ -337,6 +337,31 @@
.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 {

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { Button } from '@signozhq/ui';
import { Button } from '@signozhq/button';
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}
testId="initiate_login"
data-testid="initiate_login"
className="login-submit-btn"
suffix={<ArrowRight />}
suffixIcon={<ArrowRight size={12} />}
>
Next
</Button>
@@ -406,10 +406,10 @@ function Login(): JSX.Element {
variant="solid"
type="submit"
color="primary"
testId="callback_authn_submit"
data-testid="callback_authn_submit"
data-attr="signup"
className="login-submit-btn"
suffix={<ArrowRight />}
suffixIcon={<ArrowRight size={12} />}
>
Sign in with SSO
</Button>
@@ -420,11 +420,11 @@ function Login(): JSX.Element {
disabled={!isSubmitButtonEnabled}
variant="solid"
color="primary"
testId="password_authn_submit"
data-testid="password_authn_submit"
type="submit"
data-attr="signup"
className="login-submit-btn"
suffix={<ArrowRight />}
suffixIcon={<ArrowRight size={12} />}
>
Sign in with Password
</Button>

View File

@@ -4,10 +4,6 @@
font-family: 'Space Mono';
padding-bottom: 48px;
.panel-type-select {
width: 100%;
}
.section-heading {
font-family: 'Space Mono';
color: var(--bg-vanilla-400);
@@ -30,6 +26,10 @@
letter-spacing: 0.48px;
}
.panel-type-select {
width: 100%;
}
.header {
display: flex;
padding: 14px 14px 14px 12px;
@@ -216,8 +216,7 @@
.lightMode {
.right-container {
background-color: var(--bg-vanilla-100);
.section-heading,
.section-heading-small {
.section-heading {
color: var(--bg-ink-400);
}
.header {

View File

@@ -7,10 +7,9 @@ import {
} from 'lib/uPlotV2/config/types';
import { Paintbrush } from 'lucide-react';
import DisconnectValuesSelector from '../../components/DisconnectValuesSelector/DisconnectValuesSelector';
import FillModeSelector from '../../components/FillModeSelector/FillModeSelector';
import LineInterpolationSelector from '../../components/LineInterpolationSelector/LineInterpolationSelector';
import LineStyleSelector from '../../components/LineStyleSelector/LineStyleSelector';
import { FillModeSelector } from '../../components/FillModeSelector/FillModeSelector';
import { LineInterpolationSelector } from '../../components/LineInterpolationSelector/LineInterpolationSelector';
import { LineStyleSelector } from '../../components/LineStyleSelector/LineStyleSelector';
import SettingsSection from '../../components/SettingsSection/SettingsSection';
interface ChartAppearanceSectionProps {
@@ -22,14 +21,10 @@ interface ChartAppearanceSectionProps {
setLineInterpolation: Dispatch<SetStateAction<LineInterpolation>>;
showPoints: boolean;
setShowPoints: Dispatch<SetStateAction<boolean>>;
spanGaps: boolean | number;
setSpanGaps: Dispatch<SetStateAction<boolean | number>>;
allowFillMode: boolean;
allowLineStyle: boolean;
allowLineInterpolation: boolean;
allowShowPoints: boolean;
allowSpanGaps: boolean;
stepInterval: number;
}
export default function ChartAppearanceSection({
@@ -41,14 +36,10 @@ export default function ChartAppearanceSection({
setLineInterpolation,
showPoints,
setShowPoints,
spanGaps,
setSpanGaps,
allowFillMode,
allowLineStyle,
allowLineInterpolation,
allowShowPoints,
allowSpanGaps,
stepInterval,
}: ChartAppearanceSectionProps): JSX.Element {
return (
<SettingsSection title="Chart Appearance" icon={<Paintbrush size={14} />}>
@@ -75,13 +66,6 @@ export default function ChartAppearanceSection({
<Switch size="small" checked={showPoints} onChange={setShowPoints} />
</section>
)}
{allowSpanGaps && (
<DisconnectValuesSelector
value={spanGaps}
minValue={stepInterval}
onChange={setSpanGaps}
/>
)}
</SettingsSection>
);
}

View File

@@ -178,8 +178,6 @@ describe('RightContainer - Alerts Section', () => {
setLineStyle: jest.fn(),
showPoints: false,
setShowPoints: jest.fn(),
spanGaps: false,
setSpanGaps: jest.fn(),
};
beforeEach(() => {

View File

@@ -1,38 +0,0 @@
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
import { Typography } from 'antd';
import { DisconnectedValuesMode } from 'lib/uPlotV2/config/types';
interface DisconnectValuesModeToggleProps {
value: DisconnectedValuesMode;
onChange: (value: DisconnectedValuesMode) => void;
}
export default function DisconnectValuesModeToggle({
value,
onChange,
}: DisconnectValuesModeToggleProps): JSX.Element {
return (
<ToggleGroup
type="single"
value={value}
size="lg"
onValueChange={(newValue): void => {
if (newValue) {
onChange(newValue as DisconnectedValuesMode);
}
}}
>
<ToggleGroupItem value={DisconnectedValuesMode.Never} aria-label="Never">
<Typography.Text className="section-heading-small">Never</Typography.Text>
</ToggleGroupItem>
<ToggleGroupItem
value={DisconnectedValuesMode.Threshold}
aria-label="Threshold"
>
<Typography.Text className="section-heading-small">
Threshold
</Typography.Text>
</ToggleGroupItem>
</ToggleGroup>
);
}

View File

@@ -1,21 +0,0 @@
.disconnect-values-selector {
.disconnect-values-input-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
.disconnect-values-threshold-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
.disconnect-values-threshold-input {
max-width: 160px;
height: auto;
.disconnect-values-threshold-prefix {
padding: 0 8px;
font-size: 20px;
}
}
}
}
}

View File

@@ -1,91 +0,0 @@
import { useEffect, useState } from 'react';
import { Typography } from 'antd';
import { DisconnectedValuesMode } from 'lib/uPlotV2/config/types';
import DisconnectValuesModeToggle from './DisconnectValuesModeToggle';
import DisconnectValuesThresholdInput from './DisconnectValuesThresholdInput';
import './DisconnectValuesSelector.styles.scss';
const DEFAULT_THRESHOLD_SECONDS = 60;
interface DisconnectValuesSelectorProps {
value: boolean | number;
minValue: number;
onChange: (value: boolean | number) => void;
}
export default function DisconnectValuesSelector({
value,
minValue,
onChange,
}: DisconnectValuesSelectorProps): JSX.Element {
const [mode, setMode] = useState<DisconnectedValuesMode>(() => {
if (typeof value === 'number') {
return DisconnectedValuesMode.Threshold;
}
return DisconnectedValuesMode.Never;
});
const [thresholdSeconds, setThresholdSeconds] = useState<number>(
typeof value === 'number' ? value : minValue ?? DEFAULT_THRESHOLD_SECONDS,
);
useEffect(() => {
if (typeof value === 'boolean') {
setMode(DisconnectedValuesMode.Never);
} else if (typeof value === 'number') {
setMode(DisconnectedValuesMode.Threshold);
setThresholdSeconds(value);
}
}, [value]);
useEffect(() => {
if (minValue !== undefined) {
setThresholdSeconds(minValue);
if (mode === DisconnectedValuesMode.Threshold) {
onChange(minValue);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [minValue]);
const handleModeChange = (newMode: DisconnectedValuesMode): void => {
setMode(newMode);
switch (newMode) {
case DisconnectedValuesMode.Never:
onChange(true);
break;
case DisconnectedValuesMode.Threshold:
onChange(thresholdSeconds);
break;
}
};
const handleThresholdChange = (seconds: number): void => {
setThresholdSeconds(seconds);
onChange(seconds);
};
return (
<section className="disconnect-values-selector control-container">
<Typography.Text className="section-heading">
Disconnect values
</Typography.Text>
<div className="disconnect-values-input-wrapper">
<DisconnectValuesModeToggle value={mode} onChange={handleModeChange} />
{mode === DisconnectedValuesMode.Threshold && (
<section className="control-container">
<Typography.Text className="section-heading">
Threshold Value
</Typography.Text>
<DisconnectValuesThresholdInput
value={thresholdSeconds}
minValue={minValue}
onChange={handleThresholdChange}
/>
</section>
)}
</div>
</section>
);
}

View File

@@ -1,92 +0,0 @@
import { ChangeEvent, useEffect, useState } from 'react';
import { rangeUtil } from '@grafana/data';
import { Callout, Input } from '@signozhq/ui';
interface DisconnectValuesThresholdInputProps {
value: number;
onChange: (seconds: number) => void;
minValue: number;
}
export default function DisconnectValuesThresholdInput({
value,
onChange,
minValue,
}: DisconnectValuesThresholdInputProps): JSX.Element {
const [inputValue, setInputValue] = useState<string>(
rangeUtil.secondsToHms(value),
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setInputValue(rangeUtil.secondsToHms(value));
setError(null);
}, [value]);
const updateValue = (txt: string): void => {
if (!txt) {
return;
}
try {
let seconds: number;
if (rangeUtil.isValidTimeSpan(txt)) {
seconds = rangeUtil.intervalToSeconds(txt);
} else {
const parsed = Number(txt);
if (Number.isNaN(parsed) || parsed <= 0) {
setError('Enter a valid duration (e.g. 1h, 10m, 1d)');
return;
}
seconds = parsed;
}
if (minValue !== undefined && seconds < minValue) {
setError(`Threshold should be > ${rangeUtil.secondsToHms(minValue)}`);
return;
}
setError(null);
setInputValue(txt);
onChange(seconds);
} catch {
setError('Invalid threshold value');
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
if (e.key === 'Enter') {
updateValue(e.currentTarget.value);
}
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>): void => {
updateValue(e.currentTarget.value);
};
const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
setInputValue(e.currentTarget.value);
if (error) {
setError(null);
}
};
return (
<div className="disconnect-values-threshold-wrapper">
<Input
name="disconnect-values-threshold"
type="text"
className="disconnect-values-threshold-input"
prefix={<span className="disconnect-values-threshold-prefix">&gt;</span>}
value={inputValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
autoFocus={true}
aria-invalid={!!error}
aria-describedby={error ? 'threshold-error' : undefined}
/>
{error && (
<Callout type="error" size="small" showIcon>
{error}
</Callout>
)}
</div>
);
}

View File

@@ -9,7 +9,7 @@ interface FillModeSelectorProps {
onChange: (value: FillMode) => void;
}
export default function FillModeSelector({
export function FillModeSelector({
value,
onChange,
}: FillModeSelectorProps): JSX.Element {

View File

@@ -9,7 +9,7 @@ interface LineInterpolationSelectorProps {
onChange: (value: LineInterpolation) => void;
}
export default function LineInterpolationSelector({
export function LineInterpolationSelector({
value,
onChange,
}: LineInterpolationSelectorProps): JSX.Element {

View File

@@ -9,7 +9,7 @@ interface LineStyleSelectorProps {
onChange: (value: LineStyle) => void;
}
export default function LineStyleSelector({
export function LineStyleSelector({
value,
onChange,
}: LineStyleSelectorProps): JSX.Element {

View File

@@ -262,17 +262,3 @@ export const panelTypeVsShowPoints: {
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;
export const panelTypeVsSpanGaps: {
[key in PANEL_TYPES]: boolean;
} = {
[PANEL_TYPES.TIME_SERIES]: true,
[PANEL_TYPES.VALUE]: false,
[PANEL_TYPES.TABLE]: false,
[PANEL_TYPES.LIST]: false,
[PANEL_TYPES.PIE]: false,
[PANEL_TYPES.BAR]: false,
[PANEL_TYPES.HISTOGRAM]: false,
[PANEL_TYPES.TRACE]: false,
[PANEL_TYPES.EMPTY_WIDGET]: false,
} as const;

View File

@@ -1,7 +1,6 @@
import { Dispatch, SetStateAction, useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { Typography } from 'antd';
import { ExecStats } from 'api/v5/v5';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { PANEL_TYPES, PanelDisplay } from 'constants/queryBuilder';
import { PanelTypesWithData } from 'container/DashboardContainer/PanelTypeSelectionModal/menuItems';
@@ -12,7 +11,6 @@ import {
LineInterpolation,
LineStyle,
} from 'lib/uPlotV2/config/types';
import get from 'lodash-es/get';
import { SuccessResponse } from 'types/api';
import {
ColumnUnit,
@@ -38,7 +36,6 @@ import {
panelTypeVsPanelTimePreferences,
panelTypeVsShowPoints,
panelTypeVsSoftMinMax,
panelTypeVsSpanGaps,
panelTypeVsStackingChartPreferences,
panelTypeVsThreshold,
panelTypeVsYAxisUnit,
@@ -71,8 +68,6 @@ function RightContainer({
setLineStyle,
showPoints,
setShowPoints,
spanGaps,
setSpanGaps,
bucketCount,
bucketWidth,
stackedBarChart,
@@ -143,7 +138,6 @@ function RightContainer({
const allowLineStyle = panelTypeVsLineStyle[selectedGraph];
const allowFillMode = panelTypeVsFillMode[selectedGraph];
const allowShowPoints = panelTypeVsShowPoints[selectedGraph];
const allowSpanGaps = panelTypeVsSpanGaps[selectedGraph];
const decimapPrecisionOptions = useMemo(
() => [
@@ -182,26 +176,10 @@ function RightContainer({
(allowFillMode ||
allowLineStyle ||
allowLineInterpolation ||
allowShowPoints ||
allowSpanGaps),
[
allowFillMode,
allowLineStyle,
allowLineInterpolation,
allowShowPoints,
allowSpanGaps,
],
allowShowPoints),
[allowFillMode, allowLineStyle, allowLineInterpolation, allowShowPoints],
);
const stepInterval = useMemo(() => {
const stepIntervals: ExecStats['stepIntervals'] = get(
queryResponse,
'data.payload.data.newResult.meta.stepIntervals',
{},
);
return Math.min(...Object.values(stepIntervals));
}, [queryResponse]);
return (
<div className="right-container">
<section className="header">
@@ -259,14 +237,10 @@ function RightContainer({
setLineInterpolation={setLineInterpolation}
showPoints={showPoints}
setShowPoints={setShowPoints}
spanGaps={spanGaps}
setSpanGaps={setSpanGaps}
allowFillMode={allowFillMode}
allowLineStyle={allowLineStyle}
allowLineInterpolation={allowLineInterpolation}
allowShowPoints={allowShowPoints}
allowSpanGaps={allowSpanGaps}
stepInterval={stepInterval}
/>
)}
@@ -390,8 +364,6 @@ export interface RightContainerProps {
setLineStyle: Dispatch<SetStateAction<LineStyle>>;
showPoints: boolean;
setShowPoints: Dispatch<SetStateAction<boolean>>;
spanGaps: boolean | number;
setSpanGaps: Dispatch<SetStateAction<boolean | number>>;
}
RightContainer.defaultProps = {

View File

@@ -14,6 +14,7 @@ import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import i18n from 'ReactI18';
import {
fireEvent,
getByText as getByTextUtil,
render,
userEvent,
@@ -341,8 +342,9 @@ describe('Stacking bar in new panel', () => {
const STACKING_STATE_ATTR = 'data-stacking-state';
describe('when switching to BAR panel type', () => {
jest.setTimeout(10000);
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
// Mock useSearchParams to return the expected values
@@ -352,15 +354,7 @@ describe('when switching to BAR panel type', () => {
]);
});
afterEach(() => {
jest.useRealTimers();
});
it('should preserve saved stacking value of true', async () => {
const user = userEvent.setup({
advanceTimers: jest.advanceTimersByTime.bind(jest),
});
const { getByTestId, getByText, container } = render(
<DashboardProvider dashboardId="">
<NewWidget
@@ -376,7 +370,7 @@ describe('when switching to BAR panel type', () => {
'true',
);
await user.click(getByText('Bar')); // Panel Type Selected
await userEvent.click(getByText('Bar')); // Panel Type Selected
// find dropdown with - .ant-select-dropdown
const panelDropdown = document.querySelector(
@@ -386,7 +380,7 @@ describe('when switching to BAR panel type', () => {
// Select TimeSeries from dropdown
const option = within(panelDropdown).getByText('Time Series');
await user.click(option);
fireEvent.click(option);
expect(getByTestId('panel-change-select')).toHaveAttribute(
STACKING_STATE_ATTR,
@@ -401,7 +395,7 @@ describe('when switching to BAR panel type', () => {
expect(panelTypeDropdown2).toBeInTheDocument();
expect(getByTextUtil(panelTypeDropdown2, 'Time Series')).toBeInTheDocument();
await user.click(getByTextUtil(panelTypeDropdown2, 'Time Series'));
fireEvent.click(getByTextUtil(panelTypeDropdown2, 'Time Series'));
// find dropdown with - .ant-select-dropdown
const panelDropdown2 = document.querySelector(
@@ -409,7 +403,7 @@ describe('when switching to BAR panel type', () => {
) as HTMLElement;
// // Select BAR from dropdown
const BarOption = within(panelDropdown2).getByText('Bar');
await user.click(BarOption);
fireEvent.click(BarOption);
// Stack series should be true
checkStackSeriesState(container, true);

View File

@@ -220,9 +220,6 @@ function NewWidget({
const [showPoints, setShowPoints] = useState<boolean>(
selectedWidget?.showPoints ?? false,
);
const [spanGaps, setSpanGaps] = useState<boolean | number>(
selectedWidget?.spanGaps ?? true,
);
const [customLegendColors, setCustomLegendColors] = useState<
Record<string, string>
>(selectedWidget?.customLegendColors || {});
@@ -292,7 +289,6 @@ function NewWidget({
fillMode,
lineStyle,
showPoints,
spanGaps,
columnUnits,
bucketCount,
stackedBarChart,
@@ -332,7 +328,6 @@ function NewWidget({
fillMode,
lineStyle,
showPoints,
spanGaps,
customLegendColors,
contextLinks,
selectedWidget.columnWidths,
@@ -546,7 +541,6 @@ function NewWidget({
softMin: selectedWidget?.softMin || 0,
softMax: selectedWidget?.softMax || 0,
fillSpans: selectedWidget?.fillSpans,
spanGaps: selectedWidget?.spanGaps ?? true,
isLogScale: selectedWidget?.isLogScale || false,
bucketWidth: selectedWidget?.bucketWidth || 0,
bucketCount: selectedWidget?.bucketCount || 0,
@@ -578,7 +572,6 @@ function NewWidget({
softMin: selectedWidget?.softMin || 0,
softMax: selectedWidget?.softMax || 0,
fillSpans: selectedWidget?.fillSpans,
spanGaps: selectedWidget?.spanGaps ?? true,
isLogScale: selectedWidget?.isLogScale || false,
bucketWidth: selectedWidget?.bucketWidth || 0,
bucketCount: selectedWidget?.bucketCount || 0,
@@ -896,8 +889,6 @@ function NewWidget({
setLineStyle={setLineStyle}
showPoints={showPoints}
setShowPoints={setShowPoints}
spanGaps={spanGaps}
setSpanGaps={setSpanGaps}
opacity={opacity}
yAxisUnit={yAxisUnit}
columnUnits={columnUnits}

View File

@@ -1,2 +0,0 @@
export const SINGLE_FLIGHT_WAIT_TIME_MS = 50;
export const AUTHZ_CACHE_TIME = 20_000;

View File

@@ -1,18 +0,0 @@
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',
);

View File

@@ -14,7 +14,7 @@ type ResourceTypeMap = {
type RelationName = keyof RelationsByType;
export type ResourcesForRelation<R extends RelationName> = Extract<
type ResourcesForRelation<R extends RelationName> = Extract<
Resource,
{ type: RelationsByType[R][number] }
>['name'];
@@ -50,26 +50,8 @@ 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;
};

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useMemo } from 'react';
import { useQueries } from 'react-query';
import { authzCheck } from 'api/generated/services/authz';
import type {
@@ -6,13 +6,7 @@ import type {
AuthtypesTransactionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { AUTHZ_CACHE_TIME, SINGLE_FLIGHT_WAIT_TIME_MS } from './constants';
import {
AuthZCheckResponse,
BrandedPermission,
UseAuthZOptions,
UseAuthZResult,
} from './types';
import { AuthZCheckResponse, BrandedPermission, UseAuthZResult } from './types';
import {
gettableTransactionToPermission,
permissionToTransactionDto,
@@ -20,6 +14,8 @@ 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,
@@ -74,12 +70,7 @@ async function fetchManyPermissions(
}, {} as AuthZCheckResponse);
}
export function useAuthZ(
permissions: BrandedPermission[],
options?: UseAuthZOptions,
): UseAuthZResult {
const { enabled } = options ?? { enabled: true };
export function useAuthZ(permissions: BrandedPermission[]): UseAuthZResult {
const queryResults = useQueries(
permissions.map((permission) => {
return {
@@ -89,7 +80,6 @@ export function useAuthZ(
refetchIntervalInBackground: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
enabled,
queryFn: async (): Promise<AuthZCheckResponse> => {
const response = await dispatchPermission(permission);
@@ -106,10 +96,6 @@ export function useAuthZ(
const isLoading = useMemo(() => queryResults.some((q) => q.isLoading), [
queryResults,
]);
const isFetching = useMemo(() => queryResults.some((q) => q.isFetching), [
queryResults,
]);
const error = useMemo(
() =>
!isLoading
@@ -135,17 +121,9 @@ export function useAuthZ(
}, {} as AuthZCheckResponse);
}, [isLoading, error, queryResults]);
const refetchPermissions = useCallback(() => {
for (const query of queryResults) {
query.refetch();
}
}, [queryResults]);
return {
isLoading,
isFetching,
error,
permissions: data ?? null,
refetchPermissions,
};
}

View File

@@ -3,9 +3,9 @@ import permissionsType from './permissions.type';
import {
AuthZObject,
AuthZRelation,
AuthZResource,
BrandedPermission,
ResourceName,
ResourcesForRelation,
ResourceType,
} from './types';
@@ -19,10 +19,11 @@ export function buildPermission<R extends AuthZRelation>(
return `${relation}${PermissionSeparator}${object}` as BrandedPermission;
}
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 buildObjectString(
resource: AuthZResource,
objectId: string,
): `${AuthZResource}${typeof ObjectSeparator}${string}` {
return `${resource}${ObjectSeparator}${objectId}` as const;
}
export function parsePermission(

View File

@@ -6,9 +6,8 @@ import { LineChart } from 'lucide-react';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import uPlot, { AlignedData, Options } from 'uplot';
import { usePlotContext } from '../../context/PlotContext';
import { UPlotChartProps } from '../types';
import { prepareAlignedData } from './utils';
import { usePlotContext } from '../context/PlotContext';
import { UPlotChartProps } from './types';
/**
* Check if dimensions have changed
@@ -84,11 +83,8 @@ export default function UPlotChart({
...configOptions,
} as Options;
// prepare final AlignedData
const preparedData = prepareAlignedData({ data, config });
// Create new plot instance
const plot = new uPlot(plotConfig, preparedData, containerRef.current);
const plot = new uPlot(plotConfig, data as AlignedData, containerRef.current);
if (plotRef) {
plotRef(plot);
@@ -166,8 +162,7 @@ export default function UPlotChart({
}
// Update data if only data changed
else if (!sameData(prevProps, currentProps) && plotInstanceRef.current) {
const preparedData = prepareAlignedData({ data, config });
plotInstanceRef.current.setData(preparedData as AlignedData);
plotInstanceRef.current.setData(data as AlignedData);
}
prevPropsRef.current = currentProps;

View File

@@ -1,16 +0,0 @@
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { applySpanGapsToAlignedData } from 'lib/uPlotV2/utils/dataUtils';
import { AlignedData } from 'uplot';
export function prepareAlignedData({
data,
config,
}: {
data: AlignedData;
config: UPlotConfigBuilder;
}): AlignedData {
const seriesSpanGaps = config.getSeriesSpanGapsOptions();
return seriesSpanGaps.length > 0
? applySpanGapsToAlignedData(data as AlignedData, seriesSpanGaps)
: (data as AlignedData);
}

View File

@@ -4,7 +4,7 @@ import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { AlignedData } from 'uplot';
import { PlotContextProvider } from '../../context/PlotContext';
import UPlotChart from '../UPlotChart/UPlotChart';
import UPlotChart from '../UPlotChart';
// ---------------------------------------------------------------------------
// Mocks
@@ -86,7 +86,6 @@ const createMockConfig = (): UPlotConfigBuilder => {
}),
getId: jest.fn().mockReturnValue(undefined),
getShouldSaveSelectionPreference: jest.fn().mockReturnValue(false),
getSeriesSpanGapsOptions: jest.fn().mockReturnValue([]),
} as unknown) as UPlotConfigBuilder;
};
@@ -329,78 +328,6 @@ describe('UPlotChart', () => {
});
});
describe('spanGaps data transformation', () => {
it('inserts null break points before passing data to uPlot when a gap exceeds the numeric threshold', () => {
const config = createMockConfig();
// gap 0→100 = 100 > threshold 50 → null inserted at midpoint x=50
(config.getSeriesSpanGapsOptions as jest.Mock).mockReturnValue([
{ spanGaps: 50 },
]);
const data: AlignedData = [
[0, 100],
[1, 2],
];
render(<UPlotChart config={config} data={data} width={600} height={400} />, {
wrapper: Wrapper,
});
const [, receivedData] = mockUPlotConstructor.mock.calls[0];
expect(receivedData[0]).toEqual([0, 50, 100]);
expect(receivedData[1]).toEqual([1, null, 2]);
});
it('passes data through unchanged when no gap exceeds the numeric threshold', () => {
const config = createMockConfig();
// all gaps = 10, threshold = 50 → no insertions, same reference returned
(config.getSeriesSpanGapsOptions as jest.Mock).mockReturnValue([
{ spanGaps: 50 },
]);
const data: AlignedData = [
[0, 10, 20],
[1, 2, 3],
];
render(<UPlotChart config={config} data={data} width={600} height={400} />, {
wrapper: Wrapper,
});
const [, receivedData] = mockUPlotConstructor.mock.calls[0];
expect(receivedData).toBe(data);
});
it('transforms data passed to setData when data updates and a new gap exceeds the threshold', () => {
const config = createMockConfig();
(config.getSeriesSpanGapsOptions as jest.Mock).mockReturnValue([
{ spanGaps: 50 },
]);
// initial render: gap 10 < 50, no transformation
const initialData: AlignedData = [
[0, 10],
[1, 2],
];
// updated data: gap 100 > 50 → null inserted at midpoint x=50
const newData: AlignedData = [
[0, 100],
[3, 4],
];
const { rerender } = render(
<UPlotChart config={config} data={initialData} width={600} height={400} />,
{ wrapper: Wrapper },
);
rerender(
<UPlotChart config={config} data={newData} width={600} height={400} />,
);
const receivedData = instances[0].setData.mock.calls[0][0];
expect(receivedData[0]).toEqual([0, 50, 100]);
expect(receivedData[1]).toEqual([3, null, 4]);
});
});
describe('prop updates', () => {
it('calls setData without recreating the plot when only data changes', () => {
const config = createMockConfig();

View File

@@ -14,7 +14,6 @@ import {
STEP_INTERVAL_MULTIPLIER,
} from '../constants';
import { calculateWidthBasedOnStepInterval } from '../utils';
import { SeriesSpanGapsOption } from '../utils/dataUtils';
import {
ConfigBuilder,
ConfigBuilderProps,
@@ -162,13 +161,6 @@ export class UPlotConfigBuilder extends ConfigBuilder<
this.series.push(new UPlotSeriesBuilder(props));
}
getSeriesSpanGapsOptions(): SeriesSpanGapsOption[] {
return this.series.map((s) => {
const { spanGaps } = s.props;
return { spanGaps };
});
}
/**
* Add a hook for extensibility
*/

View File

@@ -212,12 +212,7 @@ export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
return {
scale: scaleKey,
label,
// When spanGaps is numeric, we always disable uPlot's internal
// spanGaps behavior and rely on data-prep to implement the
// threshold-based null handling. When spanGaps is boolean we
// map it directly. When spanGaps is undefined we fall back to
// the default of true.
spanGaps: typeof spanGaps === 'number' ? false : spanGaps ?? true,
spanGaps: typeof spanGaps === 'boolean' ? spanGaps : false,
value: (): string => '',
pxAlign: true,
show,

View File

@@ -40,37 +40,6 @@ describe('UPlotSeriesBuilder', () => {
expect(typeof config.value).toBe('function');
});
it('maps boolean spanGaps directly to uPlot spanGaps', () => {
const trueBuilder = new UPlotSeriesBuilder(
createBaseProps({
spanGaps: true,
}),
);
const falseBuilder = new UPlotSeriesBuilder(
createBaseProps({
spanGaps: false,
}),
);
const trueConfig = trueBuilder.getConfig();
const falseConfig = falseBuilder.getConfig();
expect(trueConfig.spanGaps).toBe(true);
expect(falseConfig.spanGaps).toBe(false);
});
it('disables uPlot spanGaps when spanGaps is a number', () => {
const builder = new UPlotSeriesBuilder(
createBaseProps({
spanGaps: 10000,
}),
);
const config = builder.getConfig();
expect(config.spanGaps).toBe(false);
});
it('uses explicit lineColor when provided, regardless of mapping', () => {
const builder = new UPlotSeriesBuilder(
createBaseProps({

View File

@@ -99,11 +99,6 @@ export interface ScaleProps {
distribution?: DistributionType;
}
export enum DisconnectedValuesMode {
Never = 'never',
Threshold = 'threshold',
}
/**
* Props for configuring a series
*/
@@ -180,16 +175,7 @@ export interface SeriesProps extends LineConfig, PointsConfig, BarConfig {
pointsFilter?: Series.Points.Filter;
pointsBuilder?: Series.Points.Show;
show?: boolean;
/**
* Controls how nulls are treated for this series.
*
* - boolean: mapped directly to uPlot's spanGaps behavior
* - number: interpreted as an X-axis threshold (same unit as ref values),
* where gaps smaller than this threshold are spanned by
* converting short null runs to undefined during data prep
* while uPlot's internal spanGaps is kept disabled.
*/
spanGaps?: boolean | number;
spanGaps?: boolean;
fillColor?: string;
fillMode?: FillMode;
isDarkMode?: boolean;

View File

@@ -1,12 +1,4 @@
import uPlot from 'uplot';
import {
applySpanGapsToAlignedData,
insertLargeGapNullsIntoAlignedData,
isInvalidPlotValue,
normalizePlotValue,
SeriesSpanGapsOption,
} from '../dataUtils';
import { isInvalidPlotValue, normalizePlotValue } from '../dataUtils';
describe('dataUtils', () => {
describe('isInvalidPlotValue', () => {
@@ -67,217 +59,4 @@ describe('dataUtils', () => {
expect(normalizePlotValue(42.5)).toBe(42.5);
});
});
describe('insertLargeGapNullsIntoAlignedData', () => {
it('returns original data unchanged when no gap exceeds the threshold', () => {
// all gaps = 10, threshold = 25 → no insertions
const data: uPlot.AlignedData = [
[0, 10, 20, 30],
[1, 2, 3, 4],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 25 }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result).toBe(data);
});
it('does not insert when the gap equals the threshold exactly', () => {
// gap = 50, threshold = 50 → condition is gap > threshold, not >=
const data: uPlot.AlignedData = [
[0, 50],
[1, 2],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result).toBe(data);
});
it('inserts a null at the midpoint when a single gap exceeds the threshold', () => {
// gap 0→100 = 100 > 50 → insert null at x=50
const data: uPlot.AlignedData = [
[0, 100],
[1, 2],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result[0]).toEqual([0, 50, 100]);
expect(result[1]).toEqual([1, null, 2]);
});
it('inserts nulls at every gap that exceeds the threshold', () => {
// gaps: 0→100=100, 100→110=10, 110→210=100; threshold=50
// → insert at 0→100 and 110→210
const data: uPlot.AlignedData = [
[0, 100, 110, 210],
[1, 2, 3, 4],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result[0]).toEqual([0, 50, 100, 110, 160, 210]);
expect(result[1]).toEqual([1, null, 2, 3, null, 4]);
});
it('inserts null for all series at a gap triggered by any one series', () => {
// series 0: threshold=50, gap=100 → triggers insertion
// series 1: threshold=200, gap=100 → would not trigger alone
// result: both series get null at the inserted x because the x-axis is shared
const data: uPlot.AlignedData = [
[0, 100],
[1, 2],
[3, 4],
];
const options: SeriesSpanGapsOption[] = [
{ spanGaps: 50 },
{ spanGaps: 200 },
];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result[0]).toEqual([0, 50, 100]);
expect(result[1]).toEqual([1, null, 2]);
expect(result[2]).toEqual([3, null, 4]);
});
it('ignores boolean spanGaps options (only numeric values trigger insertion)', () => {
const data: uPlot.AlignedData = [
[0, 100],
[1, 2],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: true }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result).toBe(data);
});
it('returns original data when series options array is empty', () => {
const data: uPlot.AlignedData = [
[0, 100],
[1, 2],
];
const result = insertLargeGapNullsIntoAlignedData(data, []);
expect(result).toBe(data);
});
it('returns original data when there is only one x point', () => {
const data: uPlot.AlignedData = [[0], [1]];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 10 }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result).toBe(data);
});
it('preserves existing null values in the series alongside inserted ones', () => {
// original series already has a null; gap 0→100 also triggers insertion
const data: uPlot.AlignedData = [
[0, 100, 110],
[1, null, 2],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }];
const result = insertLargeGapNullsIntoAlignedData(data, options);
expect(result[0]).toEqual([0, 50, 100, 110]);
expect(result[1]).toEqual([1, null, null, 2]);
});
});
describe('applySpanGapsToAlignedData', () => {
const xs: uPlot.AlignedData[0] = [0, 10, 20, 30];
it('returns original data when there are no series', () => {
const data: uPlot.AlignedData = [xs];
const result = applySpanGapsToAlignedData(data, []);
expect(result).toBe(data);
});
it('leaves data unchanged when spanGaps is undefined', () => {
const ys = [1, null, 2, null];
const data: uPlot.AlignedData = [xs, ys];
const options: SeriesSpanGapsOption[] = [{}];
const result = applySpanGapsToAlignedData(data, options);
expect(result[1]).toEqual(ys);
});
it('converts nulls to undefined when spanGaps is true', () => {
const ys = [1, null, 2, null];
const data: uPlot.AlignedData = [xs, ys];
const options: SeriesSpanGapsOption[] = [{ spanGaps: true }];
const result = applySpanGapsToAlignedData(data, options);
expect(result[1]).toEqual([1, undefined, 2, undefined]);
});
it('leaves data unchanged when spanGaps is false', () => {
const ys = [1, null, 2, null];
const data: uPlot.AlignedData = [xs, ys];
const options: SeriesSpanGapsOption[] = [{ spanGaps: false }];
const result = applySpanGapsToAlignedData(data, options);
expect(result[1]).toEqual(ys);
});
it('inserts a null break point when a gap exceeds the numeric threshold', () => {
// gap 0→100 = 100 > 50 → null inserted at midpoint x=50
const data: uPlot.AlignedData = [
[0, 100, 110],
[1, 2, 3],
];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 50 }];
const result = applySpanGapsToAlignedData(data, options);
expect(result[0]).toEqual([0, 50, 100, 110]);
expect(result[1]).toEqual([1, null, 2, 3]);
});
it('returns original data when no gap exceeds the numeric threshold', () => {
// all gaps = 10, threshold = 25 → no insertions
const data: uPlot.AlignedData = [xs, [1, 2, 3, 4]];
const options: SeriesSpanGapsOption[] = [{ spanGaps: 25 }];
const result = applySpanGapsToAlignedData(data, options);
expect(result).toBe(data);
});
it('applies both numeric gap insertion and boolean null-to-undefined in one pass', () => {
// series 0: spanGaps: 50 → gap 0→100 triggers a null break at midpoint x=50
// series 1: spanGaps: true → the inserted null at x=50 becomes undefined,
// so the line spans over it rather than breaking
const data: uPlot.AlignedData = [
[0, 100],
[1, 2],
[3, 4],
];
const options: SeriesSpanGapsOption[] = [
{ spanGaps: 50 },
{ spanGaps: true },
];
const result = applySpanGapsToAlignedData(data, options);
// x-axis extended with the inserted midpoint
expect(result[0]).toEqual([0, 50, 100]);
// series 0: null at midpoint breaks the line
expect(result[1]).toEqual([1, null, 2]);
// series 1: null at midpoint converted to undefined → line spans over it
expect(result[2]).toEqual([3, undefined, 4]);
});
});
});

View File

@@ -24,10 +24,10 @@ export function isInvalidPlotValue(value: unknown): boolean {
}
// Try to parse the string as a number
const parsedNumber = parseFloat(value);
const numValue = parseFloat(value);
// If parsing failed or resulted in a non-finite number, it's invalid
if (Number.isNaN(parsedNumber) || !Number.isFinite(parsedNumber)) {
if (Number.isNaN(numValue) || !Number.isFinite(numValue)) {
return true;
}
}
@@ -51,178 +51,3 @@ export function normalizePlotValue(
// Already a valid number
return value as number;
}
export interface SeriesSpanGapsOption {
spanGaps?: boolean | number;
}
// Internal type alias: a series value array that may contain nulls/undefineds.
// uPlot uses null to draw a visible gap and undefined to represent "no sample"
// (the line continues across undefined points but breaks at null ones).
type SeriesArray = Array<number | null | undefined>;
/**
* Returns true if the given gap size exceeds the numeric spanGaps threshold
* of at least one series. Used to decide whether to insert a null break point.
*/
function gapExceedsThreshold(
gapSize: number,
seriesOptions: SeriesSpanGapsOption[],
): boolean {
return seriesOptions.some(
({ spanGaps }) =>
typeof spanGaps === 'number' && spanGaps > 0 && gapSize > spanGaps,
);
}
/**
* For each series with a numeric spanGaps threshold, insert a null data point
* between consecutive x timestamps whose gap exceeds the threshold.
*
* Why: uPlot draws a continuous line between all non-null points. When the
* time gap between two consecutive samples is larger than the configured
* spanGaps value, we inject a synthetic null at the midpoint so uPlot renders
* a visible break instead of a misleading straight line across the gap.
*
* Because uPlot's AlignedData shares a single x-axis across all series, a null
* is inserted for every series at each position where any series needs a break.
*
* Two-pass approach for performance:
* Pass 1 — count how many nulls will be inserted (no allocations).
* Pass 2 — fill pre-allocated output arrays by index (no push/reallocation).
*/
export function insertLargeGapNullsIntoAlignedData(
data: uPlot.AlignedData,
seriesOptions: SeriesSpanGapsOption[],
): uPlot.AlignedData {
const [xValues, ...seriesValues] = data;
if (
!Array.isArray(xValues) ||
xValues.length < 2 ||
seriesValues.length === 0
) {
return data;
}
const timestamps = xValues as number[];
const totalPoints = timestamps.length;
// Pass 1: count insertions needed so we know the exact output length.
// This lets us pre-allocate arrays rather than growing them dynamically.
let insertionCount = 0;
for (let i = 0; i < totalPoints - 1; i += 1) {
if (gapExceedsThreshold(timestamps[i + 1] - timestamps[i], seriesOptions)) {
insertionCount += 1;
}
}
// No gaps exceed any threshold — return the original data unchanged.
if (insertionCount === 0) {
return data;
}
// Pass 2: build output arrays of exact size and fill them.
// `writeIndex` is the write cursor into the output arrays.
const outputLen = totalPoints + insertionCount;
const newX = new Array<number>(outputLen);
const newSeries: SeriesArray[] = seriesValues.map(
() => new Array<number | null | undefined>(outputLen),
);
let writeIndex = 0;
for (let i = 0; i < totalPoints; i += 1) {
// Copy the real data point at position i
newX[writeIndex] = timestamps[i];
for (
let seriesIndex = 0;
seriesIndex < seriesValues.length;
seriesIndex += 1
) {
newSeries[seriesIndex][writeIndex] = (seriesValues[
seriesIndex
] as SeriesArray)[i];
}
writeIndex += 1;
// If the gap to the next x timestamp exceeds the threshold, insert a
// synthetic null at the midpoint. The midpoint x is placed halfway
// between timestamps[i] and timestamps[i+1] (minimum 1 unit past timestamps[i] to stay unique).
if (
i < totalPoints - 1 &&
gapExceedsThreshold(timestamps[i + 1] - timestamps[i], seriesOptions)
) {
newX[writeIndex] =
timestamps[i] +
Math.max(1, Math.floor((timestamps[i + 1] - timestamps[i]) / 2));
for (
let seriesIndex = 0;
seriesIndex < seriesValues.length;
seriesIndex += 1
) {
newSeries[seriesIndex][writeIndex] = null; // null tells uPlot to break the line here
}
writeIndex += 1;
}
}
return [newX, ...newSeries] as uPlot.AlignedData;
}
/**
* Apply per-series spanGaps (boolean | number) handling to an aligned dataset.
*
* spanGaps controls how uPlot handles gaps in a series:
* - boolean true → convert null → undefined so uPlot spans over every gap
* (draws a continuous line, skipping missing samples)
* - boolean false → no change; nulls render as visible breaks (default)
* - number → insert a null break point between any two consecutive
* timestamps whose difference exceeds the threshold;
* gaps smaller than the threshold are left as-is
*
* The input data is expected to be of the form:
* [xValues, series1Values, series2Values, ...]
*/
export function applySpanGapsToAlignedData(
data: uPlot.AlignedData,
seriesOptions: SeriesSpanGapsOption[],
): uPlot.AlignedData {
const [xValues, ...seriesValues] = data;
if (!Array.isArray(xValues) || seriesValues.length === 0) {
return data;
}
// Numeric spanGaps: operates on the whole dataset at once because inserting
// null break points requires modifying the shared x-axis.
const hasNumericSpanGaps = seriesOptions.some(
({ spanGaps }) => typeof spanGaps === 'number',
);
const gapProcessed = hasNumericSpanGaps
? insertLargeGapNullsIntoAlignedData(data, seriesOptions)
: data;
// Boolean spanGaps === true: convert null → undefined per series so uPlot
// draws a continuous line across missing samples instead of breaking it.
// Skip this pass entirely if no series uses spanGaps: true.
const hasBooleanTrue = seriesOptions.some(({ spanGaps }) => spanGaps === true);
if (!hasBooleanTrue) {
return gapProcessed;
}
const [newX, ...newSeries] = gapProcessed;
const transformedSeries = newSeries.map((yValues, seriesIndex) => {
const { spanGaps } = seriesOptions[seriesIndex] ?? {};
if (spanGaps !== true) {
// This series doesn't use spanGaps: true — leave it unchanged.
return yValues;
}
// Replace null with undefined: uPlot skips undefined points without
// breaking the line, effectively spanning over the gap.
return (yValues as SeriesArray).map((pointValue) =>
pointValue === null ? undefined : pointValue,
) as uPlot.AlignedData[0];
});
return [newX, ...transformedSeries] as uPlot.AlignedData;
}

View File

@@ -1,5 +0,0 @@
.unauthorized-page {
&__description {
text-align: center;
}
}

View File

@@ -1,51 +1,20 @@
import { useCallback } from 'react';
import { Space, Typography } from 'antd';
import UnAuthorized from 'assets/UnAuthorized';
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';
import { Button, Container } from 'components/NotFound/styles';
import ROUTES from 'constants/routes';
function UnAuthorizePage(): JSX.Element {
const [debugCurrentRole] = useQueryState('currentRole');
const { user } = useAppContext();
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
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">
<Container>
<Space align="center" direction="vertical">
<UnAuthorized width={64} height={64} />
<Typography.Title level={3}>Access Restricted</Typography.Title>
<UnAuthorized />
<Typography.Title level={3}>
Oops.. you don&apos;t have permission to view this page
</Typography.Title>
<p className="unauthorized-page__description">
It looks like you don&lsquo;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>
<Button to={ROUTES.HOME} tabIndex={0} className="periscope-btn primary">
Return To Home
</Button>
</Space>
</Container>
);

View File

@@ -19,12 +19,6 @@ 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';
@@ -40,7 +34,7 @@ import {
UserPreference,
} from 'types/api/preferences/preference';
import { Organization } from 'types/api/user/getOrganization';
import { ROLES, USER_ROLES } from 'types/roles';
import { USER_ROLES } from 'types/roles';
import { IAppContext, IUser } from './types';
import { getUserDefaults } from './utils';
@@ -49,7 +43,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 [defaultUser, setDefaultUser] = useState<IUser>(() => getUserDefaults());
const [user, setUser] = useState<IUser>(() => getUserDefaults());
const [activeLicense, setActiveLicense] = useState<LicenseResModel | null>(
null,
);
@@ -76,51 +70,18 @@ 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: isFetchingUserData,
error: userFetchDataError,
isFetching: isFetchingUser,
error: userFetchError,
} = 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);
setDefaultUser((prev) => ({
setUser((prev) => ({
...prev,
...userData.data,
}));
@@ -242,7 +203,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
}, [userPreferencesData, isFetchingUserPreferences, isLoggedIn]);
function updateUser(user: IUser): void {
setDefaultUser((prev) => ({
setUser((prev) => ({
...prev,
...user,
}));
@@ -283,7 +244,7 @@ export function AppProvider({ children }: PropsWithChildren): JSX.Element {
...org.slice(orgIndex + 1, org.length),
];
setOrg(updatedOrg);
setDefaultUser((prev) => {
setUser((prev) => {
if (prev.orgId === orgId) {
return {
...prev,
@@ -311,7 +272,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) {
setDefaultUser((prev) => ({
setUser((prev) => ({
...prev,
accessJwt: event.detail.accessJWT,
refreshJwt: event.detail.refreshJWT,
@@ -319,14 +280,12 @@ 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);
setDefaultUser(getUserDefaults());
setUser(getUserDefaults());
setActiveLicense(null);
setTrialInfo(null);
setFeatureFlags(null);

View File

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

View File

@@ -141,7 +141,6 @@ export interface IBaseWidget {
showPoints?: boolean;
lineStyle?: LineStyle;
fillMode?: FillMode;
spanGaps?: boolean | number;
}
export interface Widgets extends IBaseWidget {
query: Query;

View File

@@ -13,9 +13,6 @@ 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;
}

View File

@@ -2,16 +2,14 @@ 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 | ANONYMOUS;
export type ROLES = ADMIN | VIEWER | EDITOR | AUTHOR;
export const USER_ROLES = {
ADMIN: 'ADMIN',
VIEWER: 'VIEWER',
EDITOR: 'EDITOR',
AUTHOR: 'AUTHOR',
ANONYMOUS: 'ANONYMOUS',
};
export enum RoleType {

View File

@@ -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', 'ANONYMOUS'],
NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR'],
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', 'ANONYMOUS'],
UN_AUTHORIZED: ['ADMIN', 'EDITOR', 'VIEWER'],
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', 'ANONYMOUS'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
LOGS_SAVE_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],
TRACES_SAVE_VIEWS: ['ADMIN', 'EDITOR', 'VIEWER'],

View File

@@ -4506,19 +4506,6 @@
"@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"
@@ -4578,30 +4565,6 @@
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"
@@ -4841,20 +4804,6 @@
"@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"
@@ -5726,42 +5675,6 @@
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"
@@ -9660,11 +9573,6 @@ 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"
@@ -11184,15 +11092,6 @@ 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"
@@ -15103,13 +15002,6 @@ 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"
@@ -15117,11 +15009,6 @@ 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"
@@ -15135,14 +15022,6 @@ 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"
@@ -15413,13 +15292,6 @@ 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"
@@ -17085,11 +16957,6 @@ 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"
@@ -18930,11 +18797,6 @@ 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"

2
go.mod
View File

@@ -11,6 +11,7 @@ require (
github.com/SigNoz/signoz-otel-collector v0.144.2
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/bytedance/sonic v1.14.1
github.com/cespare/xxhash/v2 v2.3.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/dgraph-io/ristretto/v2 v2.3.0
@@ -105,7 +106,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect

View File

@@ -10,7 +10,7 @@ import (
)
func (provider *provider) addGatewayRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/gateway/ingestion_keys", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.GetIngestionKeys), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.GetIngestionKeys), handler.OpenAPIDef{
ID: "GetIngestionKeys",
Tags: []string{"gateway"},
Summary: "Get ingestion keys for workspace",
@@ -23,12 +23,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/search", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.SearchIngestionKeys), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys/search", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.SearchIngestionKeys), handler.OpenAPIDef{
ID: "SearchIngestionKeys",
Tags: []string{"gateway"},
Summary: "Search ingestion keys for workspace",
@@ -41,12 +41,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.CreateIngestionKey), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.CreateIngestionKey), handler.OpenAPIDef{
ID: "CreateIngestionKey",
Tags: []string{"gateway"},
Summary: "Create ingestion key for workspace",
@@ -58,12 +58,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.UpdateIngestionKey), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.UpdateIngestionKey), handler.OpenAPIDef{
ID: "UpdateIngestionKey",
Tags: []string{"gateway"},
Summary: "Update ingestion key for workspace",
@@ -75,12 +75,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.DeleteIngestionKey), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.DeleteIngestionKey), handler.OpenAPIDef{
ID: "DeleteIngestionKey",
Tags: []string{"gateway"},
Summary: "Delete ingestion key for workspace",
@@ -92,12 +92,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}/limits", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.CreateIngestionKeyLimit), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys/{keyId}/limits", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.CreateIngestionKeyLimit), handler.OpenAPIDef{
ID: "CreateIngestionKeyLimit",
Tags: []string{"gateway"},
Summary: "Create limit for the ingestion key",
@@ -109,12 +109,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/limits/{limitId}", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.UpdateIngestionKeyLimit), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys/limits/{limitId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.UpdateIngestionKeyLimit), handler.OpenAPIDef{
ID: "UpdateIngestionKeyLimit",
Tags: []string{"gateway"},
Summary: "Update limit for the ingestion key",
@@ -126,12 +126,12 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/gateway/ingestion_keys/limits/{limitId}", handler.New(provider.authZ.EditAccess(provider.gatewayHandler.DeleteIngestionKeyLimit), handler.OpenAPIDef{
if err := router.Handle("/api/v2/gateway/ingestion_keys/limits/{limitId}", handler.New(provider.authZ.AdminAccess(provider.gatewayHandler.DeleteIngestionKeyLimit), handler.OpenAPIDef{
ID: "DeleteIngestionKeyLimit",
Tags: []string{"gateway"},
Summary: "Delete limit for the ingestion key",
@@ -143,7 +143,7 @@ func (provider *provider) addGatewayRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}

View File

@@ -4,24 +4,24 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types/globaltypes"
"github.com/SigNoz/signoz/pkg/types"
"github.com/gorilla/mux"
)
func (provider *provider) addGlobalRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/global/config", handler.New(provider.authZ.OpenAccess(provider.globalHandler.GetConfig), handler.OpenAPIDef{
if err := router.Handle("/api/v1/global/config", handler.New(provider.authZ.EditAccess(provider.globalHandler.GetConfig), handler.OpenAPIDef{
ID: "GetGlobalConfig",
Tags: []string{"global"},
Summary: "Get global config",
Description: "This endpoint returns global config",
Request: nil,
RequestContentType: "",
Response: new(globaltypes.Config),
Response: new(types.GettableGlobalConfig),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: nil,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}

View File

@@ -238,7 +238,7 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
func newSecuritySchemes(role types.Role) []handler.OpenAPISecurityScheme {
return []handler.OpenAPISecurityScheme{
{Name: authtypes.IdentNProviderAPIKey.StringValue(), Scopes: []string{role.String()}},
{Name: authtypes.IdentNProviderAPIkey.StringValue(), Scopes: []string{role.String()}},
{Name: authtypes.IdentNProviderTokenizer.StringValue(), Scopes: []string{role.String()}},
}
}

View File

@@ -186,7 +186,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
Description: "This endpoint lists all users",
Request: nil,
RequestContentType: "",
Response: make([]*types.User, 0),
Response: make([]*types.GettableUser, 0),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -203,7 +203,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
Description: "This endpoint returns the user I belong to",
Request: nil,
RequestContentType: "",
Response: new(types.User),
Response: new(types.GettableUser),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
@@ -220,7 +220,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
Description: "This endpoint returns the user by id",
Request: nil,
RequestContentType: "",
Response: new(types.User),
Response: new(types.GettableUser),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
@@ -237,7 +237,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
Description: "This endpoint updates the user by id",
Request: new(types.User),
RequestContentType: "application/json",
Response: new(types.User),
Response: new(types.GettableUser),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},

View File

@@ -17,8 +17,8 @@ func NewStore(sqlstore sqlstore.SQLStore) authtypes.AuthNStore {
return &store{sqlstore: sqlstore}
}
func (store *store) GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx context.Context, email string, orgID valuer.UUID) (*types.StorableUser, *types.FactorPassword, error) {
user := new(types.StorableUser)
func (store *store) GetActiveUserAndFactorPasswordByEmailAndOrgID(ctx context.Context, email string, orgID valuer.UUID) (*types.User, *types.FactorPassword, error) {
user := new(types.User)
factorPassword := new(types.FactorPassword)
err := store.

View File

@@ -1,14 +1,9 @@
package global
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/types/globaltypes"
)
import "net/http"
type Global interface {
GetConfig(context.Context) *globaltypes.Config
GetConfig() Config
}
type Handler interface {

View File

@@ -1,12 +1,11 @@
package signozglobal
import (
"context"
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types"
)
type handler struct {
@@ -18,10 +17,7 @@ func NewHandler(global global.Global) global.Handler {
}
func (handler *handler) GetConfig(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
cfg := handler.global.GetConfig()
cfg := handler.global.GetConfig(ctx)
render.Success(rw, http.StatusOK, cfg)
render.Success(rw, http.StatusOK, types.NewGettableGlobalConfig(cfg.ExternalURL, cfg.IngestionURL))
}

View File

@@ -5,38 +5,27 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/types/globaltypes"
)
type provider struct {
config global.Config
identNConfig identn.Config
settings factory.ScopedProviderSettings
config global.Config
settings factory.ScopedProviderSettings
}
func NewFactory(identNConfig identn.Config) factory.ProviderFactory[global.Global, global.Config] {
func NewFactory() factory.ProviderFactory[global.Global, global.Config] {
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config global.Config) (global.Global, error) {
return newProvider(ctx, providerSettings, config, identNConfig)
return newProvider(ctx, providerSettings, config)
})
}
func newProvider(_ context.Context, providerSettings factory.ProviderSettings, config global.Config, identNConfig identn.Config) (global.Global, error) {
func newProvider(_ context.Context, providerSettings factory.ProviderSettings, config global.Config) (global.Global, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/global/signozglobal")
return &provider{
config: config,
identNConfig: identNConfig,
settings: settings,
config: config,
settings: settings,
}, nil
}
func (provider *provider) GetConfig(context.Context) *globaltypes.Config {
return globaltypes.NewConfig(
globaltypes.NewEndpoint(provider.config.ExternalURL.String(), provider.config.IngestionURL.String()),
globaltypes.NewIdentNConfig(
globaltypes.TokenizerConfig{Enabled: provider.identNConfig.Tokenizer.Enabled},
globaltypes.APIKeyConfig{Enabled: provider.identNConfig.APIKeyConfig.Enabled},
globaltypes.ImpersonationConfig{Enabled: provider.identNConfig.Impersonation.Enabled},
),
)
func (provider *provider) GetConfig() global.Config {
return provider.config
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
@@ -40,7 +41,9 @@ func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
commentCtx := ctxtypes.CommentFromContext(ctx)
authtype, ok := commentCtx.Map()["auth_type"]
if ok && (authtype == authtypes.IdentNProviderAPIkey.StringValue()) {
if err := claims.IsViewer(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
render.Error(rw, err)
@@ -90,7 +93,9 @@ func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
commentCtx := ctxtypes.CommentFromContext(ctx)
authtype, ok := commentCtx.Map()["auth_type"]
if ok && (authtype == authtypes.IdentNProviderAPIkey.StringValue()) {
if err := claims.IsEditor(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
render.Error(rw, err)
@@ -139,7 +144,9 @@ func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
return
}
if claims.IdentNProvider == authtypes.IdentNProviderAPIKey.StringValue() {
commentCtx := ctxtypes.CommentFromContext(ctx)
authtype, ok := commentCtx.Map()["auth_type"]
if ok && (authtype == authtypes.IdentNProviderAPIkey.StringValue()) {
if err := claims.IsAdmin(); err != nil {
middleware.logger.WarnContext(ctx, authzDeniedMessage, "claims", claims)
render.Error(rw, err)

View File

@@ -25,7 +25,7 @@ type provider struct {
}
func NewFactory(store sqlstore.SQLStore) factory.ProviderFactory[identn.IdentN, identn.Config] {
return factory.NewProviderFactory(factory.MustNewName(authtypes.IdentNProviderAPIKey.StringValue()), func(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config) (identn.IdentN, error) {
return factory.NewProviderFactory(factory.MustNewName(authtypes.IdentNProviderAPIkey.StringValue()), func(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config) (identn.IdentN, error) {
return New(providerSettings, store, config)
})
}
@@ -40,7 +40,7 @@ func New(providerSettings factory.ProviderSettings, store sqlstore.SQLStore, con
}
func (provider *provider) Name() authtypes.IdentNProvider {
return authtypes.IdentNProviderAPIKey
return authtypes.IdentNProviderAPIkey
}
func (provider *provider) Test(req *http.Request) bool {
@@ -52,6 +52,10 @@ func (provider *provider) Test(req *http.Request) bool {
return false
}
func (provider *provider) Enabled() bool {
return provider.config.APIKeyConfig.Enabled
}
func (provider *provider) Pre(req *http.Request) *http.Request {
token := provider.extractToken(req)
if token == "" {
@@ -85,7 +89,7 @@ func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, e
return nil, errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, "api key has expired")
}
var user types.StorableUser
var user types.User
err = provider.
store.
BunDB().
@@ -97,8 +101,13 @@ func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, e
return nil, err
}
identity := authtypes.NewIdentity(user.ID, user.OrgID, user.Email, apiKey.Role, provider.Name())
return identity, nil
identity := authtypes.Identity{
UserID: user.ID,
Role: apiKey.Role,
Email: user.Email,
OrgID: user.OrgID,
}
return &identity, nil
}
func (provider *provider) Post(ctx context.Context, _ *http.Request, _ authtypes.Claims) {

View File

@@ -1,7 +1,6 @@
package identn
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
)
@@ -11,20 +10,11 @@ type Config struct {
// Config for apikey identN resolver
APIKeyConfig APIKeyConfig `mapstructure:"apikey"`
// Config for impersonation identN resolver
Impersonation ImpersonationConfig `mapstructure:"impersonation"`
}
type ImpersonationConfig struct {
// Toggles the identN resolver
Enabled bool `mapstructure:"enabled"`
}
type TokenizerConfig struct {
// Toggles the identN resolver
Enabled bool `mapstructure:"enabled"`
// Headers to extract from incoming requests
Headers []string `mapstructure:"headers"`
}
@@ -32,7 +22,6 @@ type TokenizerConfig struct {
type APIKeyConfig struct {
// Toggles the identN resolver
Enabled bool `mapstructure:"enabled"`
// Headers to extract from incoming requests
Headers []string `mapstructure:"headers"`
}
@@ -51,22 +40,9 @@ func newConfig() factory.Config {
Enabled: true,
Headers: []string{"SIGNOZ-API-KEY"},
},
Impersonation: ImpersonationConfig{
Enabled: false,
},
}
}
func (c Config) Validate() error {
if c.Impersonation.Enabled {
if c.Tokenizer.Enabled {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "identn::impersonation cannot be enabled if identn::tokenizer is enabled")
}
if c.APIKeyConfig.Enabled {
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "identn::impersonation cannot be enabled if identn::apikey is enabled")
}
}
return nil
}

View File

@@ -23,6 +23,8 @@ type IdentN interface {
GetIdentity(r *http.Request) (*authtypes.Identity, error)
Name() authtypes.IdentNProvider
Enabled() bool
}
// IdentNWithPreHook is optionally implemented by resolvers that need to

View File

@@ -1,96 +0,0 @@
package impersonationidentn
import (
"context"
"net/http"
"sync"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/identn"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
type provider struct {
config identn.Config
settings factory.ScopedProviderSettings
orgGetter organization.Getter
userGetter user.Getter
userConfig user.Config
mu sync.RWMutex
identity *authtypes.Identity
}
func NewFactory(orgGetter organization.Getter, userGetter user.Getter, userConfig user.Config) factory.ProviderFactory[identn.IdentN, identn.Config] {
return factory.NewProviderFactory(factory.MustNewName(authtypes.IdentNProviderImpersonation.StringValue()), func(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config) (identn.IdentN, error) {
return New(ctx, providerSettings, config, orgGetter, userGetter, userConfig)
})
}
func New(ctx context.Context, providerSettings factory.ProviderSettings, config identn.Config, orgGetter organization.Getter, userGetter user.Getter, userConfig user.Config) (identn.IdentN, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn/impersonationidentn")
settings.Logger().WarnContext(ctx, "impersonation identity provider is enabled, all requests will impersonate the root user")
if !userConfig.Root.Enabled {
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "root user is not enabled, impersonation identity provider will not be able to resolve any identity")
}
return &provider{
config: config,
settings: settings,
orgGetter: orgGetter,
userGetter: userGetter,
userConfig: userConfig,
}, nil
}
func (provider *provider) Name() authtypes.IdentNProvider {
return authtypes.IdentNProviderImpersonation
}
func (provider *provider) Test(_ *http.Request) bool {
return true
}
func (provider *provider) GetIdentity(req *http.Request) (*authtypes.Identity, error) {
ctx := req.Context()
provider.mu.RLock()
if provider.identity != nil {
provider.mu.RUnlock()
return provider.identity, nil
}
provider.mu.RUnlock()
provider.mu.Lock()
defer provider.mu.Unlock()
// Re-check after acquiring write lock; another goroutine may have resolved it.
if provider.identity != nil {
return provider.identity, nil
}
org, _, err := provider.orgGetter.GetByIDOrName(ctx, provider.userConfig.Root.Org.ID, provider.userConfig.Root.Org.Name)
if err != nil {
return nil, err
}
rootUser, err := provider.userGetter.GetRootUserByOrgID(ctx, org.ID)
if err != nil {
return nil, err
}
provider.identity = authtypes.NewIdentity(
rootUser.ID,
rootUser.OrgID,
rootUser.Email,
rootUser.Role,
authtypes.IdentNProviderImpersonation,
)
return provider.identity, nil
}

View File

@@ -1,11 +1,9 @@
package identn
import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/types/authtypes"
)
type identNResolver struct {
@@ -13,55 +11,19 @@ type identNResolver struct {
settings factory.ScopedProviderSettings
}
func NewIdentNResolver(ctx context.Context, providerSettings factory.ProviderSettings, identNConfig Config, identNFactories factory.NamedMap[factory.ProviderFactory[IdentN, Config]]) (IdentNResolver, error) {
identNs := []IdentN{}
func NewIdentNResolver(providerSettings factory.ProviderSettings, identNs ...IdentN) IdentNResolver {
enabledIdentNs := []IdentN{}
if identNConfig.Impersonation.Enabled {
identNFactory, err := identNFactories.Get(authtypes.IdentNProviderImpersonation.StringValue())
if err != nil {
return nil, err
for _, identN := range identNs {
if identN.Enabled() {
enabledIdentNs = append(enabledIdentNs, identN)
}
identN, err := identNFactory.New(ctx, providerSettings, identNConfig)
if err != nil {
return nil, err
}
identNs = append(identNs, identN)
}
if identNConfig.Tokenizer.Enabled {
identNFactory, err := identNFactories.Get(authtypes.IdentNProviderTokenizer.StringValue())
if err != nil {
return nil, err
}
identN, err := identNFactory.New(ctx, providerSettings, identNConfig)
if err != nil {
return nil, err
}
identNs = append(identNs, identN)
}
if identNConfig.APIKeyConfig.Enabled {
identNFactory, err := identNFactories.Get(authtypes.IdentNProviderAPIKey.StringValue())
if err != nil {
return nil, err
}
identN, err := identNFactory.New(ctx, providerSettings, identNConfig)
if err != nil {
return nil, err
}
identNs = append(identNs, identN)
}
return &identNResolver{
identNs: identNs,
identNs: enabledIdentNs,
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/identn"),
}, nil
}
}
// GetIdentN returns the first IdentN whose Test() returns true.

View File

@@ -48,6 +48,10 @@ func (provider *provider) Test(req *http.Request) bool {
return false
}
func (provider *provider) Enabled() bool {
return provider.config.Tokenizer.Enabled
}
func (provider *provider) Pre(req *http.Request) *http.Request {
accessToken := provider.extractToken(req)
if accessToken == "" {

View File

@@ -3,7 +3,6 @@ package implorganization
import (
"context"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/sharder"
"github.com/SigNoz/signoz/pkg/types"
@@ -23,33 +22,6 @@ func (module *getter) Get(ctx context.Context, id valuer.UUID) (*types.Organizat
return module.store.Get(ctx, id)
}
func (module *getter) GetByIDOrName(ctx context.Context, id valuer.UUID, name string) (*types.Organization, bool, error) {
if id.IsZero() {
org, err := module.store.GetByName(ctx, name)
if err != nil {
return nil, false, err
}
return org, true, nil
}
org, err := module.store.Get(ctx, id)
if err == nil {
return org, false, nil
}
if !errors.Ast(err, errors.TypeNotFound) {
return nil, false, err
}
org, err = module.store.GetByName(ctx, name)
if err != nil {
return nil, false, err
}
return org, true, nil
}
func (module *getter) ListByOwnedKeyRange(ctx context.Context) ([]*types.Organization, error) {
start, end, err := module.sharder.GetMyOwnedKeyRange(ctx)
if err != nil {

View File

@@ -12,10 +12,6 @@ type Getter interface {
// Get gets the organization based on the given id
Get(context.Context, valuer.UUID) (*types.Organization, error)
// GetByIDOrName gets the organization by id, falling back to name on not found.
// The boolean is true when the name fallback path was used.
GetByIDOrName(context.Context, valuer.UUID, string) (*types.Organization, bool, error)
// ListByOwnedKeyRange gets all the organizations owned by the instance
ListByOwnedKeyRange(context.Context) ([]*types.Organization, error)

View File

@@ -78,7 +78,7 @@ func (m *module) ListPromotedAndIndexedPaths(ctx context.Context) ([]promotetype
// add the paths that are not promoted but have indexes
for path, indexes := range aggr {
path := strings.TrimPrefix(path, telemetrylogs.BodyV2ColumnPrefix)
path := strings.TrimPrefix(path, telemetrylogs.BodyJSONColumnPrefix)
path = telemetrytypes.BodyJSONStringSearchPrefix + path
response = append(response, promotetypes.PromotePath{
Path: path,
@@ -163,7 +163,7 @@ func (m *module) PromoteAndIndexPaths(
}
}
if len(it.Indexes) > 0 {
parentColumn := telemetrylogs.LogsV2BodyV2Column
parentColumn := telemetrylogs.LogsV2BodyJSONColumn
// if the path is already promoted or is being promoted, add it to the promoted column
if _, promoted := existingPromotedPaths[it.Path]; promoted || it.Promote {
parentColumn = telemetrylogs.LogsV2BodyPromotedColumn

View File

@@ -8,7 +8,6 @@ import (
"time"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/authdomain"
@@ -29,10 +28,9 @@ type module struct {
authDomain authdomain.Module
tokenizer tokenizer.Tokenizer
orgGetter organization.Getter
authz authz.AuthZ
}
func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.AuthNProvider]authn.AuthN, user user.Module, userGetter user.Getter, authDomain authdomain.Module, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter, authz authz.AuthZ) session.Module {
func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.AuthNProvider]authn.AuthN, user user.Module, userGetter user.Getter, authDomain authdomain.Module, tokenizer tokenizer.Tokenizer, orgGetter organization.Getter) session.Module {
return &module{
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/session/implsession"),
authNs: authNs,
@@ -41,7 +39,6 @@ func NewModule(providerSettings factory.ProviderSettings, authNs map[authtypes.A
authDomain: authDomain,
tokenizer: tokenizer,
orgGetter: orgGetter,
authz: authz,
}
}
@@ -145,16 +142,9 @@ func (module *module) CreateCallbackAuthNSession(ctx context.Context, authNProvi
}
roleMapping := authDomain.AuthDomainConfig().RoleMapping
managedRoles := roleMapping.ManagedRolesFromCallbackIdentity(callbackIdentity)
role := roleMapping.NewRoleFromCallbackIdentity(callbackIdentity)
// pass only valid or fallback to viewer
validRoles, err := module.resolveValidRoles(ctx, callbackIdentity.OrgID, managedRoles, callbackIdentity.Email)
if err != nil {
return "", err
}
legacyRole := authtypes.HighestLegacyRoleFromManagedRoles(validRoles)
user, err := types.NewUser(callbackIdentity.Name, callbackIdentity.Email, legacyRole, validRoles, callbackIdentity.OrgID, types.UserStatusActive)
user, err := types.NewUser(callbackIdentity.Name, callbackIdentity.Email, role, callbackIdentity.OrgID, types.UserStatusActive)
if err != nil {
return "", err
}
@@ -232,34 +222,3 @@ func getProvider[T authn.AuthN](authNProvider authtypes.AuthNProvider, authNs ma
return provider, nil
}
// resolveValidRoles validates role names against the database
// returns only roles that exist. If none are valid, falls back to signoz-viewer role
func (module *module) resolveValidRoles(ctx context.Context, orgID valuer.UUID, roles []string, email valuer.Email) ([]string, error) {
validRoles := make([]string, 0, len(roles))
var ignored []string
for _, roleName := range roles {
_, err := module.authz.GetByOrgIDAndName(ctx, orgID, roleName)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
ignored = append(ignored, roleName)
continue
}
return nil, err
}
validRoles = append(validRoles, roleName)
}
if len(ignored) > 0 {
module.settings.Logger().WarnContext(ctx, "ignoring non-existent roles from SSO mapping", "ignored_roles", ignored, "email", email)
}
// fallback to viewer if no valid roles
if len(validRoles) == 0 {
module.settings.Logger().WarnContext(ctx, "no valid roles from SSO mapping, falling back to viewer", "email", email)
validRoles = []string{authtypes.SigNozViewerRoleName}
}
return validRoles, nil
}

View File

@@ -30,7 +30,7 @@ func (module *module) Create(ctx context.Context, timestamp int64, name string,
funnel.CreatedBy = userID.String()
// Set up the user relationship
funnel.CreatedByUser = &types.StorableUser{
funnel.CreatedByUser = &types.User{
Identifiable: types.Identifiable{
ID: userID,
},

View File

@@ -4,108 +4,76 @@ import (
"context"
"slices"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type getter struct {
store types.UserStore
authz authz.AuthZ
userRoleStore authtypes.UserRoleStore
flagger flagger.Flagger
store types.UserStore
flagger flagger.Flagger
}
func NewGetter(store types.UserStore, authz authz.AuthZ, userRoleStore authtypes.UserRoleStore, flagger flagger.Flagger) user.Getter {
return &getter{store: store, authz: authz, userRoleStore: userRoleStore, flagger: flagger}
func NewGetter(store types.UserStore, flagger flagger.Flagger) user.Getter {
return &getter{store: store, flagger: flagger}
}
func (module *getter) GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (*types.User, error) {
storableUser, err := module.store.GetRootUserByOrgID(ctx, orgID)
if err != nil {
return nil, err
}
roleNames, err := module.resolveRoleNamesForUser(ctx, storableUser.ID, storableUser.OrgID)
if err != nil {
return nil, err
}
user := types.NewUserFromStorable(storableUser, roleNames)
return user, nil
return module.store.GetRootUserByOrgID(ctx, orgID)
}
func (module *getter) ListByOrgID(ctx context.Context, orgID valuer.UUID) ([]*types.User, error) {
storableUsers, err := module.store.ListUsersByOrgID(ctx, orgID)
if err != nil {
return nil, err
}
userIDs := make([]valuer.UUID, len(storableUsers))
for idx, storableUser := range storableUsers {
userIDs[idx] = storableUser.ID
}
storableUserRoles, err := module.userRoleStore.ListUserRolesByOrgIDAndUserIDs(ctx, orgID, userIDs)
if err != nil {
return nil, err
}
userIDToRoleIDs, roleIDs := authtypes.GetUserIDToRoleIDsMappingAndUniqueRoles(storableUserRoles)
roles, err := module.authz.ListByOrgIDAndIDs(ctx, orgID, roleIDs)
users, err := module.store.ListUsersByOrgID(ctx, orgID)
if err != nil {
return nil, err
}
// filter root users if feature flag `hide_root_users` is true
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
hideRootUsers := module.flagger.BooleanOrEmpty(ctx, flagger.FeatureHideRootUser, evalCtx)
if hideRootUsers {
storableUsers = slices.DeleteFunc(storableUsers, func(user *types.StorableUser) bool { return user.IsRoot })
users = slices.DeleteFunc(users, func(user *types.User) bool { return user.IsRoot })
}
users := module.usersFromStorableUsersAndRolesMaps(storableUsers, roles, userIDToRoleIDs)
return users, nil
}
func (module *getter) GetUsersByEmail(ctx context.Context, email valuer.Email) ([]*types.User, error) {
users, err := module.store.GetUsersByEmail(ctx, email)
if err != nil {
return nil, err
}
return users, nil
}
func (module *getter) GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*types.User, error) {
user, err := module.store.GetByOrgIDAndID(ctx, orgID, id)
if err != nil {
return nil, err
}
return user, nil
}
func (module *getter) Get(ctx context.Context, id valuer.UUID) (*types.User, error) {
storableUser, err := module.store.GetUser(ctx, id)
user, err := module.store.GetUser(ctx, id)
if err != nil {
return nil, err
}
roleNames, err := module.resolveRoleNamesForUser(ctx, storableUser.ID, storableUser.OrgID)
if err != nil {
return nil, err
}
user := types.NewUserFromStorable(storableUser, roleNames)
return user, nil
}
func (module *getter) ListUsersByEmailAndOrgIDs(ctx context.Context, email valuer.Email, orgIDs []valuer.UUID) ([]*types.User, error) {
storableUsers, err := module.store.ListUsersByEmailAndOrgIDs(ctx, email, orgIDs)
users, err := module.store.ListUsersByEmailAndOrgIDs(ctx, email, orgIDs)
if err != nil {
return nil, err
}
users := make([]*types.User, len(storableUsers))
for idx, storableUser := range storableUsers {
roleNames, err := module.resolveRoleNamesForUser(ctx, storableUser.ID, storableUser.OrgID)
if err != nil {
return nil, err
}
users[idx] = types.NewUserFromStorable(storableUser, roleNames)
}
return users, nil
}
@@ -135,52 +103,3 @@ func (module *getter) GetFactorPasswordByUserID(ctx context.Context, userID valu
return factorPassword, nil
}
func (module *getter) resolveRoleNamesForUser(ctx context.Context, userID valuer.UUID, orgID valuer.UUID) ([]string, error) {
storableUserRoles, err := module.userRoleStore.GetUserRolesByUserID(ctx, userID)
if err != nil {
return nil, err
}
roleIDs := make([]valuer.UUID, len(storableUserRoles))
for idx, sur := range storableUserRoles {
roleIDs[idx] = sur.RoleID
}
roles, err := module.authz.ListByOrgIDAndIDs(ctx, orgID, roleIDs)
if err != nil {
return nil, err
}
roleNames := make([]string, len(roles))
for idx, role := range roles {
roleNames[idx] = role.Name
}
return roleNames, nil
}
func (module *getter) usersFromStorableUsersAndRolesMaps(storableUsers []*types.StorableUser, roles []*authtypes.Role, userIDToRoleIDsMap map[valuer.UUID][]valuer.UUID) []*types.User {
users := make([]*types.User, 0, len(storableUsers))
roleIDToRole := make(map[string]*authtypes.Role, len(roles))
for _, role := range roles {
roleIDToRole[role.ID.String()] = role
}
for _, user := range storableUsers {
roleIDs := userIDToRoleIDsMap[user.ID]
roleNames := make([]string, 0, len(roleIDs))
for _, rid := range roleIDs {
if role, ok := roleIDToRole[rid.String()]; ok {
roleNames = append(roleNames, role.Name)
}
}
account := types.NewUserFromStorable(user, roleNames)
users = append(users, account)
}
return users
}

View File

@@ -169,7 +169,7 @@ func (h *handler) GetUser(w http.ResponseWriter, r *http.Request) {
return
}
user, err := h.module.GetByOrgIDAndUserID(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(id))
user, err := h.getter.GetByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(id))
if err != nil {
render.Error(w, err)
return
@@ -188,7 +188,7 @@ func (h *handler) GetMyUser(w http.ResponseWriter, r *http.Request) {
return
}
user, err := h.module.GetByOrgIDAndUserID(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.UserID))
user, err := h.getter.GetByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(claims.UserID))
if err != nil {
render.Error(w, err)
return
@@ -270,7 +270,7 @@ func (handler *handler) GetResetPasswordToken(w http.ResponseWriter, r *http.Req
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
userID := mux.Vars(r)["id"]
id := mux.Vars(r)["id"]
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
@@ -278,7 +278,13 @@ func (handler *handler) GetResetPasswordToken(w http.ResponseWriter, r *http.Req
return
}
token, err := handler.module.GetOrCreateResetPasswordToken(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(userID))
user, err := handler.getter.GetByOrgIDAndID(ctx, valuer.MustNewUUID(claims.OrgID), valuer.MustNewUUID(id))
if err != nil {
render.Error(w, err)
return
}
token, err := handler.module.GetOrCreateResetPasswordToken(ctx, user.ID)
if err != nil {
render.Error(w, err)
return

View File

@@ -12,6 +12,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/user"
root "github.com/SigNoz/signoz/pkg/modules/user"
"github.com/SigNoz/signoz/pkg/tokenizer"
"github.com/SigNoz/signoz/pkg/types"
@@ -23,53 +24,34 @@ import (
)
type Module struct {
store types.UserStore
userRoleStore authtypes.UserRoleStore
tokenizer tokenizer.Tokenizer
emailing emailing.Emailing
settings factory.ScopedProviderSettings
orgSetter organization.Setter
authz authz.AuthZ
analytics analytics.Analytics
config root.Config
store types.UserStore
tokenizer tokenizer.Tokenizer
emailing emailing.Emailing
settings factory.ScopedProviderSettings
orgSetter organization.Setter
authz authz.AuthZ
analytics analytics.Analytics
config user.Config
}
// This module is a WIP, don't take inspiration from this.
func NewModule(store types.UserStore, userRoleStore authtypes.UserRoleStore, tokenizer tokenizer.Tokenizer, emailing emailing.Emailing, providerSettings factory.ProviderSettings, orgSetter organization.Setter, authz authz.AuthZ, analytics analytics.Analytics, config root.Config) root.Module {
func NewModule(store types.UserStore, tokenizer tokenizer.Tokenizer, emailing emailing.Emailing, providerSettings factory.ProviderSettings, orgSetter organization.Setter, authz authz.AuthZ, analytics analytics.Analytics, config user.Config) root.Module {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/user/impluser")
return &Module{
store: store,
userRoleStore: userRoleStore,
tokenizer: tokenizer,
emailing: emailing,
settings: settings,
orgSetter: orgSetter,
analytics: analytics,
authz: authz,
config: config,
store: store,
tokenizer: tokenizer,
emailing: emailing,
settings: settings,
orgSetter: orgSetter,
analytics: analytics,
authz: authz,
config: config,
}
}
// this function gets user with its proper roles populated
func (m *Module) GetByOrgIDAndUserID(ctx context.Context, orgID, userID valuer.UUID) (*types.User, error) {
storableUser, err := m.store.GetByOrgIDAndID(ctx, orgID, userID)
if err != nil {
return nil, err
}
roleNames, err := m.resolveRoleNamesForUser(ctx, userID, storableUser.OrgID)
if err != nil {
return nil, err
}
user := types.NewUserFromStorable(storableUser, roleNames)
return user, nil
}
func (m *Module) AcceptInvite(ctx context.Context, token string, password string) (*types.User, error) {
// get the user by reset password token
storableUser, err := m.store.GetUserByResetPasswordToken(ctx, token)
user, err := m.store.GetUserByResetPasswordToken(ctx, token)
if err != nil {
return nil, err
}
@@ -81,7 +63,7 @@ func (m *Module) AcceptInvite(ctx context.Context, token string, password string
}
// query the user again
user, err := m.GetByOrgIDAndUserID(ctx, storableUser.OrgID, storableUser.ID)
user, err = m.store.GetByOrgIDAndID(ctx, user.OrgID, user.ID)
if err != nil {
return nil, err
}
@@ -91,12 +73,7 @@ func (m *Module) AcceptInvite(ctx context.Context, token string, password string
func (m *Module) GetInviteByToken(ctx context.Context, token string) (*types.Invite, error) {
// get the user
storableUser, err := m.store.GetUserByResetPasswordToken(ctx, token)
if err != nil {
return nil, err
}
user, err := m.GetByOrgIDAndUserID(ctx, storableUser.OrgID, storableUser.ID)
user, err := m.store.GetUserByResetPasswordToken(ctx, token)
if err != nil {
return nil, err
}
@@ -110,7 +87,6 @@ func (m *Module) GetInviteByToken(ctx context.Context, token string) (*types.Inv
Email: user.Email,
Token: token,
Role: user.Role,
Roles: user.Roles,
OrgID: user.OrgID,
TimeAuditable: types.TimeAuditable{
CreatedAt: user.CreatedAt,
@@ -130,52 +106,24 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID
// validate all emails to be invited
emails := make([]string, len(bulkInvites.Invites))
var allRolesFromRequest []string
seenRolesFromRequest := make(map[string]struct{})
for idx := range bulkInvites.Invites {
invite := &bulkInvites.Invites[idx]
for idx, invite := range bulkInvites.Invites {
emails[idx] = invite.Email.StringValue()
// backward compat: derive Roles from legacy Role when Roles is not provided
if len(invite.Roles) == 0 && invite.Role != "" {
if managedRole, ok := authtypes.ExistingRoleToSigNozManagedRoleMap[invite.Role]; ok {
invite.Roles = []string{managedRole}
}
} else if invite.Role == "" && len(invite.Roles) > 0 {
// and vice versa
invite.Role = authtypes.HighestLegacyRoleFromManagedRoles(invite.Roles)
}
// for role name validation
for _, role := range invite.Roles {
if _, ok := seenRolesFromRequest[role]; !ok {
seenRolesFromRequest[role] = struct{}{}
allRolesFromRequest = append(allRolesFromRequest, role)
}
}
}
storableUsers, err := m.store.GetUsersByEmailsOrgIDAndStatuses(ctx, orgID, emails, []string{types.UserStatusActive.StringValue(), types.UserStatusPendingInvite.StringValue()})
users, err := m.store.GetUsersByEmailsOrgIDAndStatuses(ctx, orgID, emails, []string{types.UserStatusActive.StringValue(), types.UserStatusPendingInvite.StringValue()})
if err != nil {
return nil, err
}
if len(storableUsers) > 0 {
if err := storableUsers[0].ErrIfRoot(); err != nil {
if len(users) > 0 {
if err := users[0].ErrIfRoot(); err != nil {
return nil, errors.WithAdditionalf(err, "Cannot send invite to root user")
}
if storableUsers[0].Status == types.UserStatusPendingInvite {
return nil, errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "An invite already exists for this email: %s", storableUsers[0].Email.StringValue())
if users[0].Status == types.UserStatusPendingInvite {
return nil, errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "An invite already exists for this email: %s", users[0].Email.StringValue())
}
return nil, errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "User already exists with this email: %s", storableUsers[0].Email.StringValue())
}
// this function returns error if some role is not found by name
_, err = m.authz.ListByOrgIDAndNames(ctx, orgID, allRolesFromRequest)
if err != nil {
return nil, err
return nil, errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "User already exists with this email: %s", users[0].Email.StringValue())
}
type userWithResetToken struct {
@@ -187,20 +135,25 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID
if err := m.store.RunInTx(ctx, func(ctx context.Context) error {
for idx, invite := range bulkInvites.Invites {
// create a new user with pending invite status
newUser, err := types.NewUser(invite.Name, invite.Email, invite.Role, invite.Roles, orgID, types.UserStatusPendingInvite)
role, err := types.NewRole(invite.Role.String())
if err != nil {
return err
}
// store the user and user_role entries in db
// create a new user with pending invite status
newUser, err := types.NewUser(invite.Name, invite.Email, role, orgID, types.UserStatusPendingInvite)
if err != nil {
return err
}
// store the user and password in db
err = m.createUserWithoutGrant(ctx, newUser)
if err != nil {
return err
}
// generate reset password token
resetPasswordToken, err := m.GetOrCreateResetPasswordToken(ctx, newUser.OrgID, newUser.ID)
resetPasswordToken, err := m.GetOrCreateResetPasswordToken(ctx, newUser.ID)
if err != nil {
m.settings.Logger().ErrorContext(ctx, "failed to create reset password token for invited user", "error", err)
return err
@@ -222,7 +175,7 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID
for idx, userWithToken := range newUsersWithResetToken {
m.analytics.TrackUser(ctx, orgID.String(), creator.ID.String(), "Invite Sent", map[string]any{
"invitee_email": userWithToken.User.Email,
"invitee_roles": userWithToken.User.Roles,
"invitee_role": userWithToken.User.Role,
})
invite := &types.Invite{
@@ -233,7 +186,6 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID
Email: userWithToken.User.Email,
Token: userWithToken.ResetPasswordToken.Token,
Role: userWithToken.User.Role,
Roles: userWithToken.User.Roles,
OrgID: userWithToken.User.OrgID,
TimeAuditable: types.TimeAuditable{
CreatedAt: userWithToken.User.CreatedAt,
@@ -267,39 +219,39 @@ func (m *Module) CreateBulkInvite(ctx context.Context, orgID valuer.UUID, userID
}
func (m *Module) ListInvite(ctx context.Context, orgID string) ([]*types.Invite, error) {
storableUsers, err := m.store.ListUsersByOrgID(ctx, valuer.MustNewUUID(orgID))
// find all the users with pending_invite status
users, err := m.store.ListUsersByOrgID(ctx, valuer.MustNewUUID(orgID))
if err != nil {
return nil, err
}
storableUsers = slices.DeleteFunc(storableUsers, func(user *types.StorableUser) bool { return user.Status != types.UserStatusPendingInvite })
pendingUsers := slices.DeleteFunc(users, func(user *types.User) bool { return user.Status != types.UserStatusPendingInvite })
var invites []*types.Invite
for _, su := range storableUsers {
roleNames, err := m.resolveRoleNamesForUser(ctx, su.ID, su.OrgID)
if err != nil {
return nil, err
}
user := types.NewUserFromStorable(su, roleNames)
resetPasswordToken, err := m.GetOrCreateResetPasswordToken(ctx, user.OrgID, user.ID)
for _, pUser := range pendingUsers {
// get the reset password token
resetPasswordToken, err := m.GetOrCreateResetPasswordToken(ctx, pUser.ID)
if err != nil {
return nil, err
}
// create a dummy invite obj for backward compatibility
invite := &types.Invite{
Identifiable: types.Identifiable{ID: user.ID},
Name: user.DisplayName,
Email: user.Email,
Token: resetPasswordToken.Token,
Role: user.Role,
Roles: user.Roles,
OrgID: user.OrgID,
Identifiable: types.Identifiable{
ID: pUser.ID,
},
Name: pUser.DisplayName,
Email: pUser.Email,
Token: resetPasswordToken.Token,
Role: pUser.Role,
OrgID: pUser.OrgID,
TimeAuditable: types.TimeAuditable{
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
CreatedAt: pUser.CreatedAt,
UpdatedAt: pUser.UpdatedAt, // dummy
},
}
invites = append(invites, invite)
}
@@ -307,27 +259,16 @@ func (m *Module) ListInvite(ctx context.Context, orgID string) ([]*types.Invite,
}
func (module *Module) CreateUser(ctx context.Context, input *types.User, opts ...root.CreateUserOption) error {
// validate the roles
_, err := module.authz.ListByOrgIDAndNames(ctx, input.OrgID, input.Roles)
if err != nil {
return err
}
// since assign is idempotant multiple calls to assign won't cause issues in case of retries, also we cannot run this in a transaction for now
err = module.authz.Grant(ctx, input.OrgID, input.Roles, authtypes.MustNewSubject(authtypes.TypeableUser, input.ID.StringValue(), input.OrgID, nil))
if err != nil {
return err
}
createUserOpts := root.NewCreateUserOptions(opts...)
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.CreateUser(ctx, types.NewStorableUser(input)); err != nil {
return err
}
// since assign is idempotant multiple calls to assign won't cause issues in case of retries.
err := module.authz.Grant(ctx, input.OrgID, []string{authtypes.MustGetSigNozManagedRoleFromExistingRole(input.Role)}, authtypes.MustNewSubject(authtypes.TypeableUser, input.ID.StringValue(), input.OrgID, nil))
if err != nil {
return err
}
// create user_role junction entries
if err := module.createUserRoleEntries(ctx, input); err != nil {
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.CreateUser(ctx, input); err != nil {
return err
}
@@ -350,7 +291,7 @@ func (module *Module) CreateUser(ctx context.Context, input *types.User, opts ..
}
func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, user *types.User, updatedBy string) (*types.User, error) {
existingUser, err := m.GetByOrgIDAndUserID(ctx, orgID, valuer.MustNewUUID(id))
existingUser, err := m.store.GetUser(ctx, valuer.MustNewUUID(id))
if err != nil {
return nil, err
}
@@ -367,30 +308,18 @@ func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, u
return nil, errors.WithAdditionalf(err, "cannot update pending user")
}
requestor, err := m.GetByOrgIDAndUserID(ctx, orgID, valuer.MustNewUUID(updatedBy))
requestor, err := m.store.GetUser(ctx, valuer.MustNewUUID(updatedBy))
if err != nil {
return nil, err
}
// backward compatibility: convert legacy "role" field to "roles" when "roles" is not provided
if user.Roles == nil && user.Role != "" && user.Role != existingUser.Role {
user.Roles = []string{authtypes.MustGetSigNozManagedRoleFromExistingRole(user.Role)}
}
var grants, revokes []string
var rolesChanged bool
if user.Roles != nil {
grants, revokes = existingUser.PatchRoles(user.Roles)
rolesChanged = (len(grants) > 0) || (len(revokes) > 0)
}
if rolesChanged && !slices.Contains(requestor.Roles, authtypes.SigNozAdminRoleName) {
if user.Role != "" && user.Role != existingUser.Role && requestor.Role != types.RoleAdmin {
return nil, errors.New(errors.TypeForbidden, errors.CodeForbidden, "only admins can change roles")
}
// Make sure that the request is not demoting the last admin user.
if rolesChanged && slices.Contains(existingUser.Roles, authtypes.SigNozAdminRoleName) && !slices.Contains(user.Roles, authtypes.SigNozAdminRoleName) {
adminUsers, err := m.store.GetActiveUsersByRoleNameAndOrgID(ctx, authtypes.SigNozAdminRoleName, orgID)
if user.Role != "" && user.Role != existingUser.Role && existingUser.Role == types.RoleAdmin {
adminUsers, err := m.store.GetActiveUsersByRoleAndOrgID(ctx, types.RoleAdmin, orgID)
if err != nil {
return nil, err
}
@@ -400,58 +329,28 @@ func (m *Module) UpdateUser(ctx context.Context, orgID valuer.UUID, id string, u
}
}
if rolesChanged {
// can't run in txn
err = m.authz.ModifyGrant(ctx, orgID, revokes, grants, authtypes.MustNewSubject(authtypes.TypeableUser, id, orgID, nil))
if user.Role != "" && user.Role != existingUser.Role {
err = m.authz.ModifyGrant(ctx,
orgID,
[]string{authtypes.MustGetSigNozManagedRoleFromExistingRole(existingUser.Role)},
[]string{authtypes.MustGetSigNozManagedRoleFromExistingRole(user.Role)},
authtypes.MustNewSubject(authtypes.TypeableUser, id, orgID, nil),
)
if err != nil {
return nil, err
}
}
// preserve existing role and roles when not explicitly provided in the request
updateRole := user.Role
updateRoles := user.Roles
if user.Roles == nil {
updateRole = existingUser.Role
updateRoles = existingUser.Roles
} else if updateRole == "" {
updateRole = existingUser.Role
}
existingUser.Update(user.DisplayName, updateRole, updateRoles)
if rolesChanged {
err = m.store.RunInTx(ctx, func(ctx context.Context) error {
// update the user
if err := m.UpdateAnyUser(ctx, orgID, existingUser); err != nil {
return err
}
// delete old role entries and create new ones
if err := m.userRoleStore.DeleteUserRoles(ctx, existingUser.ID); err != nil {
return err
}
// create new ones
if err := m.createUserRoleEntries(ctx, existingUser); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
} else {
// persist display name change even when roles haven't changed
if err := m.UpdateAnyUser(ctx, orgID, existingUser); err != nil {
return nil, err
}
existingUser.Update(user.DisplayName, user.Role)
if err := m.UpdateAnyUser(ctx, orgID, existingUser); err != nil {
return nil, err
}
return existingUser, nil
}
func (module *Module) UpdateAnyUser(ctx context.Context, orgID valuer.UUID, user *types.User) error {
storableUser := types.NewStorableUser(user)
if err := module.store.UpdateUser(ctx, orgID, storableUser); err != nil {
if err := module.store.UpdateUser(ctx, orgID, user); err != nil {
return err
}
@@ -467,7 +366,7 @@ func (module *Module) UpdateAnyUser(ctx context.Context, orgID valuer.UUID, user
}
func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id string, deletedBy string) error {
user, err := module.GetByOrgIDAndUserID(ctx, orgID, valuer.MustNewUUID(id))
user, err := module.store.GetUser(ctx, valuer.MustNewUUID(id))
if err != nil {
return err
}
@@ -485,17 +384,17 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
}
// don't allow to delete the last admin user
adminUsers, err := module.store.GetActiveUsersByRoleNameAndOrgID(ctx, authtypes.SigNozAdminRoleName, orgID)
adminUsers, err := module.store.GetActiveUsersByRoleAndOrgID(ctx, types.RoleAdmin, orgID)
if err != nil {
return err
}
if len(adminUsers) == 1 && slices.Contains(user.Roles, authtypes.SigNozAdminRoleName) {
if len(adminUsers) == 1 && user.Role == types.RoleAdmin {
return errors.New(errors.TypeForbidden, errors.CodeForbidden, "cannot delete the last admin")
}
// since revoke is idempotant multiple calls to revoke won't cause issues in case of retries
err = module.authz.Revoke(ctx, orgID, user.Roles, authtypes.MustNewSubject(authtypes.TypeableUser, id, orgID, nil))
err = module.authz.Revoke(ctx, orgID, []string{authtypes.MustGetSigNozManagedRoleFromExistingRole(user.Role)}, authtypes.MustNewSubject(authtypes.TypeableUser, id, orgID, nil))
if err != nil {
return err
}
@@ -512,8 +411,8 @@ func (module *Module) DeleteUser(ctx context.Context, orgID valuer.UUID, id stri
return nil
}
func (module *Module) GetOrCreateResetPasswordToken(ctx context.Context, orgID, userID valuer.UUID) (*types.ResetPasswordToken, error) {
user, err := module.GetByOrgIDAndUserID(ctx, orgID, userID)
func (module *Module) GetOrCreateResetPasswordToken(ctx context.Context, userID valuer.UUID) (*types.ResetPasswordToken, error) {
user, err := module.store.GetUser(ctx, userID)
if err != nil {
return nil, err
}
@@ -596,7 +495,7 @@ func (module *Module) ForgotPassword(ctx context.Context, orgID valuer.UUID, ema
return errors.WithAdditionalf(err, "cannot reset password for root user")
}
token, err := module.GetOrCreateResetPasswordToken(ctx, orgID, user.ID)
token, err := module.GetOrCreateResetPasswordToken(ctx, user.ID)
if err != nil {
module.settings.Logger().ErrorContext(ctx, "failed to create reset password token", "error", err)
return err
@@ -642,17 +541,17 @@ func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, to
return err
}
storableUser, err := module.store.GetUser(ctx, valuer.MustNewUUID(password.UserID))
user, err := module.store.GetUser(ctx, valuer.MustNewUUID(password.UserID))
if err != nil {
return err
}
// handle deleted user
if err := storableUser.ErrIfDeleted(); err != nil {
if err := user.ErrIfDeleted(); err != nil {
return errors.WithAdditionalf(err, "deleted users cannot reset their password")
}
if err := storableUser.ErrIfRoot(); err != nil {
if err := user.ErrIfRoot(); err != nil {
return errors.WithAdditionalf(err, "cannot reset password for root user")
}
@@ -660,19 +559,12 @@ func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, to
return err
}
roleNames, err := module.resolveRoleNamesForUser(ctx, storableUser.ID, storableUser.OrgID)
if err != nil {
return err
}
user := types.NewUserFromStorable(storableUser, roleNames)
// since grant is idempotent, multiple calls won't cause issues in case of retries
if user.Status == types.UserStatusPendingInvite {
if err = module.authz.Grant(
ctx,
user.OrgID,
user.Roles,
[]string{authtypes.MustGetSigNozManagedRoleFromExistingRole(user.Role)},
authtypes.MustNewSubject(authtypes.TypeableUser, user.ID.StringValue(), user.OrgID, nil),
); err != nil {
return err
@@ -684,7 +576,7 @@ func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, to
if err := user.UpdateStatus(types.UserStatusActive); err != nil {
return err
}
if err := module.store.UpdateUser(ctx, user.OrgID, types.NewStorableUser(user)); err != nil {
if err := module.store.UpdateUser(ctx, user.OrgID, user); err != nil {
return err
}
}
@@ -702,16 +594,16 @@ func (module *Module) UpdatePasswordByResetPasswordToken(ctx context.Context, to
}
func (module *Module) UpdatePassword(ctx context.Context, userID valuer.UUID, oldpasswd string, passwd string) error {
storableUser, err := module.store.GetUser(ctx, userID)
user, err := module.store.GetUser(ctx, userID)
if err != nil {
return err
}
if err := storableUser.ErrIfDeleted(); err != nil {
if err := user.ErrIfDeleted(); err != nil {
return errors.WithAdditionalf(err, "cannot change password for deleted user")
}
if err := storableUser.ErrIfRoot(); err != nil {
if err := user.ErrIfRoot(); err != nil {
return errors.WithAdditionalf(err, "cannot change password for root user")
}
@@ -756,12 +648,10 @@ func (module *Module) GetOrCreateUser(ctx context.Context, user *types.User, opt
if existingUser != nil {
// for users logging through SSO flow but are having status as pending_invite
if existingUser.Status == types.UserStatusPendingInvite {
// capture old roles before overwriting with SSO roles
oldRoles := existingUser.Roles
// respect the role coming from the SSO
existingUser.Update("", user.Role, user.Roles)
existingUser.Update("", user.Role)
// activate the user
if err = module.activatePendingUser(ctx, existingUser, oldRoles); err != nil {
if err = module.activatePendingUser(ctx, existingUser); err != nil {
return nil, err
}
}
@@ -798,7 +688,7 @@ func (m *Module) RevokeAPIKey(ctx context.Context, id, removedByUserID valuer.UU
}
func (module *Module) CreateFirstUser(ctx context.Context, organization *types.Organization, name string, email valuer.Email, passwd string) (*types.User, error) {
user, err := types.NewRootUser(name, email, organization.ID, []string{authtypes.SigNozAdminRoleName})
user, err := types.NewRootUser(name, email, organization.ID)
if err != nil {
return nil, err
}
@@ -860,24 +750,20 @@ func (module *Module) Collect(ctx context.Context, orgID valuer.UUID) (map[strin
// this function restricts that only one non-deleted user email can exist for an org ID, if found more, it throws an error
func (module *Module) GetNonDeletedUserByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) (*types.User, error) {
existingStorableUsers, err := module.store.GetUsersByEmailAndOrgID(ctx, email, orgID)
existingUsers, err := module.store.GetUsersByEmailAndOrgID(ctx, email, orgID)
if err != nil {
return nil, err
}
// filter out the deleted users
existingStorableUsers = slices.DeleteFunc(existingStorableUsers, func(user *types.StorableUser) bool { return user.ErrIfDeleted() != nil })
existingUsers = slices.DeleteFunc(existingUsers, func(user *types.User) bool { return user.ErrIfDeleted() != nil })
if len(existingStorableUsers) > 1 {
if len(existingUsers) > 1 {
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "Multiple non-deleted users found for email %s in org_id: %s", email.StringValue(), orgID.StringValue())
}
if len(existingStorableUsers) == 1 {
existingUser, err := module.GetByOrgIDAndUserID(ctx, existingStorableUsers[0].OrgID, existingStorableUsers[0].ID)
if err != nil {
return nil, err
}
return existingUser, nil
if len(existingUsers) == 1 {
return existingUsers[0], nil
}
return nil, errors.Newf(errors.TypeNotFound, errors.CodeNotFound, "No non-deleted user found with email %s in org_id: %s", email.StringValue(), orgID.StringValue())
@@ -887,12 +773,7 @@ func (module *Module) GetNonDeletedUserByEmailAndOrgID(ctx context.Context, emai
func (module *Module) createUserWithoutGrant(ctx context.Context, input *types.User, opts ...root.CreateUserOption) error {
createUserOpts := root.NewCreateUserOptions(opts...)
if err := module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.CreateUser(ctx, types.NewStorableUser(input)); err != nil {
return err
}
// create user_role junction entries
if err := module.createUserRoleEntries(ctx, input); err != nil {
if err := module.store.CreateUser(ctx, input); err != nil {
return err
}
@@ -914,27 +795,11 @@ func (module *Module) createUserWithoutGrant(ctx context.Context, input *types.U
return nil
}
func (module *Module) createUserRoleEntries(ctx context.Context, user *types.User) error {
if len(user.Roles) == 0 {
return nil
}
storableRoles, err := module.authz.ListByOrgIDAndNames(ctx, user.OrgID, user.Roles)
if err != nil {
return err
}
userRoles := authtypes.NewStorableUserRoles(user.ID, storableRoles)
return module.userRoleStore.CreateUserRoles(ctx, userRoles)
}
func (module *Module) activatePendingUser(ctx context.Context, user *types.User, oldRoles []string) error {
// use ModifyGrant to revoke old invite roles and grant new SSO roles
err := module.authz.ModifyGrant(
func (module *Module) activatePendingUser(ctx context.Context, user *types.User) error {
err := module.authz.Grant(
ctx,
user.OrgID,
oldRoles,
user.Roles,
[]string{authtypes.MustGetSigNozManagedRoleFromExistingRole(user.Role)},
authtypes.MustNewSubject(authtypes.TypeableUser, user.ID.StringValue(), user.OrgID, nil),
)
if err != nil {
@@ -944,66 +809,10 @@ func (module *Module) activatePendingUser(ctx context.Context, user *types.User,
if err := user.UpdateStatus(types.UserStatusActive); err != nil {
return err
}
return module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.UpdateUser(ctx, user.OrgID, types.NewStorableUser(user)); err != nil {
return err
}
// delete old invite role entries and create new ones from SSO
if err := module.userRoleStore.DeleteUserRoles(ctx, user.ID); err != nil {
return err
}
return module.createUserRoleEntries(ctx, user)
})
}
func (module *Module) usersFromStorableUsersAndRolesMaps(storableUsers []*types.StorableUser, roles []*authtypes.Role, userIDToRoleIDsMap map[valuer.UUID][]valuer.UUID) []*types.User {
users := make([]*types.User, 0, len(storableUsers))
roleIDToRole := make(map[string]*authtypes.Role, len(roles))
for _, role := range roles {
roleIDToRole[role.ID.String()] = role
}
for _, user := range storableUsers {
roleIDs := userIDToRoleIDsMap[user.ID]
roleNames := make([]string, 0, len(roleIDs))
for _, rid := range roleIDs {
if role, ok := roleIDToRole[rid.String()]; ok {
roleNames = append(roleNames, role.Name)
}
}
account := types.NewUserFromStorable(user, roleNames)
users = append(users, account)
}
return users
}
func (m *Module) resolveRoleNamesForUser(ctx context.Context, userID valuer.UUID, orgID valuer.UUID) ([]string, error) {
storableUserRoles, err := m.userRoleStore.GetUserRolesByUserID(ctx, userID)
err = module.store.UpdateUser(ctx, user.OrgID, user)
if err != nil {
return nil, err
return err
}
roleIDs := make([]valuer.UUID, len(storableUserRoles))
for idx, sur := range storableUserRoles {
roleIDs[idx] = sur.RoleID
}
roles, err := m.authz.ListByOrgIDAndIDs(ctx, orgID, roleIDs)
if err != nil {
return nil, err
}
roleNames := make([]string, len(roles))
for idx, role := range roles {
roleNames[idx] = role.Name
}
return roleNames, nil
return nil
}

View File

@@ -15,34 +15,31 @@ import (
)
type service struct {
settings factory.ScopedProviderSettings
store types.UserStore
userRoleStore authtypes.UserRoleStore
module user.Module
orgGetter organization.Getter
authz authz.AuthZ
config user.RootConfig
stopC chan struct{}
settings factory.ScopedProviderSettings
store types.UserStore
module user.Module
orgGetter organization.Getter
authz authz.AuthZ
config user.RootConfig
stopC chan struct{}
}
func NewService(
providerSettings factory.ProviderSettings,
store types.UserStore,
userRoleStore authtypes.UserRoleStore,
module user.Module,
orgGetter organization.Getter,
authz authz.AuthZ,
config user.RootConfig,
) user.Service {
return &service{
settings: factory.NewScopedProviderSettings(providerSettings, "go.signoz.io/pkg/modules/user"),
store: store,
userRoleStore: userRoleStore,
module: module,
orgGetter: orgGetter,
authz: authz,
config: config,
stopC: make(chan struct{}),
settings: factory.NewScopedProviderSettings(providerSettings, "go.signoz.io/pkg/modules/user"),
store: store,
module: module,
orgGetter: orgGetter,
authz: authz,
config: config,
stopC: make(chan struct{}),
}
}
@@ -80,33 +77,59 @@ func (s *service) Stop(ctx context.Context) error {
}
func (s *service) reconcile(ctx context.Context) error {
org, resolvedByName, err := s.orgGetter.GetByIDOrName(ctx, s.config.Org.ID, s.config.Org.Name)
if !s.config.Org.ID.IsZero() {
return s.reconcileWithOrgID(ctx)
}
return s.reconcileByName(ctx)
}
func (s *service) reconcileWithOrgID(ctx context.Context) error {
org, err := s.orgGetter.Get(ctx, s.config.Org.ID)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
return err // something really went wrong
}
if s.config.Org.ID.IsZero() {
newOrg := types.NewOrganization(s.config.Org.Name, s.config.Org.Name)
_, err := s.module.CreateFirstUser(ctx, newOrg, s.config.Email.String(), s.config.Email, s.config.Password)
return err
// org was not found using id check if we can find an org using name
existingOrgByName, nameErr := s.orgGetter.GetByName(ctx, s.config.Org.Name)
if nameErr != nil && !errors.Ast(nameErr, errors.TypeNotFound) {
return nameErr // something really went wrong
}
// we found an org using name
if existingOrgByName != nil {
// the existing org has the same name as config but org id is different inform user with actionable message
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "organization with name %q already exists with a different ID %s (expected %s)", s.config.Org.Name, existingOrgByName.ID.StringValue(), s.config.Org.ID.StringValue())
}
// default - we did not found any org using id and name both - create a new org
newOrg := types.NewOrganizationWithID(s.config.Org.ID, s.config.Org.Name, s.config.Org.Name)
_, err = s.module.CreateFirstUser(ctx, newOrg, s.config.Email.String(), s.config.Email, s.config.Password)
return err
}
if !s.config.Org.ID.IsZero() && resolvedByName {
// the existing org has the same name as config but org id is different; inform user with actionable message
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "organization with name %q already exists with a different ID %s (expected %s)", s.config.Org.Name, org.ID.StringValue(), s.config.Org.ID.StringValue())
return s.reconcileRootUser(ctx, org.ID)
}
func (s *service) reconcileByName(ctx context.Context) error {
org, err := s.orgGetter.GetByName(ctx, s.config.Org.Name)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
newOrg := types.NewOrganization(s.config.Org.Name, s.config.Org.Name)
_, err := s.module.CreateFirstUser(ctx, newOrg, s.config.Email.String(), s.config.Email, s.config.Password)
return err
}
return err
}
return s.reconcileRootUser(ctx, org.ID)
}
func (s *service) reconcileRootUser(ctx context.Context, orgID valuer.UUID) error {
existingRoot, err := s.getRootUserByOrgID(ctx, orgID)
existingRoot, err := s.store.GetRootUserByOrgID(ctx, orgID)
if err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}
@@ -125,49 +148,29 @@ func (s *service) createOrPromoteRootUser(ctx context.Context, orgID valuer.UUID
}
if existingUser != nil {
oldRoles := existingUser.Roles
oldRole := existingUser.Role
existingUser.PromoteToRoot() // this only sets the column is_root as true (permissions are managed by authz in next step)
existingUser.Roles = []string{authtypes.SigNozAdminRoleName}
// authz grant is idempotent and safe to retry, so do it before DB mutations
if err := s.authz.ModifyGrant(ctx,
orgID,
oldRoles,
[]string{authtypes.SigNozAdminRoleName},
authtypes.MustNewSubject(authtypes.TypeableUser, existingUser.ID.StringValue(), orgID, nil),
); err != nil {
return err
}
// this is idempotent
existingUser.PromoteToRoot()
if err := s.module.UpdateAnyUser(ctx, orgID, existingUser); err != nil {
return err
}
// resolve the admin role ID for user_role entries
storableRoles, err := s.authz.ListByOrgIDAndNames(ctx, orgID, []string{authtypes.SigNozAdminRoleName})
if err != nil {
return err
if oldRole != types.RoleAdmin {
if err := s.authz.ModifyGrant(ctx,
orgID,
[]string{authtypes.MustGetSigNozManagedRoleFromExistingRole(oldRole)},
[]string{authtypes.MustGetSigNozManagedRoleFromExistingRole(types.RoleAdmin)},
authtypes.MustNewSubject(authtypes.TypeableUser, existingUser.ID.StringValue(), orgID, nil),
); err != nil {
return err
}
}
// wrap user_role updates and password in a transaction
return s.store.RunInTx(ctx, func(ctx context.Context) error {
if err := s.userRoleStore.DeleteUserRoles(ctx, existingUser.ID); err != nil {
return err
}
userRoles := authtypes.NewStorableUserRoles(existingUser.ID, storableRoles)
if err := s.userRoleStore.CreateUserRoles(ctx, userRoles); err != nil {
return err
}
return s.setPassword(ctx, existingUser.ID)
})
return s.setPassword(ctx, existingUser.ID)
}
// Create new root user
newUser, err := types.NewRootUser(s.config.Email.String(), s.config.Email, orgID, []string{authtypes.SigNozAdminRoleName})
newUser, err := types.NewRootUser(s.config.Email.String(), s.config.Email, orgID)
if err != nil {
return err
}
@@ -177,7 +180,6 @@ func (s *service) createOrPromoteRootUser(ctx context.Context, orgID valuer.UUID
return err
}
// authz grants are handled inside CreateUser
return s.module.CreateUser(ctx, newUser, user.WithFactorPassword(factorPassword))
}
@@ -219,12 +221,3 @@ func (s *service) setPassword(ctx context.Context, userID valuer.UUID) error {
return nil
}
func (s *service) getRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (*types.User, error) {
storableRoot, err := s.store.GetRootUserByOrgID(ctx, orgID)
if err != nil {
return nil, err
}
return s.module.GetByOrgIDAndUserID(ctx, orgID, storableRoot.ID)
}

View File

@@ -39,7 +39,7 @@ func (store *store) CreatePassword(ctx context.Context, password *types.FactorPa
return nil
}
func (store *store) CreateUser(ctx context.Context, user *types.StorableUser) error {
func (store *store) CreateUser(ctx context.Context, user *types.User) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
@@ -52,8 +52,8 @@ func (store *store) CreateUser(ctx context.Context, user *types.StorableUser) er
return nil
}
func (store *store) GetUsersByEmail(ctx context.Context, email valuer.Email) ([]*types.StorableUser, error) {
var users []*types.StorableUser
func (store *store) GetUsersByEmail(ctx context.Context, email valuer.Email) ([]*types.User, error) {
var users []*types.User
err := store.
sqlstore.
@@ -69,8 +69,8 @@ func (store *store) GetUsersByEmail(ctx context.Context, email valuer.Email) ([]
return users, nil
}
func (store *store) GetUser(ctx context.Context, id valuer.UUID) (*types.StorableUser, error) {
user := new(types.StorableUser)
func (store *store) GetUser(ctx context.Context, id valuer.UUID) (*types.User, error) {
user := new(types.User)
err := store.
sqlstore.
@@ -86,8 +86,8 @@ func (store *store) GetUser(ctx context.Context, id valuer.UUID) (*types.Storabl
return user, nil
}
func (store *store) GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*types.StorableUser, error) {
user := new(types.StorableUser)
func (store *store) GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*types.User, error) {
user := new(types.User)
err := store.
sqlstore.
@@ -104,8 +104,8 @@ func (store *store) GetByOrgIDAndID(ctx context.Context, orgID valuer.UUID, id v
return user, nil
}
func (store *store) GetUsersByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) ([]*types.StorableUser, error) {
var users []*types.StorableUser
func (store *store) GetUsersByEmailAndOrgID(ctx context.Context, email valuer.Email, orgID valuer.UUID) ([]*types.User, error) {
var users []*types.User
err := store.
sqlstore.
@@ -122,7 +122,26 @@ func (store *store) GetUsersByEmailAndOrgID(ctx context.Context, email valuer.Em
return users, nil
}
func (store *store) UpdateUser(ctx context.Context, orgID valuer.UUID, user *types.StorableUser) error {
func (store *store) GetActiveUsersByRoleAndOrgID(ctx context.Context, role types.Role, orgID valuer.UUID) ([]*types.User, error) {
var users []*types.User
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&users).
Where("org_id = ?", orgID).
Where("role = ?", role).
Where("status = ?", types.UserStatusActive.StringValue()).
Scan(ctx)
if err != nil {
return nil, err
}
return users, nil
}
func (store *store) UpdateUser(ctx context.Context, orgID valuer.UUID, user *types.User) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
@@ -143,8 +162,8 @@ func (store *store) UpdateUser(ctx context.Context, orgID valuer.UUID, user *typ
return nil
}
func (store *store) ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]*types.StorableUser, error) {
users := []*types.StorableUser{}
func (store *store) ListUsersByOrgID(ctx context.Context, orgID valuer.UUID) ([]*types.GettableUser, error) {
users := []*types.User{}
err := store.
sqlstore.
@@ -228,7 +247,7 @@ func (store *store) DeleteUser(ctx context.Context, orgID string, id string) err
// delete user
_, err = tx.NewDelete().
Model(new(types.StorableUser)).
Model(new(types.User)).
Where("org_id = ?", orgID).
Where("id = ?", id).
Exec(ctx)
@@ -313,7 +332,7 @@ func (store *store) SoftDeleteUser(ctx context.Context, orgID string, id string)
// soft delete user
now := time.Now()
_, err = tx.NewUpdate().
Model(new(types.StorableUser)).
Model(new(types.User)).
Set("status = ?", types.UserStatusDeleted).
Set("deleted_at = ?", now).
Set("updated_at = ?", now).
@@ -544,7 +563,7 @@ func (store *store) GetAPIKey(ctx context.Context, orgID, id valuer.UUID) (*type
}
func (store *store) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64, error) {
user := new(types.StorableUser)
user := new(types.User)
count, err := store.
sqlstore.
@@ -561,7 +580,7 @@ func (store *store) CountByOrgID(ctx context.Context, orgID valuer.UUID) (int64,
}
func (store *store) CountByOrgIDAndStatuses(ctx context.Context, orgID valuer.UUID, statuses []string) (map[valuer.String]int64, error) {
user := new(types.StorableUser)
user := new(types.User)
var results []struct {
Status valuer.String `bun:"status"`
Count int64 `bun:"count"`
@@ -614,8 +633,8 @@ func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) er
})
}
func (store *store) GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (*types.StorableUser, error) {
user := new(types.StorableUser)
func (store *store) GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (*types.User, error) {
user := new(types.User)
err := store.
sqlstore.
BunDBCtx(ctx).
@@ -630,8 +649,8 @@ func (store *store) GetRootUserByOrgID(ctx context.Context, orgID valuer.UUID) (
return user, nil
}
func (store *store) ListUsersByEmailAndOrgIDs(ctx context.Context, email valuer.Email, orgIDs []valuer.UUID) ([]*types.StorableUser, error) {
users := []*types.StorableUser{}
func (store *store) ListUsersByEmailAndOrgIDs(ctx context.Context, email valuer.Email, orgIDs []valuer.UUID) ([]*types.User, error) {
users := []*types.User{}
err := store.
sqlstore.
BunDB().
@@ -647,15 +666,15 @@ func (store *store) ListUsersByEmailAndOrgIDs(ctx context.Context, email valuer.
return users, nil
}
func (store *store) GetUserByResetPasswordToken(ctx context.Context, token string) (*types.StorableUser, error) {
user := new(types.StorableUser)
func (store *store) GetUserByResetPasswordToken(ctx context.Context, token string) (*types.User, error) {
user := new(types.User)
err := store.
sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(user).
Join(`JOIN factor_password ON factor_password.user_id = "users".id`).
Join(`JOIN factor_password ON factor_password.user_id = "user".id`).
Join("JOIN reset_password_token ON reset_password_token.password_id = factor_password.id").
Where("reset_password_token.token = ?", token).
Scan(ctx)
@@ -666,8 +685,8 @@ func (store *store) GetUserByResetPasswordToken(ctx context.Context, token strin
return user, nil
}
func (store *store) GetUsersByEmailsOrgIDAndStatuses(ctx context.Context, orgID valuer.UUID, emails []string, statuses []string) ([]*types.StorableUser, error) {
users := []*types.StorableUser{}
func (store *store) GetUsersByEmailsOrgIDAndStatuses(ctx context.Context, orgID valuer.UUID, emails []string, statuses []string) ([]*types.User, error) {
users := []*types.User{}
err := store.
sqlstore.
@@ -684,20 +703,3 @@ func (store *store) GetUsersByEmailsOrgIDAndStatuses(ctx context.Context, orgID
return users, nil
}
func (store *store) GetActiveUsersByRoleNameAndOrgID(ctx context.Context, roleName string, orgID valuer.UUID) ([]*types.StorableUser, error) {
var users []*types.StorableUser
err := store.sqlstore.BunDBCtx(ctx).NewSelect().
Model(&users).
Join("JOIN user_role ON user_role.user_id = users.id").
Join("JOIN role ON role.id = user_role.role_id").
Where("users.org_id = ?", orgID).
Where("role.name = ?", roleName).
Where("users.status = ?", types.UserStatusActive.StringValue()).
Scan(ctx)
if err != nil {
return nil, err
}
return users, nil
}

View File

@@ -1,62 +0,0 @@
package impluser
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
type userRoleStore struct {
sqlstore sqlstore.SQLStore
settings factory.ProviderSettings
}
func NewUserRoleStore(sqlstore sqlstore.SQLStore, settings factory.ProviderSettings) authtypes.UserRoleStore {
return &userRoleStore{sqlstore: sqlstore, settings: settings}
}
func (store *userRoleStore) ListUserRolesByOrgIDAndUserIDs(ctx context.Context, orgID valuer.UUID, userIDs []valuer.UUID) ([]*authtypes.StorableUserRole, error) {
storableUserRoles := make([]*authtypes.StorableUserRole, 0)
err := store.sqlstore.BunDBCtx(ctx).NewSelect().Model(&storableUserRoles).
Join("JOIN users").
JoinOn("users.id = user_role.user_id").
Where("users.org_id = ?", orgID).Where("users.id IN (?)", bun.In(userIDs)).Scan(ctx)
if err != nil {
return nil, err
}
return storableUserRoles, nil
}
func (store *userRoleStore) CreateUserRoles(ctx context.Context, userRoles []*authtypes.StorableUserRole) error {
_, err := store.sqlstore.BunDBCtx(ctx).NewInsert().Model(&userRoles).Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, authtypes.ErrCodeUserRoleAlreadyExists, "duplicate role assignments for service account")
}
return nil
}
func (store *userRoleStore) DeleteUserRoles(ctx context.Context, userID valuer.UUID) error {
_, err := store.sqlstore.BunDBCtx(ctx).NewDelete().Model(new(authtypes.StorableUserRole)).Where("user_id = ?", userID).Exec(ctx)
if err != nil {
return err
}
return nil
}
func (store *userRoleStore) GetUserRolesByUserID(ctx context.Context, userID valuer.UUID) ([]*authtypes.StorableUserRole, error) {
storableUserRoles := make([]*authtypes.StorableUserRole, 0)
err := store.sqlstore.BunDBCtx(ctx).NewSelect().Model(&storableUserRoles).Where("user_id = ?", userID).Scan(ctx)
if err != nil {
return nil, err
}
return storableUserRoles, nil
}

View File

@@ -10,9 +10,6 @@ import (
)
type Module interface {
// Gets user by org id and user id, this includes the roles resolution
GetByOrgIDAndUserID(ctx context.Context, orgID, userID valuer.UUID) (*types.User, error)
// Creates the organization and the first user of that organization.
CreateFirstUser(ctx context.Context, organization *types.Organization, name string, email valuer.Email, password string) (*types.User, error)
@@ -24,7 +21,7 @@ type Module interface {
// Get or Create a reset password token for a user. If the password does not exist, a new one is randomly generated and inserted. The function
// is idempotent and can be called multiple times.
GetOrCreateResetPasswordToken(ctx context.Context, orgID, userID valuer.UUID) (*types.ResetPasswordToken, error)
GetOrCreateResetPasswordToken(ctx context.Context, userID valuer.UUID) (*types.ResetPasswordToken, error)
// Updates password of a user using a reset password token. It also deletes all reset password tokens for the user.
// This is used to reset the password of a user when they forget their password.
@@ -67,6 +64,12 @@ type Getter interface {
// Get gets the users based on the given id
ListByOrgID(context.Context, valuer.UUID) ([]*types.User, error)
// Get users by email.
GetUsersByEmail(context.Context, valuer.Email) ([]*types.User, error)
// Get user by orgID and id.
GetByOrgIDAndID(context.Context, valuer.UUID, valuer.UUID) (*types.User, error)
// Get user by id.
Get(context.Context, valuer.UUID) (*types.User, error)

View File

@@ -10,11 +10,13 @@ import (
"github.com/ClickHouse/clickhouse-go/v2"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/telemetrylogs"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/bytedance/sonic"
)
type builderQuery[T any] struct {
@@ -260,6 +262,40 @@ func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string,
return nil, err
}
// merge body_json and promoted into body
if q.spec.Signal == telemetrytypes.SignalLogs {
switch typedPayload := payload.(type) {
case *qbtypes.RawData:
for _, rr := range typedPayload.Rows {
seeder := func() error {
body, ok := rr.Data[telemetrylogs.LogsV2BodyJSONColumn].(map[string]any)
if !ok {
return nil
}
promoted, ok := rr.Data[telemetrylogs.LogsV2BodyPromotedColumn].(map[string]any)
if !ok {
return nil
}
seed(promoted, body)
str, err := sonic.MarshalString(body)
if err != nil {
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "failed to marshal body")
}
rr.Data["body"] = str
return nil
}
err := seeder()
if err != nil {
return nil, err
}
delete(rr.Data, telemetrylogs.LogsV2BodyJSONColumn)
delete(rr.Data, telemetrylogs.LogsV2BodyPromotedColumn)
}
payload = typedPayload
}
}
return &qbtypes.Result{
Type: q.kind,
Value: payload,
@@ -387,3 +423,18 @@ func decodeCursor(cur string) (int64, error) {
}
return strconv.ParseInt(string(b), 10, 64)
}
func seed(promoted map[string]any, body map[string]any) {
for key, fromValue := range promoted {
if toValue, ok := body[key]; !ok {
body[key] = fromValue
} else {
if fromValue, ok := fromValue.(map[string]any); ok {
if toValue, ok := toValue.(map[string]any); ok {
seed(fromValue, toValue)
body[key] = toValue
}
}
}
}
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/bytedance/sonic"
)
var (
@@ -393,11 +394,17 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
// de-reference the typed pointer to any
val := reflect.ValueOf(cellPtr).Elem().Interface()
// Post-process JSON columns: normalize into String value
// Post-process JSON columns: normalize into structured values
if strings.HasPrefix(strings.ToUpper(colTypes[i].DatabaseTypeName()), "JSON") {
switch x := val.(type) {
case []byte:
val = string(x)
if len(x) > 0 {
var v any
if err := sonic.Unmarshal(x, &v); err == nil {
val = v
}
}
default:
// already a structured type (map[string]any, []any, etc.)
}

View File

@@ -177,7 +177,7 @@ func (r *cloudProviderAccountsSQLRepository) upsert(
onConflictClause := ""
if len(onConflictSetStmts) > 0 {
onConflictClause = fmt.Sprintf(
"conflict(id) do update SET\n%s",
"conflict(id, provider, org_id) do update SET\n%s",
strings.Join(onConflictSetStmts, ",\n"),
)
}
@@ -202,8 +202,6 @@ 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,
))

View File

@@ -131,38 +131,32 @@ func (ic *LogParsingPipelineController) ValidatePipelines(ctx context.Context,
return err
}
func (ic *LogParsingPipelineController) getDefaultPipelines() ([]pipelinetypes.GettablePipeline, error) {
defaultPipelines := []pipelinetypes.GettablePipeline{}
if querybuilder.BodyJSONQueryEnabled {
preprocessingPipeline := pipelinetypes.GettablePipeline{
StoreablePipeline: pipelinetypes.StoreablePipeline{
Name: "Default Pipeline - PreProcessing Body",
Alias: "NormalizeBodyDefault",
Enabled: true,
},
Filter: &v3.FilterSet{
Items: []v3.FilterItem{
{
Key: v3.AttributeKey{
Key: "body",
},
Operator: v3.FilterOperatorExists,
},
},
},
Config: []pipelinetypes.PipelineOperator{
func (ic *LogParsingPipelineController) getNormalizePipeline() pipelinetypes.GettablePipeline {
return pipelinetypes.GettablePipeline{
StoreablePipeline: pipelinetypes.StoreablePipeline{
Name: "Default Pipeline - PreProcessing Body",
Alias: "NormalizeBodyDefault",
Enabled: true,
},
Filter: &v3.FilterSet{
Items: []v3.FilterItem{
{
ID: uuid.NewString(),
Type: "normalize",
Enabled: true,
If: "body != nil",
Key: v3.AttributeKey{
Key: "body",
},
Operator: v3.FilterOperatorExists,
},
},
}
defaultPipelines = append(defaultPipelines, preprocessingPipeline)
},
Config: []pipelinetypes.PipelineOperator{
{
ID: uuid.NewString(),
Type: "normalize",
Enabled: true,
If: "body != nil",
},
},
}
return defaultPipelines, nil
}
// Returns effective list of pipelines including user created
@@ -295,12 +289,10 @@ func (pc *LogParsingPipelineController) RecommendAgentConfig(
return nil, "", err
}
// recommend default pipelines along with user created pipelines
defaultPipelines, err := pc.getDefaultPipelines()
if err != nil {
return nil, "", model.InternalError(fmt.Errorf("failed to get default pipelines: %w", err))
if querybuilder.BodyJSONQueryEnabled {
// add default normalize pipeline at the beginning
pipelinesResp.Pipelines = append([]pipelinetypes.GettablePipeline{pc.getNormalizePipeline()}, pipelinesResp.Pipelines...)
}
pipelinesResp.Pipelines = append(pipelinesResp.Pipelines, defaultPipelines...)
updatedConf, err := GenerateCollectorConfigWithPipelines(currentConfYaml, pipelinesResp.Pipelines)
if err != nil {

View File

@@ -7,14 +7,12 @@ import (
"sync"
"time"
"log/slog"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
opentracing "github.com/opentracing/opentracing-go"
plabels "github.com/prometheus/prometheus/model/labels"
"log/slog"
)
// PromRuleTask is a promql rule executor
@@ -373,7 +371,7 @@ func (g *PromRuleTask) Eval(ctx context.Context, ts time.Time) {
comment := ctxtypes.CommentFromContext(ctx)
comment.Set("rule_id", rule.ID())
comment.Set("identn_provider", authtypes.IdentNProviderInternal.StringValue())
comment.Set("auth_type", "internal")
ctx = ctxtypes.NewContextWithComment(ctx, comment)
_, err := rule.Eval(ctx, ts)

View File

@@ -10,7 +10,6 @@ import (
"log/slog"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -359,7 +358,7 @@ func (g *RuleTask) Eval(ctx context.Context, ts time.Time) {
comment := ctxtypes.CommentFromContext(ctx)
comment.Set("rule_id", rule.ID())
comment.Set("identn_provider", authtypes.IdentNProviderInternal.StringValue())
comment.Set("auth_type", "internal")
ctx = ctxtypes.NewContextWithComment(ctx, comment)
_, err := rule.Eval(ctx, ts)

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