mirror of
https://github.com/SigNoz/signoz.git
synced 2026-02-13 04:42:54 +00:00
Compare commits
11 Commits
fix/unit_c
...
sso-mappin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a75ee7bdba | ||
|
|
1869e91155 | ||
|
|
e10c29399a | ||
|
|
9a89cb02b0 | ||
|
|
a49d7e1662 | ||
|
|
b3e41b5520 | ||
|
|
83bb97cc58 | ||
|
|
f41eb6f5b8 | ||
|
|
cad51403e5 | ||
|
|
370952b7f9 | ||
|
|
c57ab4d76a |
@@ -12,6 +12,8 @@ export interface MockUPlotInstance {
|
||||
export interface MockUPlotPaths {
|
||||
spline: jest.Mock;
|
||||
bars: jest.Mock;
|
||||
linear: jest.Mock;
|
||||
stepped: jest.Mock;
|
||||
}
|
||||
|
||||
// Create mock instance methods
|
||||
@@ -23,10 +25,23 @@ const createMockUPlotInstance = (): MockUPlotInstance => ({
|
||||
setSeries: jest.fn(),
|
||||
});
|
||||
|
||||
// Create mock paths
|
||||
const mockPaths: MockUPlotPaths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
|
||||
const createMockPathBuilder = (name: string): jest.Mock =>
|
||||
jest.fn(() => ({
|
||||
name, // To test if the correct pathBuilder is used
|
||||
stroke: jest.fn(),
|
||||
fill: jest.fn(),
|
||||
clip: jest.fn(),
|
||||
}));
|
||||
|
||||
// Create mock paths - linear, spline, stepped needed by UPlotSeriesBuilder.getPathBuilder
|
||||
const mockPaths = {
|
||||
spline: jest.fn(() => createMockPathBuilder('spline')),
|
||||
bars: jest.fn(() => createMockPathBuilder('bars')),
|
||||
linear: jest.fn(() => createMockPathBuilder('linear')),
|
||||
stepped: jest.fn((opts?: { align?: number }) =>
|
||||
createMockPathBuilder(`stepped-(${opts?.align ?? 0})`),
|
||||
),
|
||||
};
|
||||
|
||||
// Mock static methods
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
|
||||
const deleteDomain = async (id: string): Promise<SuccessResponseV2<null>> => {
|
||||
try {
|
||||
const response = await axios.delete<null>(`/domains/${id}`);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: null,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default deleteDomain;
|
||||
@@ -1,25 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { UpdatableAuthDomain } from 'types/api/v1/domains/put';
|
||||
|
||||
const put = async (
|
||||
props: UpdatableAuthDomain,
|
||||
): Promise<SuccessResponseV2<null>> => {
|
||||
try {
|
||||
const response = await axios.put<RawSuccessResponse<null>>(
|
||||
`/domains/${props.id}`,
|
||||
{ config: props.config },
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default put;
|
||||
@@ -1,24 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { GettableAuthDomain } from 'types/api/v1/domains/list';
|
||||
|
||||
const listAllDomain = async (): Promise<
|
||||
SuccessResponseV2<GettableAuthDomain[]>
|
||||
> => {
|
||||
try {
|
||||
const response = await axios.get<RawSuccessResponse<GettableAuthDomain[]>>(
|
||||
`/domains`,
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default listAllDomain;
|
||||
@@ -1,26 +0,0 @@
|
||||
import axios from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, RawSuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { GettableAuthDomain } from 'types/api/v1/domains/list';
|
||||
import { PostableAuthDomain } from 'types/api/v1/domains/post';
|
||||
|
||||
const post = async (
|
||||
props: PostableAuthDomain,
|
||||
): Promise<SuccessResponseV2<GettableAuthDomain>> => {
|
||||
try {
|
||||
const response = await axios.post<RawSuccessResponse<GettableAuthDomain>>(
|
||||
`/domains`,
|
||||
props,
|
||||
);
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
}
|
||||
};
|
||||
|
||||
export default post;
|
||||
@@ -83,7 +83,7 @@ export const prepareUPlotConfig = ({
|
||||
drawStyle: DrawStyle.Line,
|
||||
label: label,
|
||||
colorMapping: widget.customLegendColors ?? {},
|
||||
spanGaps: false,
|
||||
spanGaps: true,
|
||||
lineStyle: LineStyle.Solid,
|
||||
lineInterpolation: LineInterpolation.Spline,
|
||||
showPoints: VisibilityMode.Never,
|
||||
|
||||
@@ -88,7 +88,7 @@ export function buildBaseConfig({
|
||||
max: undefined,
|
||||
softMin: widget.softMin ?? undefined,
|
||||
softMax: widget.softMax ?? undefined,
|
||||
// thresholds,
|
||||
thresholds: thresholdOptions,
|
||||
logBase: widget.isLogScale ? 10 : undefined,
|
||||
distribution: widget.isLogScale
|
||||
? DistributionType.Logarithmic
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button, Form, Modal } from 'antd';
|
||||
import put from 'api/v1/domains/id/put';
|
||||
import post from 'api/v1/domains/post';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import {
|
||||
useCreateAuthDomain,
|
||||
useUpdateAuthDomain,
|
||||
} from 'api/generated/services/authdomains';
|
||||
import {
|
||||
AuthtypesGettableAuthDomainDTO,
|
||||
AuthtypesGoogleConfigDTO,
|
||||
AuthtypesOIDCConfigDTO,
|
||||
AuthtypesPostableAuthDomainDTO,
|
||||
AuthtypesRoleMappingDTO,
|
||||
AuthtypesSamlConfigDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { FeatureKeys } from 'constants/features';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { defaultTo } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { GettableAuthDomain } from 'types/api/v1/domains/list';
|
||||
import { PostableAuthDomain } from 'types/api/v1/domains/post';
|
||||
|
||||
import AuthnProviderSelector from './AuthnProviderSelector';
|
||||
import ConfigureGoogleAuthAuthnProvider from './Providers/AuthnGoogleAuth';
|
||||
@@ -20,7 +33,22 @@ import './CreateEdit.styles.scss';
|
||||
interface CreateOrEditProps {
|
||||
isCreate: boolean;
|
||||
onClose: () => void;
|
||||
record?: GettableAuthDomain;
|
||||
record?: AuthtypesGettableAuthDomainDTO;
|
||||
}
|
||||
|
||||
// Form values interface for internal use (includes array-based fields for UI)
|
||||
interface FormValues {
|
||||
name?: string;
|
||||
ssoEnabled?: boolean;
|
||||
ssoType?: string;
|
||||
googleAuthConfig?: AuthtypesGoogleConfigDTO & {
|
||||
domainToAdminEmailList?: Array<{ domain?: string; adminEmail?: string }>;
|
||||
};
|
||||
samlConfig?: AuthtypesSamlConfigDTO;
|
||||
oidcConfig?: AuthtypesOIDCConfigDTO;
|
||||
roleMapping?: AuthtypesRoleMappingDTO & {
|
||||
groupMappingsList?: Array<{ groupName?: string; role?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
function configureAuthnProvider(
|
||||
@@ -39,64 +67,299 @@ function configureAuthnProvider(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts groupMappingsList array to groupMappings Record for API
|
||||
*/
|
||||
function convertGroupMappingsToRecord(
|
||||
groupMappingsList?: Array<{ groupName?: string; role?: string }>,
|
||||
): Record<string, string> | undefined {
|
||||
if (!Array.isArray(groupMappingsList) || groupMappingsList.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const groupMappings: Record<string, string> = {};
|
||||
groupMappingsList.forEach((item) => {
|
||||
if (item.groupName && item.role) {
|
||||
groupMappings[item.groupName] = item.role;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(groupMappings).length > 0 ? groupMappings : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts groupMappings Record to groupMappingsList array for form
|
||||
*/
|
||||
function convertGroupMappingsToList(
|
||||
groupMappings?: Record<string, string> | null,
|
||||
): Array<{ groupName: string; role: string }> {
|
||||
if (!groupMappings) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(groupMappings).map(([groupName, role]) => ({
|
||||
groupName,
|
||||
role,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts domainToAdminEmailList array to domainToAdminEmail Record for API
|
||||
*/
|
||||
function convertDomainMappingsToRecord(
|
||||
domainToAdminEmailList?: Array<{ domain?: string; adminEmail?: string }>,
|
||||
): Record<string, string> | undefined {
|
||||
if (
|
||||
!Array.isArray(domainToAdminEmailList) ||
|
||||
domainToAdminEmailList.length === 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const domainToAdminEmail: Record<string, string> = {};
|
||||
domainToAdminEmailList.forEach((item) => {
|
||||
if (item.domain && item.adminEmail) {
|
||||
domainToAdminEmail[item.domain] = item.adminEmail;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(domainToAdminEmail).length > 0
|
||||
? domainToAdminEmail
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts domainToAdminEmail Record to domainToAdminEmailList array for form
|
||||
*/
|
||||
function convertDomainMappingsToList(
|
||||
domainToAdminEmail?: Record<string, string>,
|
||||
): Array<{ domain: string; adminEmail: string }> {
|
||||
if (!domainToAdminEmail) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(domainToAdminEmail).map(([domain, adminEmail]) => ({
|
||||
domain,
|
||||
adminEmail,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares initial form values from API record
|
||||
*/
|
||||
function prepareInitialValues(
|
||||
record?: AuthtypesGettableAuthDomainDTO,
|
||||
): FormValues {
|
||||
if (!record) {
|
||||
return {
|
||||
name: '',
|
||||
ssoEnabled: false,
|
||||
ssoType: '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
googleAuthConfig: record.googleAuthConfig
|
||||
? {
|
||||
...record.googleAuthConfig,
|
||||
domainToAdminEmailList: convertDomainMappingsToList(
|
||||
record.googleAuthConfig.domainToAdminEmail,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
roleMapping: record.roleMapping
|
||||
? {
|
||||
...record.roleMapping,
|
||||
groupMappingsList: convertGroupMappingsToList(
|
||||
record.roleMapping.groupMappings,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
const { isCreate, record, onClose } = props;
|
||||
const [form] = Form.useForm<PostableAuthDomain>();
|
||||
const [form] = Form.useForm<AuthtypesPostableAuthDomainDTO>();
|
||||
const [authnProvider, setAuthnProvider] = useState<string>(
|
||||
record?.ssoType || '',
|
||||
);
|
||||
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { featureFlags } = useAppContext();
|
||||
|
||||
const handleError = useCallback(
|
||||
(error: AxiosError<RenderErrorResponseDTO>): void => {
|
||||
try {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
} catch (apiError) {
|
||||
showErrorModal(apiError as APIError);
|
||||
}
|
||||
},
|
||||
[showErrorModal],
|
||||
);
|
||||
const samlEnabled =
|
||||
featureFlags?.find((flag) => flag.name === FeatureKeys.SSO)?.active || false;
|
||||
|
||||
const onSubmitHandler = async (): Promise<void> => {
|
||||
const {
|
||||
mutate: createAuthDomain,
|
||||
isLoading: isCreating,
|
||||
} = useCreateAuthDomain<AxiosError<RenderErrorResponseDTO>>();
|
||||
|
||||
const {
|
||||
mutate: updateAuthDomain,
|
||||
isLoading: isUpdating,
|
||||
} = useUpdateAuthDomain<AxiosError<RenderErrorResponseDTO>>();
|
||||
|
||||
/**
|
||||
* Prepares Google Auth config for API payload
|
||||
*/
|
||||
const getGoogleAuthConfig = useCallback(():
|
||||
| AuthtypesGoogleConfigDTO
|
||||
| undefined => {
|
||||
const config = form.getFieldValue('googleAuthConfig');
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { domainToAdminEmailList, ...rest } = config;
|
||||
const domainToAdminEmail = convertDomainMappingsToRecord(
|
||||
domainToAdminEmailList,
|
||||
);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
...(domainToAdminEmail && { domainToAdminEmail }),
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
/**
|
||||
* Prepares role mapping for API payload
|
||||
*/
|
||||
const getRoleMapping = useCallback((): AuthtypesRoleMappingDTO | undefined => {
|
||||
const roleMapping = form.getFieldValue('roleMapping');
|
||||
if (!roleMapping) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { groupMappingsList, ...rest } = roleMapping;
|
||||
const groupMappings = convertGroupMappingsToRecord(groupMappingsList);
|
||||
|
||||
// Only return roleMapping if there's meaningful content
|
||||
const hasDefaultRole = rest.defaultRole && rest.defaultRole !== 'VIEWER';
|
||||
const hasUseRoleAttribute = rest.useRoleAttribute === true;
|
||||
const hasGroupMappings =
|
||||
groupMappings && Object.keys(groupMappings).length > 0;
|
||||
|
||||
if (!hasDefaultRole && !hasUseRoleAttribute && !hasGroupMappings) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
...(groupMappings && { groupMappings }),
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
const onSubmitHandler = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
await form.validateFields();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = form.getFieldValue('name');
|
||||
const googleAuthConfig = form.getFieldValue('googleAuthConfig');
|
||||
const googleAuthConfig = getGoogleAuthConfig();
|
||||
const samlConfig = form.getFieldValue('samlConfig');
|
||||
const oidcConfig = form.getFieldValue('oidcConfig');
|
||||
const roleMapping = getRoleMapping();
|
||||
|
||||
try {
|
||||
if (isCreate) {
|
||||
await post({
|
||||
name,
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: authnProvider,
|
||||
googleAuthConfig,
|
||||
samlConfig,
|
||||
oidcConfig,
|
||||
if (isCreate) {
|
||||
createAuthDomain(
|
||||
{
|
||||
data: {
|
||||
name,
|
||||
config: {
|
||||
ssoEnabled: true,
|
||||
ssoType: authnProvider,
|
||||
googleAuthConfig,
|
||||
samlConfig,
|
||||
oidcConfig,
|
||||
roleMapping,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await put({
|
||||
id: record?.id || '',
|
||||
config: {
|
||||
ssoEnabled: form.getFieldValue('ssoEnabled'),
|
||||
ssoType: authnProvider,
|
||||
googleAuthConfig,
|
||||
samlConfig,
|
||||
oidcConfig,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifications.success({
|
||||
message: 'Domain created successfully',
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
onError: handleError,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
if (!record?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
updateAuthDomain(
|
||||
{
|
||||
pathParams: { id: record.id },
|
||||
data: {
|
||||
config: {
|
||||
ssoEnabled: form.getFieldValue('ssoEnabled'),
|
||||
ssoType: authnProvider,
|
||||
googleAuthConfig,
|
||||
samlConfig,
|
||||
oidcConfig,
|
||||
roleMapping,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifications.success({
|
||||
message: 'Domain updated successfully',
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
onError: handleError,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
authnProvider,
|
||||
createAuthDomain,
|
||||
form,
|
||||
getGoogleAuthConfig,
|
||||
getRoleMapping,
|
||||
handleError,
|
||||
isCreate,
|
||||
notifications,
|
||||
onClose,
|
||||
record,
|
||||
updateAuthDomain,
|
||||
]);
|
||||
|
||||
const onBackHandler = (): void => {
|
||||
const onBackHandler = useCallback((): void => {
|
||||
form.resetFields();
|
||||
setAuthnProvider('');
|
||||
};
|
||||
}, [form]);
|
||||
|
||||
return (
|
||||
<Modal open footer={null} onCancel={onClose}>
|
||||
<Modal
|
||||
open
|
||||
footer={null}
|
||||
onCancel={onClose}
|
||||
width={authnProvider ? 980 : undefined}
|
||||
>
|
||||
<Form
|
||||
name="auth-domain"
|
||||
initialValues={defaultTo(record, {
|
||||
initialValues={defaultTo(prepareInitialValues(record), {
|
||||
name: '',
|
||||
ssoEnabled: false,
|
||||
ssoType: '',
|
||||
@@ -116,7 +379,11 @@ function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
|
||||
<section className="action-buttons">
|
||||
{isCreate && <Button onClick={onBackHandler}>Back</Button>}
|
||||
{!isCreate && <Button onClick={onClose}>Cancel</Button>}
|
||||
<Button onClick={onSubmitHandler} type="primary">
|
||||
<Button
|
||||
onClick={onSubmitHandler}
|
||||
type="primary"
|
||||
loading={isCreating || isUpdating}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
@@ -1,20 +1,46 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { Form, Input, Typography } from 'antd';
|
||||
import { Checkbox } from '@signozhq/checkbox';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Collapse, Form, Tooltip } from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import { ChevronDown, ChevronRight, CircleHelp } from 'lucide-react';
|
||||
|
||||
import DomainMappingList from './components/DomainMappingList';
|
||||
import EmailTagInput from './components/EmailTagInput';
|
||||
import RoleMappingSection from './components/RoleMappingSection';
|
||||
|
||||
import './Providers.styles.scss';
|
||||
|
||||
type ExpandedSection = 'workspace-groups' | 'role-mapping' | null;
|
||||
|
||||
function ConfigureGoogleAuthAuthnProvider({
|
||||
isCreate,
|
||||
}: {
|
||||
isCreate: boolean;
|
||||
}): JSX.Element {
|
||||
const form = Form.useFormInstance();
|
||||
const fetchGroups = Form.useWatch(['googleAuthConfig', 'fetchGroups'], form);
|
||||
|
||||
const [expandedSection, setExpandedSection] = useState<ExpandedSection>(null);
|
||||
|
||||
const handleWorkspaceGroupsChange = useCallback(
|
||||
(keys: string | string[]): void => {
|
||||
const isExpanding = Array.isArray(keys) ? keys.length > 0 : !!keys;
|
||||
setExpandedSection(isExpanding ? 'workspace-groups' : null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRoleMappingChange = useCallback((expanded: boolean): void => {
|
||||
setExpandedSection(expanded ? 'role-mapping' : null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="google-auth">
|
||||
<section className="header">
|
||||
<Typography.Text className="title">
|
||||
Edit Google Authentication
|
||||
</Typography.Text>
|
||||
<Typography.Paragraph className="description">
|
||||
<section className="google-auth__header">
|
||||
<h3 className="google-auth__title">Edit Google Authentication</h3>
|
||||
<p className="google-auth__description">
|
||||
Enter OAuth 2.0 credentials obtained from the Google API Console below.
|
||||
Read the{' '}
|
||||
<a
|
||||
@@ -25,50 +51,234 @@ function ConfigureGoogleAuthAuthnProvider({
|
||||
docs
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</Typography.Paragraph>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Form.Item
|
||||
label="Domain"
|
||||
name="name"
|
||||
className="field"
|
||||
tooltip={{
|
||||
title:
|
||||
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
|
||||
}}
|
||||
>
|
||||
<Input disabled={!isCreate} />
|
||||
</Form.Item>
|
||||
<div className="google-auth__columns">
|
||||
{/* Left Column - Core OAuth Settings */}
|
||||
<div className="google-auth__left">
|
||||
<div className="google-auth__field-group">
|
||||
<label className="google-auth__label" htmlFor="google-domain">
|
||||
Domain
|
||||
<Tooltip title="The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name="name"
|
||||
className="google-auth__form-item"
|
||||
rules={[
|
||||
{ required: true, message: 'Domain is required', whitespace: true },
|
||||
]}
|
||||
>
|
||||
<Input id="google-domain" disabled={!isCreate} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Client ID"
|
||||
name={['googleAuthConfig', 'clientId']}
|
||||
className="field"
|
||||
tooltip={{
|
||||
title: `ClientID is the application's ID. For example, 292085223830.apps.googleusercontent.com.`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="google-auth__field-group">
|
||||
<label className="google-auth__label" htmlFor="google-client-id">
|
||||
Client ID
|
||||
<Tooltip title="ClientID is the application's ID. For example, 292085223830.apps.googleusercontent.com.">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['googleAuthConfig', 'clientId']}
|
||||
className="google-auth__form-item"
|
||||
rules={[
|
||||
{ required: true, message: 'Client ID is required', whitespace: true },
|
||||
]}
|
||||
>
|
||||
<Input id="google-client-id" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Client Secret"
|
||||
name={['googleAuthConfig', 'clientSecret']}
|
||||
className="field"
|
||||
tooltip={{
|
||||
title: `It is the application's secret.`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="google-auth__field-group">
|
||||
<label className="google-auth__label" htmlFor="google-client-secret">
|
||||
Client Secret
|
||||
<Tooltip title="It is the application's secret.">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['googleAuthConfig', 'clientSecret']}
|
||||
className="google-auth__form-item"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Client Secret is required',
|
||||
whitespace: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input id="google-client-secret" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Callout
|
||||
type="warning"
|
||||
size="small"
|
||||
showIcon
|
||||
description="Google OAuth2 won’t be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
<div className="google-auth__checkbox-row">
|
||||
<Form.Item
|
||||
name={['googleAuthConfig', 'insecureSkipEmailVerified']}
|
||||
valuePropName="checked"
|
||||
noStyle
|
||||
>
|
||||
<Checkbox
|
||||
id="google-skip-email-verification"
|
||||
labelName="Skip Email Verification"
|
||||
onCheckedChange={(checked: boolean): void => {
|
||||
form.setFieldValue(
|
||||
['googleAuthConfig', 'insecureSkipEmailVerified'],
|
||||
checked,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Tooltip title='Whether to skip email verification. Defaults to "false"'>
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Callout
|
||||
type="warning"
|
||||
size="small"
|
||||
showIcon
|
||||
description="Google OAuth2 won't be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Google Workspace Groups (Advanced) */}
|
||||
<div className="google-auth__right">
|
||||
<Collapse
|
||||
bordered={false}
|
||||
activeKey={
|
||||
expandedSection === 'workspace-groups' ? ['workspace-groups'] : []
|
||||
}
|
||||
onChange={handleWorkspaceGroupsChange}
|
||||
className="google-auth__collapse"
|
||||
expandIcon={(): null => null}
|
||||
>
|
||||
<Collapse.Panel
|
||||
key="workspace-groups"
|
||||
header={
|
||||
<div className="google-auth__collapse-header">
|
||||
{expandedSection !== 'workspace-groups' ? (
|
||||
<ChevronRight size={16} />
|
||||
) : (
|
||||
<ChevronDown size={16} />
|
||||
)}
|
||||
<div className="google-auth__collapse-header-text">
|
||||
<h4 className="google-auth__section-title">
|
||||
Google Workspace Groups (Advanced)
|
||||
</h4>
|
||||
<p className="google-auth__section-description">
|
||||
Enable group fetching to retrieve user groups from Google Workspace.
|
||||
Requires a Service Account with domain-wide delegation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="google-auth__group-content">
|
||||
<div className="google-auth__checkbox-row">
|
||||
<Form.Item
|
||||
name={['googleAuthConfig', 'fetchGroups']}
|
||||
valuePropName="checked"
|
||||
noStyle
|
||||
>
|
||||
<Checkbox
|
||||
id="google-fetch-groups"
|
||||
labelName="Fetch Groups"
|
||||
onCheckedChange={(checked: boolean): void => {
|
||||
form.setFieldValue(['googleAuthConfig', 'fetchGroups'], checked);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Tooltip title="Enable fetching Google Workspace groups for the user. Requires service account configuration.">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{fetchGroups && (
|
||||
<div className="google-auth__group-fields">
|
||||
<div className="google-auth__field-group">
|
||||
<label
|
||||
className="google-auth__label"
|
||||
htmlFor="google-service-account-json"
|
||||
>
|
||||
Service Account JSON
|
||||
<Tooltip title="The JSON content of the Google Service Account credentials file. Required for group fetching.">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['googleAuthConfig', 'serviceAccountJson']}
|
||||
className="google-auth__form-item"
|
||||
>
|
||||
<TextArea
|
||||
id="google-service-account-json"
|
||||
rows={3}
|
||||
placeholder="Paste service account JSON"
|
||||
className="google-auth__textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<DomainMappingList
|
||||
fieldNamePrefix={['googleAuthConfig', 'domainToAdminEmailList']}
|
||||
/>
|
||||
|
||||
<div className="google-auth__checkbox-row">
|
||||
<Form.Item
|
||||
name={['googleAuthConfig', 'fetchTransitiveGroupMembership']}
|
||||
valuePropName="checked"
|
||||
noStyle
|
||||
>
|
||||
<Checkbox
|
||||
id="google-transitive-membership"
|
||||
labelName="Fetch Transitive Group Membership"
|
||||
onCheckedChange={(checked: boolean): void => {
|
||||
form.setFieldValue(
|
||||
['googleAuthConfig', 'fetchTransitiveGroupMembership'],
|
||||
checked,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Tooltip title="If enabled, recursively fetch groups that contain other groups (transitive membership).">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="google-auth__field-group">
|
||||
<label
|
||||
className="google-auth__label"
|
||||
htmlFor="google-allowed-groups"
|
||||
>
|
||||
Allowed Groups
|
||||
<Tooltip title="Optional list of allowed groups. If configured, only users belonging to one of these groups will be allowed to login.">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['googleAuthConfig', 'allowedGroups']}
|
||||
className="google-auth__form-item"
|
||||
>
|
||||
<EmailTagInput placeholder="Type a group email and press Enter" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
|
||||
<RoleMappingSection
|
||||
fieldNamePrefix={['roleMapping']}
|
||||
isExpanded={expandedSection === 'role-mapping'}
|
||||
onExpandChange={handleRoleMappingChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,110 +1,211 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { Checkbox, Form, Input, Typography } from 'antd';
|
||||
import { Checkbox } from '@signozhq/checkbox';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Form, Tooltip } from 'antd';
|
||||
import { CircleHelp } from 'lucide-react';
|
||||
|
||||
import ClaimMappingSection from './components/ClaimMappingSection';
|
||||
import RoleMappingSection from './components/RoleMappingSection';
|
||||
|
||||
import './Providers.styles.scss';
|
||||
|
||||
type ExpandedSection = 'claim-mapping' | 'role-mapping' | null;
|
||||
|
||||
function ConfigureOIDCAuthnProvider({
|
||||
isCreate,
|
||||
}: {
|
||||
isCreate: boolean;
|
||||
}): JSX.Element {
|
||||
const form = Form.useFormInstance();
|
||||
|
||||
const [expandedSection, setExpandedSection] = useState<ExpandedSection>(null);
|
||||
|
||||
const handleClaimMappingChange = useCallback((expanded: boolean): void => {
|
||||
setExpandedSection(expanded ? 'claim-mapping' : null);
|
||||
}, []);
|
||||
|
||||
const handleRoleMappingChange = useCallback((expanded: boolean): void => {
|
||||
setExpandedSection(expanded ? 'role-mapping' : null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="saml">
|
||||
<section className="header">
|
||||
<Typography.Text className="title">
|
||||
Edit OIDC Authentication
|
||||
</Typography.Text>
|
||||
<div className="google-auth">
|
||||
<section className="google-auth__header">
|
||||
<h3 className="google-auth__title">Edit OIDC Authentication</h3>
|
||||
<p className="google-auth__description">
|
||||
Configure OpenID Connect Single Sign-On with your Identity Provider. Read
|
||||
the{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/sso-authentication"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
docs
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Form.Item
|
||||
label="Domain"
|
||||
name="name"
|
||||
tooltip={{
|
||||
title:
|
||||
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
|
||||
}}
|
||||
>
|
||||
<Input disabled={!isCreate} />
|
||||
</Form.Item>
|
||||
<div className="google-auth__columns">
|
||||
{/* Left Column - Core OIDC Settings */}
|
||||
<div className="google-auth__left">
|
||||
<div className="google-auth__field-group">
|
||||
<label className="google-auth__label" htmlFor="oidc-domain">
|
||||
Domain
|
||||
<Tooltip title="The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name="name"
|
||||
className="google-auth__form-item"
|
||||
rules={[
|
||||
{ required: true, message: 'Domain is required', whitespace: true },
|
||||
]}
|
||||
>
|
||||
<Input id="oidc-domain" disabled={!isCreate} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Issuer URL"
|
||||
name={['oidcConfig', 'issuer']}
|
||||
tooltip={{
|
||||
title: `It is the URL identifier for the service. For example: "https://accounts.google.com" or "https://login.salesforce.com".`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="google-auth__field-group">
|
||||
<label className="google-auth__label" htmlFor="oidc-issuer">
|
||||
Issuer URL
|
||||
<Tooltip title='The URL identifier for the OIDC provider. For example: "https://accounts.google.com" or "https://login.salesforce.com".'>
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['oidcConfig', 'issuer']}
|
||||
className="google-auth__form-item"
|
||||
rules={[
|
||||
{ required: true, message: 'Issuer URL is required', whitespace: true },
|
||||
]}
|
||||
>
|
||||
<Input id="oidc-issuer" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Issuer Alias"
|
||||
name={['oidcConfig', 'issuerAlias']}
|
||||
tooltip={{
|
||||
title: `Some offspec providers like Azure, Oracle IDCS have oidc discovery url different from issuer url which causes issuerValidation to fail.
|
||||
This provides a way to override the Issuer url from the .well-known/openid-configuration issuer`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="google-auth__field-group">
|
||||
<label className="google-auth__label" htmlFor="oidc-issuer-alias">
|
||||
Issuer Alias
|
||||
<Tooltip title="Optional: Override the issuer URL from .well-known/openid-configuration for providers like Azure or Oracle IDCS.">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['oidcConfig', 'issuerAlias']}
|
||||
className="google-auth__form-item"
|
||||
>
|
||||
<Input id="oidc-issuer-alias" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Client ID"
|
||||
name={['oidcConfig', 'clientId']}
|
||||
tooltip={{ title: `It is the application's ID.` }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="google-auth__field-group">
|
||||
<label className="google-auth__label" htmlFor="oidc-client-id">
|
||||
Client ID
|
||||
<Tooltip title="The application's client ID from your OIDC provider.">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['oidcConfig', 'clientId']}
|
||||
className="google-auth__form-item"
|
||||
rules={[
|
||||
{ required: true, message: 'Client ID is required', whitespace: true },
|
||||
]}
|
||||
>
|
||||
<Input id="oidc-client-id" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Client Secret"
|
||||
name={['oidcConfig', 'clientSecret']}
|
||||
tooltip={{ title: `It is the application's secret.` }}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="google-auth__field-group">
|
||||
<label className="google-auth__label" htmlFor="oidc-client-secret">
|
||||
Client Secret
|
||||
<Tooltip title="The application's client secret from your OIDC provider.">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['oidcConfig', 'clientSecret']}
|
||||
className="google-auth__form-item"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Client Secret is required',
|
||||
whitespace: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input id="oidc-client-secret" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Email Claim Mapping"
|
||||
name={['oidcConfig', 'claimMapping', 'email']}
|
||||
tooltip={{
|
||||
title: `Mapping of email claims to the corresponding email field in the token.`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="google-auth__checkbox-row">
|
||||
<Form.Item
|
||||
name={['oidcConfig', 'insecureSkipEmailVerified']}
|
||||
valuePropName="checked"
|
||||
noStyle
|
||||
>
|
||||
<Checkbox
|
||||
id="oidc-skip-email-verification"
|
||||
labelName="Skip Email Verification"
|
||||
onCheckedChange={(checked: boolean): void => {
|
||||
form.setFieldValue(
|
||||
['oidcConfig', 'insecureSkipEmailVerified'],
|
||||
checked,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Tooltip title='Whether to skip email verification. Defaults to "false"'>
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Skip Email Verification"
|
||||
name={['oidcConfig', 'insecureSkipEmailVerified']}
|
||||
valuePropName="checked"
|
||||
className="field"
|
||||
tooltip={{
|
||||
title: `Whether to skip email verification. Defaults to "false"`,
|
||||
}}
|
||||
>
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
<div className="google-auth__checkbox-row">
|
||||
<Form.Item
|
||||
name={['oidcConfig', 'getUserInfo']}
|
||||
valuePropName="checked"
|
||||
noStyle
|
||||
>
|
||||
<Checkbox
|
||||
id="oidc-get-user-info"
|
||||
labelName="Get User Info"
|
||||
onCheckedChange={(checked: boolean): void => {
|
||||
form.setFieldValue(['oidcConfig', 'getUserInfo'], checked);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Tooltip title="Use the userinfo endpoint to get additional claims. Useful when providers return thin ID tokens.">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Get User Info"
|
||||
name={['oidcConfig', 'getUserInfo']}
|
||||
valuePropName="checked"
|
||||
className="field"
|
||||
tooltip={{
|
||||
title: `Uses the userinfo endpoint to get additional claims for the token. This is especially useful where upstreams return "thin" id tokens`,
|
||||
}}
|
||||
>
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
<Callout
|
||||
type="warning"
|
||||
size="small"
|
||||
showIcon
|
||||
description="OIDC won't be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Callout
|
||||
type="warning"
|
||||
size="small"
|
||||
showIcon
|
||||
description="OIDC won’t be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
{/* Right Column - Advanced Settings */}
|
||||
<div className="google-auth__right">
|
||||
<ClaimMappingSection
|
||||
fieldNamePrefix={['oidcConfig', 'claimMapping']}
|
||||
isExpanded={expandedSection === 'claim-mapping'}
|
||||
onExpandChange={handleClaimMappingChange}
|
||||
/>
|
||||
|
||||
<RoleMappingSection
|
||||
fieldNamePrefix={['roleMapping']}
|
||||
isExpanded={expandedSection === 'role-mapping'}
|
||||
onExpandChange={handleRoleMappingChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,82 +1,190 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import { Checkbox, Form, Input, Typography } from 'antd';
|
||||
import { Checkbox } from '@signozhq/checkbox';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Form, Tooltip } from 'antd';
|
||||
import TextArea from 'antd/lib/input/TextArea';
|
||||
import { CircleHelp } from 'lucide-react';
|
||||
|
||||
import AttributeMappingSection from './components/AttributeMappingSection';
|
||||
import RoleMappingSection from './components/RoleMappingSection';
|
||||
|
||||
import './Providers.styles.scss';
|
||||
|
||||
type ExpandedSection = 'attribute-mapping' | 'role-mapping' | null;
|
||||
|
||||
function ConfigureSAMLAuthnProvider({
|
||||
isCreate,
|
||||
}: {
|
||||
isCreate: boolean;
|
||||
}): JSX.Element {
|
||||
const form = Form.useFormInstance();
|
||||
|
||||
const [expandedSection, setExpandedSection] = useState<ExpandedSection>(null);
|
||||
|
||||
const handleAttributeMappingChange = useCallback((expanded: boolean): void => {
|
||||
setExpandedSection(expanded ? 'attribute-mapping' : null);
|
||||
}, []);
|
||||
|
||||
const handleRoleMappingChange = useCallback((expanded: boolean): void => {
|
||||
setExpandedSection(expanded ? 'role-mapping' : null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="saml">
|
||||
<section className="header">
|
||||
<Typography.Text className="title">
|
||||
Edit SAML Authentication
|
||||
</Typography.Text>
|
||||
<div className="google-auth">
|
||||
<section className="google-auth__header">
|
||||
<h3 className="google-auth__title">Edit SAML Authentication</h3>
|
||||
<p className="google-auth__description">
|
||||
Configure SAML 2.0 Single Sign-On with your Identity Provider. Read the{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/sso-authentication"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
docs
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Form.Item
|
||||
label="Domain"
|
||||
name="name"
|
||||
tooltip={{
|
||||
title:
|
||||
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
|
||||
}}
|
||||
>
|
||||
<Input disabled={!isCreate} />
|
||||
</Form.Item>
|
||||
<div className="google-auth__columns">
|
||||
{/* Left Column - Core SAML Settings */}
|
||||
<div className="google-auth__left">
|
||||
<div className="google-auth__field-group">
|
||||
<label className="google-auth__label" htmlFor="saml-domain">
|
||||
Domain
|
||||
<Tooltip title="The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name="name"
|
||||
className="google-auth__form-item"
|
||||
rules={[
|
||||
{ required: true, message: 'Domain is required', whitespace: true },
|
||||
]}
|
||||
>
|
||||
<Input id="saml-domain" disabled={!isCreate} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="SAML ACS URL"
|
||||
name={['samlConfig', 'samlIdp']}
|
||||
tooltip={{
|
||||
title: `The SSO endpoint of the SAML identity provider. It can typically be found in the SingleSignOnService element in the SAML metadata of the identity provider. Example: <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{samlIdp}"/>`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="google-auth__field-group">
|
||||
<label className="google-auth__label" htmlFor="saml-acs-url">
|
||||
SAML ACS URL
|
||||
<Tooltip title="The SSO endpoint of the SAML identity provider. It can typically be found in the SingleSignOnService element in the SAML metadata of the identity provider.">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['samlConfig', 'samlIdp']}
|
||||
className="google-auth__form-item"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'SAML ACS URL is required',
|
||||
whitespace: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input id="saml-acs-url" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="SAML Entity ID"
|
||||
name={['samlConfig', 'samlEntity']}
|
||||
tooltip={{
|
||||
title: `The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata of the identity provider. Example: <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{samlEntity}">`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<div className="google-auth__field-group">
|
||||
<label className="google-auth__label" htmlFor="saml-entity-id">
|
||||
SAML Entity ID
|
||||
<Tooltip title="The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata.">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['samlConfig', 'samlEntity']}
|
||||
className="google-auth__form-item"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'SAML Entity ID is required',
|
||||
whitespace: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input id="saml-entity-id" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="SAML X.509 Certificate"
|
||||
name={['samlConfig', 'samlCert']}
|
||||
tooltip={{
|
||||
title: `The certificate of the SAML identity provider. It can typically be found in the X509Certificate element in the SAML metadata of the identity provider. Example: <ds:X509Certificate><ds:X509Certificate>{samlCert}</ds:X509Certificate></ds:X509Certificate>`,
|
||||
}}
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<div className="google-auth__field-group">
|
||||
<label className="google-auth__label" htmlFor="saml-certificate">
|
||||
SAML X.509 Certificate
|
||||
<Tooltip title="The certificate of the SAML identity provider. It can typically be found in the X509Certificate element in the SAML metadata.">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={['samlConfig', 'samlCert']}
|
||||
className="google-auth__form-item"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'SAML Certificate is required',
|
||||
whitespace: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TextArea
|
||||
id="saml-certificate"
|
||||
rows={3}
|
||||
placeholder="Paste X.509 certificate"
|
||||
className="google-auth__textarea"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
label="Skip Signing AuthN Requests"
|
||||
name={['samlConfig', 'insecureSkipAuthNRequestsSigned']}
|
||||
valuePropName="checked"
|
||||
className="field"
|
||||
tooltip={{
|
||||
title: `Whether to skip signing the SAML requests. It can typically be found in the WantAuthnRequestsSigned attribute of the IDPSSODescriptor element in the SAML metadata of the identity provider. Example: <md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
For providers like jumpcloud, this should be set to true.Note: This is the reverse of WantAuthnRequestsSigned. If WantAuthnRequestsSigned is false, then InsecureSkipAuthNRequestsSigned should be true.`,
|
||||
}}
|
||||
>
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
<div className="google-auth__checkbox-row">
|
||||
<Form.Item
|
||||
name={['samlConfig', 'insecureSkipAuthNRequestsSigned']}
|
||||
valuePropName="checked"
|
||||
noStyle
|
||||
>
|
||||
<Checkbox
|
||||
id="saml-skip-signing"
|
||||
labelName="Skip Signing AuthN Requests"
|
||||
onCheckedChange={(checked: boolean): void => {
|
||||
form.setFieldValue(
|
||||
['samlConfig', 'insecureSkipAuthNRequestsSigned'],
|
||||
checked,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Tooltip title="Whether to skip signing the SAML requests. For providers like JumpCloud, this should be enabled.">
|
||||
<CircleHelp size={14} className="google-auth__label-icon" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Callout
|
||||
type="warning"
|
||||
size="small"
|
||||
showIcon
|
||||
description="SAML won’t be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
<Callout
|
||||
type="warning"
|
||||
size="small"
|
||||
showIcon
|
||||
description="SAML won't be enabled unless you enter all the attributes above"
|
||||
className="callout"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Advanced Settings */}
|
||||
<div className="google-auth__right">
|
||||
<AttributeMappingSection
|
||||
fieldNamePrefix={['samlConfig', 'attributeMapping']}
|
||||
isExpanded={expandedSection === 'attribute-mapping'}
|
||||
onExpandChange={handleAttributeMappingChange}
|
||||
/>
|
||||
|
||||
<RoleMappingSection
|
||||
fieldNamePrefix={['roleMapping']}
|
||||
isExpanded={expandedSection === 'role-mapping'}
|
||||
onExpandChange={handleRoleMappingChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,23 +2,243 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 12px !important;
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
&__title {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0;
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
|
||||
a {
|
||||
color: var(--accent-primary);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__columns {
|
||||
display: grid;
|
||||
grid-template-columns: 0.9fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__right {
|
||||
border-left: 1px solid var(--l3-border);
|
||||
padding-left: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
&__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__label-icon {
|
||||
color: var(--l3-foreground);
|
||||
cursor: help;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__form-item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
&__checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
height: 32px;
|
||||
background: var(--l3-background) !important;
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
border-radius: 2px;
|
||||
color: var(--l1-foreground) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 0px !important;
|
||||
&:hover {
|
||||
border-color: var(--l3-border) !important;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
box-shadow: none !important;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: auto;
|
||||
}
|
||||
&__textarea {
|
||||
min-height: 60px !important;
|
||||
max-height: 200px;
|
||||
resize: vertical;
|
||||
background: var(--l3-background) !important;
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
border-radius: 2px;
|
||||
color: var(--l1-foreground) !important;
|
||||
font-family: 'SF Mono', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground) !important;
|
||||
font-family: Inter, sans-serif;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l3-border) !important;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
box-shadow: none !important;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
button[role='checkbox'] {
|
||||
border: 1px solid var(--l2-foreground) !important;
|
||||
border-radius: 2px;
|
||||
|
||||
&[data-state='checked'] {
|
||||
background-color: var(--bg-robin-500) !important;
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse {
|
||||
background: transparent !important;
|
||||
|
||||
.ant-collapse-item {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.ant-collapse-header {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 12px 0 0 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
margin-top: 2px;
|
||||
color: var(--l3-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__section-description {
|
||||
margin: 0;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&__group-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__group-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
max-height: 45vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
|
||||
.google-auth__field-group,
|
||||
.google-auth__checkbox-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l3-foreground);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--l3-foreground) transparent;
|
||||
}
|
||||
|
||||
.callout {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
.attribute-mapping-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__collapse {
|
||||
background: transparent !important;
|
||||
|
||||
.ant-collapse-item {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.ant-collapse-header {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 12px 0 0 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
margin-top: 2px;
|
||||
color: var(--l3-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__section-description {
|
||||
margin: 0;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 45vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
|
||||
// Thin scrollbar
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l3-foreground);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--l3-foreground) transparent;
|
||||
}
|
||||
|
||||
&__field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__label-icon {
|
||||
color: var(--l3-foreground);
|
||||
cursor: help;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__form-item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
input {
|
||||
height: 32px;
|
||||
background: var(--l3-background) !important;
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
border-radius: 2px;
|
||||
color: var(--l1-foreground) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l3-border) !important;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
box-shadow: none !important;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Collapse, Form, Tooltip } from 'antd';
|
||||
import { ChevronDown, ChevronRight, CircleHelp } from 'lucide-react';
|
||||
|
||||
import './AttributeMappingSection.styles.scss';
|
||||
|
||||
interface AttributeMappingSectionProps {
|
||||
/** The form field name prefix for the attribute mapping configuration */
|
||||
fieldNamePrefix: string[];
|
||||
/** Whether the section is expanded (controlled mode) */
|
||||
isExpanded?: boolean;
|
||||
/** Callback when expand/collapse is toggled */
|
||||
onExpandChange?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
function AttributeMappingSection({
|
||||
fieldNamePrefix,
|
||||
isExpanded,
|
||||
onExpandChange,
|
||||
}: AttributeMappingSectionProps): JSX.Element {
|
||||
// Support both controlled and uncontrolled modes
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
const isControlled = isExpanded !== undefined;
|
||||
const expanded = isControlled ? isExpanded : internalExpanded;
|
||||
|
||||
const handleCollapseChange = useCallback(
|
||||
(keys: string | string[]): void => {
|
||||
const newExpanded = Array.isArray(keys) ? keys.length > 0 : !!keys;
|
||||
if (isControlled && onExpandChange) {
|
||||
onExpandChange(newExpanded);
|
||||
} else {
|
||||
setInternalExpanded(newExpanded);
|
||||
}
|
||||
},
|
||||
[isControlled, onExpandChange],
|
||||
);
|
||||
|
||||
const collapseActiveKey = expanded ? ['attribute-mapping'] : [];
|
||||
|
||||
return (
|
||||
<div className="attribute-mapping-section">
|
||||
<Collapse
|
||||
bordered={false}
|
||||
activeKey={collapseActiveKey}
|
||||
onChange={handleCollapseChange}
|
||||
className="attribute-mapping-section__collapse"
|
||||
expandIcon={(): null => null}
|
||||
>
|
||||
<Collapse.Panel
|
||||
key="attribute-mapping"
|
||||
header={
|
||||
<div className="attribute-mapping-section__collapse-header">
|
||||
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
|
||||
<div className="attribute-mapping-section__collapse-header-text">
|
||||
<h4 className="attribute-mapping-section__section-title">
|
||||
Attribute Mapping (Advanced)
|
||||
</h4>
|
||||
<p className="attribute-mapping-section__section-description">
|
||||
Configure how SAML assertion attributes from your Identity Provider map
|
||||
to SigNoz user attributes. Leave empty to use default values. Note:
|
||||
Email is always extracted from the NameID assertion.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="attribute-mapping-section__content">
|
||||
{/* Name Attribute */}
|
||||
<div className="attribute-mapping-section__field-group">
|
||||
<label
|
||||
className="attribute-mapping-section__label"
|
||||
htmlFor="name-attribute"
|
||||
>
|
||||
Name Attribute
|
||||
<Tooltip title="The SAML attribute key that contains the user's display name. Default: 'name'">
|
||||
<CircleHelp
|
||||
size={14}
|
||||
className="attribute-mapping-section__label-icon"
|
||||
/>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'name']}
|
||||
className="attribute-mapping-section__form-item"
|
||||
>
|
||||
<Input id="name-attribute" placeholder="Name" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Groups Attribute */}
|
||||
<div className="attribute-mapping-section__field-group">
|
||||
<label
|
||||
className="attribute-mapping-section__label"
|
||||
htmlFor="groups-attribute"
|
||||
>
|
||||
Groups Attribute
|
||||
<Tooltip title="The SAML attribute key that contains the user's group memberships. Used for role mapping. Default: 'groups'">
|
||||
<CircleHelp
|
||||
size={14}
|
||||
className="attribute-mapping-section__label-icon"
|
||||
/>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'groups']}
|
||||
className="attribute-mapping-section__form-item"
|
||||
>
|
||||
<Input id="groups-attribute" placeholder="Groups" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Role Attribute */}
|
||||
<div className="attribute-mapping-section__field-group">
|
||||
<label
|
||||
className="attribute-mapping-section__label"
|
||||
htmlFor="role-attribute"
|
||||
>
|
||||
Role Attribute
|
||||
<Tooltip title="The SAML attribute key that contains the user's role directly from the IDP. Default: 'role'">
|
||||
<CircleHelp
|
||||
size={14}
|
||||
className="attribute-mapping-section__label-icon"
|
||||
/>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'role']}
|
||||
className="attribute-mapping-section__form-item"
|
||||
>
|
||||
<Input id="role-attribute" placeholder="Role" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeMappingSection;
|
||||
@@ -0,0 +1,131 @@
|
||||
.claim-mapping-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__collapse {
|
||||
background: transparent !important;
|
||||
|
||||
.ant-collapse-item {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.ant-collapse-header {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 12px 0 0 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
margin-top: 2px;
|
||||
color: var(--l3-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__section-description {
|
||||
margin: 0;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 45vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
|
||||
// Thin scrollbar
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l3-foreground);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--l3-foreground) transparent;
|
||||
}
|
||||
|
||||
&__field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__label-icon {
|
||||
color: var(--l3-foreground);
|
||||
cursor: help;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__form-item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
input {
|
||||
height: 32px;
|
||||
background: var(--l3-background) !important;
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
border-radius: 2px;
|
||||
color: var(--l1-foreground) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l3-border) !important;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
box-shadow: none !important;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Collapse, Form, Tooltip } from 'antd';
|
||||
import { ChevronDown, ChevronRight, CircleHelp } from 'lucide-react';
|
||||
|
||||
import './ClaimMappingSection.styles.scss';
|
||||
|
||||
interface ClaimMappingSectionProps {
|
||||
/** The form field name prefix for the claim mapping configuration */
|
||||
fieldNamePrefix: string[];
|
||||
/** Whether the section is expanded (controlled mode) */
|
||||
isExpanded?: boolean;
|
||||
/** Callback when expand/collapse is toggled */
|
||||
onExpandChange?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
function ClaimMappingSection({
|
||||
fieldNamePrefix,
|
||||
isExpanded,
|
||||
onExpandChange,
|
||||
}: ClaimMappingSectionProps): JSX.Element {
|
||||
// Support both controlled and uncontrolled modes
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
const isControlled = isExpanded !== undefined;
|
||||
const expanded = isControlled ? isExpanded : internalExpanded;
|
||||
|
||||
const handleCollapseChange = useCallback(
|
||||
(keys: string | string[]): void => {
|
||||
const newExpanded = Array.isArray(keys) ? keys.length > 0 : !!keys;
|
||||
if (isControlled && onExpandChange) {
|
||||
onExpandChange(newExpanded);
|
||||
} else {
|
||||
setInternalExpanded(newExpanded);
|
||||
}
|
||||
},
|
||||
[isControlled, onExpandChange],
|
||||
);
|
||||
|
||||
const collapseActiveKey = expanded ? ['claim-mapping'] : [];
|
||||
|
||||
return (
|
||||
<div className="claim-mapping-section">
|
||||
<Collapse
|
||||
bordered={false}
|
||||
activeKey={collapseActiveKey}
|
||||
onChange={handleCollapseChange}
|
||||
className="claim-mapping-section__collapse"
|
||||
expandIcon={(): null => null}
|
||||
>
|
||||
<Collapse.Panel
|
||||
key="claim-mapping"
|
||||
header={
|
||||
<div className="claim-mapping-section__collapse-header">
|
||||
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
|
||||
<div className="claim-mapping-section__collapse-header-text">
|
||||
<h4 className="claim-mapping-section__section-title">
|
||||
Claim Mapping (Advanced)
|
||||
</h4>
|
||||
<p className="claim-mapping-section__section-description">
|
||||
Configure how claims from your Identity Provider map to SigNoz user
|
||||
attributes. Leave empty to use default values.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="claim-mapping-section__content">
|
||||
{/* Email Claim */}
|
||||
<div className="claim-mapping-section__field-group">
|
||||
<label className="claim-mapping-section__label" htmlFor="email-claim">
|
||||
Email Claim
|
||||
<Tooltip title="The claim key that contains the user's email address. Default: 'email'">
|
||||
<CircleHelp size={14} className="claim-mapping-section__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'email']}
|
||||
className="claim-mapping-section__form-item"
|
||||
>
|
||||
<Input id="email-claim" placeholder="Email" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Name Claim */}
|
||||
<div className="claim-mapping-section__field-group">
|
||||
<label className="claim-mapping-section__label" htmlFor="name-claim">
|
||||
Name Claim
|
||||
<Tooltip title="The claim key that contains the user's display name. Default: 'name'">
|
||||
<CircleHelp size={14} className="claim-mapping-section__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'name']}
|
||||
className="claim-mapping-section__form-item"
|
||||
>
|
||||
<Input id="name-claim" placeholder="Name" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Groups Claim */}
|
||||
<div className="claim-mapping-section__field-group">
|
||||
<label className="claim-mapping-section__label" htmlFor="groups-claim">
|
||||
Groups Claim
|
||||
<Tooltip title="The claim key that contains the user's group memberships. Used for role mapping. Default: 'groups'">
|
||||
<CircleHelp size={14} className="claim-mapping-section__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'groups']}
|
||||
className="claim-mapping-section__form-item"
|
||||
>
|
||||
<Input id="groups-claim" placeholder="Groups" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Role Claim */}
|
||||
<div className="claim-mapping-section__field-group">
|
||||
<label className="claim-mapping-section__label" htmlFor="role-claim">
|
||||
Role Claim
|
||||
<Tooltip title="The claim key that contains the user's role directly from the IDP. Default: 'role'">
|
||||
<CircleHelp size={14} className="claim-mapping-section__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'role']}
|
||||
className="claim-mapping-section__form-item"
|
||||
>
|
||||
<Input id="role-claim" placeholder="Role" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClaimMappingSection;
|
||||
@@ -0,0 +1,103 @@
|
||||
.domain-mapping-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__description {
|
||||
margin: 0;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__remove-btn {
|
||||
flex-shrink: 0;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
min-width: 32px !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: 2px !important;
|
||||
background: transparent !important;
|
||||
color: var(--bg-cherry-500) !important;
|
||||
opacity: 0.6 !important;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, opacity 0.2s;
|
||||
box-shadow: none !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
color: var(--bg-cherry-500) !important;
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(229, 72, 77, 0.1) !important;
|
||||
opacity: 0.9 !important;
|
||||
color: var(--bg-cherry-500) !important;
|
||||
|
||||
svg {
|
||||
color: var(--bg-cherry-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.7 !important;
|
||||
background: rgba(229, 72, 77, 0.15) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__add-btn {
|
||||
width: 100%;
|
||||
|
||||
// Ensure icon is visible
|
||||
svg,
|
||||
[class*='icon'] {
|
||||
color: var(--l2-foreground) !important;
|
||||
display: inline-block !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
|
||||
svg,
|
||||
[class*='icon'] {
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Plus, Trash2 } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Form } from 'antd';
|
||||
|
||||
import './DomainMappingList.styles.scss';
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
interface DomainMappingListProps {
|
||||
/** The form field name prefix for the domain mapping list */
|
||||
fieldNamePrefix: string[];
|
||||
}
|
||||
|
||||
function DomainMappingList({
|
||||
fieldNamePrefix,
|
||||
}: DomainMappingListProps): JSX.Element {
|
||||
const validateEmail = useCallback(
|
||||
(_: unknown, value: string): Promise<void> => {
|
||||
if (!value) {
|
||||
return Promise.reject(new Error('Admin email is required'));
|
||||
}
|
||||
if (!EMAIL_REGEX.test(value)) {
|
||||
return Promise.reject(new Error('Please enter a valid email'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="domain-mapping-list">
|
||||
<div className="domain-mapping-list__header">
|
||||
<span className="domain-mapping-list__title">
|
||||
Domain to Admin Email Mapping
|
||||
</span>
|
||||
<p className="domain-mapping-list__description">
|
||||
Map workspace domains to admin emails for service account impersonation.
|
||||
Use "*" as a wildcard for any domain.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form.List name={fieldNamePrefix}>
|
||||
{(fields, { add, remove }): JSX.Element => (
|
||||
<div className="domain-mapping-list__items">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="domain-mapping-list__row">
|
||||
<Form.Item
|
||||
name={[field.name, 'domain']}
|
||||
className="domain-mapping-list__field"
|
||||
rules={[{ required: true, message: 'Domain is required' }]}
|
||||
>
|
||||
<Input placeholder="Domain (e.g., example.com or *)" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={[field.name, 'adminEmail']}
|
||||
className="domain-mapping-list__field"
|
||||
rules={[{ validator: validateEmail }]}
|
||||
>
|
||||
<Input placeholder="Admin Email" />
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className="domain-mapping-list__remove-btn"
|
||||
onClick={(): void => remove(field.name)}
|
||||
aria-label="Remove mapping"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="dashed"
|
||||
onClick={(): void => add({ domain: '', adminEmail: '' })}
|
||||
prefixIcon={<Plus size={14} />}
|
||||
className="domain-mapping-list__add-btn"
|
||||
>
|
||||
Add Domain Mapping
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DomainMappingList;
|
||||
@@ -0,0 +1,28 @@
|
||||
.email-tag-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
&__select {
|
||||
width: 100%;
|
||||
|
||||
.ant-select-selector {
|
||||
.ant-select-selection-search {
|
||||
input {
|
||||
height: 32px !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
box-shadow: none !important;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__error {
|
||||
margin: 0;
|
||||
color: var(--bg-cherry-500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Select, Tooltip } from 'antd';
|
||||
|
||||
import './EmailTagInput.styles.scss';
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
interface EmailTagInputProps {
|
||||
/** Current value (array of email strings) */
|
||||
value?: string[];
|
||||
/** Change handler */
|
||||
onChange?: (value: string[]) => void;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
function EmailTagInput({
|
||||
value = [],
|
||||
onChange,
|
||||
placeholder = 'Type an email and press Enter',
|
||||
}: EmailTagInputProps): JSX.Element {
|
||||
const [validationError, setValidationError] = useState('');
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValues: string[]): void => {
|
||||
const addedValues = newValues.filter((v) => !value.includes(v));
|
||||
const invalidEmail = addedValues.find((v) => !EMAIL_REGEX.test(v));
|
||||
|
||||
if (invalidEmail) {
|
||||
setValidationError(`"${invalidEmail}" is not a valid email`);
|
||||
return;
|
||||
}
|
||||
setValidationError('');
|
||||
onChange?.(newValues);
|
||||
},
|
||||
[onChange, value],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="email-tag-input">
|
||||
<Tooltip
|
||||
title={validationError}
|
||||
open={!!validationError}
|
||||
placement="topRight"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
tokenSeparators={[',', ' ']}
|
||||
className="email-tag-input__select"
|
||||
allowClear
|
||||
status={validationError ? 'error' : undefined}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EmailTagInput;
|
||||
@@ -0,0 +1,292 @@
|
||||
.role-mapping-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 24px;
|
||||
|
||||
&__collapse {
|
||||
background: transparent !important;
|
||||
|
||||
.ant-collapse-item {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.ant-collapse-header {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content {
|
||||
border-top: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 12px 0 0 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
margin-top: 2px;
|
||||
color: var(--l3-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__collapse-header-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__section-description {
|
||||
margin: 0;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-height: 45vh;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
|
||||
// Thin scrollbar
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--l3-foreground);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--l3-foreground) transparent;
|
||||
}
|
||||
|
||||
&__field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__label-icon {
|
||||
color: var(--l3-foreground);
|
||||
cursor: help;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__form-item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
&__checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&__select {
|
||||
width: 100%;
|
||||
|
||||
&.ant-select {
|
||||
.ant-select-selector {
|
||||
height: 32px;
|
||||
background: var(--l3-background) !important;
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
border-radius: 2px;
|
||||
color: var(--l1-foreground) !important;
|
||||
|
||||
.ant-select-selection-item {
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .ant-select-selector {
|
||||
border-color: var(--l3-border) !important;
|
||||
}
|
||||
|
||||
&.ant-select-focused .ant-select-selector {
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__group-mappings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__group-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
&__group-title {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
&__group-description {
|
||||
margin: 0;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
&__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__field {
|
||||
&--group {
|
||||
flex: 2;
|
||||
}
|
||||
|
||||
&--role {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
}
|
||||
|
||||
&__remove-btn {
|
||||
flex-shrink: 0;
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
min-width: 32px !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
border-radius: 2px !important;
|
||||
background: transparent !important;
|
||||
color: var(--bg-cherry-500) !important;
|
||||
opacity: 0.6 !important;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s, opacity 0.2s;
|
||||
box-shadow: none !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
color: var(--bg-cherry-500) !important;
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(229, 72, 77, 0.1) !important;
|
||||
opacity: 0.9 !important;
|
||||
color: var(--bg-cherry-500) !important;
|
||||
|
||||
svg {
|
||||
color: var(--bg-cherry-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.7 !important;
|
||||
background: rgba(229, 72, 77, 0.15) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__add-btn {
|
||||
width: 100%;
|
||||
|
||||
// Ensure icon is visible
|
||||
svg,
|
||||
[class*='icon'] {
|
||||
color: var(--l2-foreground) !important;
|
||||
display: inline-block !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
|
||||
svg,
|
||||
[class*='icon'] {
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Checkbox border visibility ---
|
||||
button[role='checkbox'] {
|
||||
border: 1px solid var(--l2-foreground) !important;
|
||||
border-radius: 2px;
|
||||
|
||||
&[data-state='checked'] {
|
||||
background-color: var(--bg-robin-500) !important;
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Input styles ---
|
||||
input {
|
||||
height: 32px;
|
||||
background: var(--l3-background) !important;
|
||||
border: 1px solid var(--l3-border) !important;
|
||||
border-radius: 2px;
|
||||
color: var(--l1-foreground) !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground) !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l3-border) !important;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
border-color: var(--bg-robin-500) !important;
|
||||
box-shadow: none !important;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Checkbox } from '@signozhq/checkbox';
|
||||
import { Plus, Trash2 } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { Collapse, Form, Select, Tooltip } from 'antd';
|
||||
import { ChevronDown, ChevronRight, CircleHelp } from 'lucide-react';
|
||||
|
||||
import './RoleMappingSection.styles.scss';
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ value: 'VIEWER', label: 'VIEWER' },
|
||||
{ value: 'EDITOR', label: 'EDITOR' },
|
||||
{ value: 'ADMIN', label: 'ADMIN' },
|
||||
];
|
||||
|
||||
interface RoleMappingSectionProps {
|
||||
/** The form field name prefix for the role mapping configuration */
|
||||
fieldNamePrefix: string[];
|
||||
/** Whether the section is expanded (controlled mode) */
|
||||
isExpanded?: boolean;
|
||||
/** Callback when expand/collapse is toggled */
|
||||
onExpandChange?: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
function RoleMappingSection({
|
||||
fieldNamePrefix,
|
||||
isExpanded,
|
||||
onExpandChange,
|
||||
}: RoleMappingSectionProps): JSX.Element {
|
||||
const form = Form.useFormInstance();
|
||||
const useRoleAttribute = Form.useWatch(
|
||||
[...fieldNamePrefix, 'useRoleAttribute'],
|
||||
form,
|
||||
);
|
||||
|
||||
// Support both controlled and uncontrolled modes
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
const isControlled = isExpanded !== undefined;
|
||||
const expanded = isControlled ? isExpanded : internalExpanded;
|
||||
|
||||
const handleCollapseChange = useCallback(
|
||||
(keys: string | string[]): void => {
|
||||
const newExpanded = Array.isArray(keys) ? keys.length > 0 : !!keys;
|
||||
if (isControlled && onExpandChange) {
|
||||
onExpandChange(newExpanded);
|
||||
} else {
|
||||
setInternalExpanded(newExpanded);
|
||||
}
|
||||
},
|
||||
[isControlled, onExpandChange],
|
||||
);
|
||||
|
||||
const collapseActiveKey = expanded ? ['role-mapping'] : [];
|
||||
|
||||
return (
|
||||
<div className="role-mapping-section">
|
||||
<Collapse
|
||||
bordered={false}
|
||||
activeKey={collapseActiveKey}
|
||||
onChange={handleCollapseChange}
|
||||
className="role-mapping-section__collapse"
|
||||
expandIcon={(): null => null}
|
||||
>
|
||||
<Collapse.Panel
|
||||
key="role-mapping"
|
||||
header={
|
||||
<div className="role-mapping-section__collapse-header">
|
||||
{!expanded ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
|
||||
<div className="role-mapping-section__collapse-header-text">
|
||||
<h4 className="role-mapping-section__section-title">
|
||||
Role Mapping (Advanced)
|
||||
</h4>
|
||||
<p className="role-mapping-section__section-description">
|
||||
Configure how user roles are determined from your Identity Provider.
|
||||
You can either use a direct role attribute or map IDP groups to SigNoz
|
||||
roles.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="role-mapping-section__content">
|
||||
{/* Default Role */}
|
||||
<div className="role-mapping-section__field-group">
|
||||
<label className="role-mapping-section__label" htmlFor="default-role">
|
||||
Default Role
|
||||
<Tooltip title='The default role assigned to new SSO users if no other role mapping applies. Default: "VIEWER"'>
|
||||
<CircleHelp size={14} className="role-mapping-section__label-icon" />
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'defaultRole']}
|
||||
className="role-mapping-section__form-item"
|
||||
initialValue="VIEWER"
|
||||
>
|
||||
<Select
|
||||
id="default-role"
|
||||
options={ROLE_OPTIONS}
|
||||
className="role-mapping-section__select"
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Use Role Attribute */}
|
||||
<div className="role-mapping-section__checkbox-row">
|
||||
<Form.Item
|
||||
name={[...fieldNamePrefix, 'useRoleAttribute']}
|
||||
valuePropName="checked"
|
||||
noStyle
|
||||
>
|
||||
<Checkbox
|
||||
id="use-role-attribute"
|
||||
labelName="Use Role Attribute Directly"
|
||||
onCheckedChange={(checked: boolean): void => {
|
||||
form.setFieldValue([...fieldNamePrefix, 'useRoleAttribute'], checked);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Tooltip title="If enabled, the role claim/attribute from the IDP will be used directly instead of group mappings. The role value must match a SigNoz role (VIEWER, EDITOR, or ADMIN).">
|
||||
<CircleHelp size={14} className="role-mapping-section__label-icon" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Group to Role Mappings - only show when useRoleAttribute is false */}
|
||||
{!useRoleAttribute && (
|
||||
<div className="role-mapping-section__group-mappings">
|
||||
<div className="role-mapping-section__group-header">
|
||||
<span className="role-mapping-section__group-title">
|
||||
Group to Role Mappings
|
||||
</span>
|
||||
<p className="role-mapping-section__group-description">
|
||||
Map IDP group names to SigNoz roles. If a user belongs to multiple
|
||||
groups, the highest privilege role will be assigned.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form.List name={[...fieldNamePrefix, 'groupMappingsList']}>
|
||||
{(fields, { add, remove }): JSX.Element => (
|
||||
<div className="role-mapping-section__items">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="role-mapping-section__row">
|
||||
<Form.Item
|
||||
name={[field.name, 'groupName']}
|
||||
className="role-mapping-section__field role-mapping-section__field--group"
|
||||
rules={[{ required: true, message: 'Group name is required' }]}
|
||||
>
|
||||
<Input placeholder="IDP Group Name" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name={[field.name, 'role']}
|
||||
className="role-mapping-section__field role-mapping-section__field--role"
|
||||
rules={[{ required: true, message: 'Role is required' }]}
|
||||
initialValue="VIEWER"
|
||||
>
|
||||
<Select
|
||||
options={ROLE_OPTIONS}
|
||||
className="role-mapping-section__select"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className="role-mapping-section__remove-btn"
|
||||
onClick={(): void => remove(field.name)}
|
||||
aria-label="Remove mapping"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="dashed"
|
||||
onClick={(): void => add({ groupName: '', role: 'VIEWER' })}
|
||||
prefixIcon={<Plus size={14} />}
|
||||
className="role-mapping-section__add-btn"
|
||||
>
|
||||
Add Group Mapping
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoleMappingSection;
|
||||
@@ -1,35 +1,60 @@
|
||||
import { useState } from 'react';
|
||||
import { Switch } from 'antd';
|
||||
import put from 'api/v1/domains/id/put';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { useUpdateAuthDomain } from 'api/generated/services/authdomains';
|
||||
import {
|
||||
AuthtypesGettableAuthDomainDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { GettableAuthDomain } from 'types/api/v1/domains/list';
|
||||
|
||||
interface ToggleProps {
|
||||
isDefaultChecked: boolean;
|
||||
record: AuthtypesGettableAuthDomainDTO;
|
||||
}
|
||||
|
||||
function Toggle({ isDefaultChecked, record }: ToggleProps): JSX.Element {
|
||||
const [isChecked, setIsChecked] = useState<boolean>(isDefaultChecked);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const onChangeHandler = async (checked: boolean): Promise<void> => {
|
||||
setIsLoading(true);
|
||||
const { mutate: updateAuthDomain, isLoading } = useUpdateAuthDomain<
|
||||
AxiosError<RenderErrorResponseDTO>
|
||||
>();
|
||||
|
||||
try {
|
||||
await put({
|
||||
id: record.id,
|
||||
config: {
|
||||
ssoEnabled: checked,
|
||||
ssoType: record.ssoType,
|
||||
googleAuthConfig: record.googleAuthConfig,
|
||||
oidcConfig: record.oidcConfig,
|
||||
samlConfig: record.samlConfig,
|
||||
},
|
||||
});
|
||||
setIsChecked(checked);
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
const onChangeHandler = (checked: boolean): void => {
|
||||
if (!record.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
updateAuthDomain(
|
||||
{
|
||||
pathParams: { id: record.id },
|
||||
data: {
|
||||
config: {
|
||||
ssoEnabled: checked,
|
||||
ssoType: record.ssoType,
|
||||
googleAuthConfig: record.googleAuthConfig,
|
||||
oidcConfig: record.oidcConfig,
|
||||
samlConfig: record.samlConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsChecked(checked);
|
||||
},
|
||||
onError: (error) => {
|
||||
try {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
} catch (apiError) {
|
||||
showErrorModal(apiError as APIError);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -37,9 +62,4 @@ function Toggle({ isDefaultChecked, record }: ToggleProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
interface ToggleProps {
|
||||
isDefaultChecked: boolean;
|
||||
record: GettableAuthDomain;
|
||||
}
|
||||
|
||||
export default Toggle;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import AuthDomain from '../index';
|
||||
import {
|
||||
AUTH_DOMAINS_LIST_ENDPOINT,
|
||||
mockDomainsListResponse,
|
||||
mockEmptyDomainsResponse,
|
||||
mockErrorResponse,
|
||||
} from './mocks';
|
||||
|
||||
const successNotification = jest.fn();
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
__esModule: true,
|
||||
useNotifications: jest.fn(() => ({
|
||||
notifications: {
|
||||
success: successNotification,
|
||||
error: jest.fn(),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('AuthDomain', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('List View', () => {
|
||||
it('renders page header and add button', async () => {
|
||||
server.use(
|
||||
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockEmptyDomainsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<AuthDomain />);
|
||||
|
||||
expect(screen.getByText(/authenticated domains/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /add domain/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders list of auth domains successfully', async () => {
|
||||
server.use(
|
||||
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockDomainsListResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<AuthDomain />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByText('example.com')).toBeInTheDocument();
|
||||
expect(screen.getByText('corp.io')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders empty state when no domains exist', async () => {
|
||||
server.use(
|
||||
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockEmptyDomainsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<AuthDomain />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no data/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays error content when API fails', async () => {
|
||||
server.use(
|
||||
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json(mockErrorResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<AuthDomain />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/failed to perform operation/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Add Domain', () => {
|
||||
it('opens create modal when Add Domain button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockEmptyDomainsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<AuthDomain />);
|
||||
|
||||
const addButton = await screen.findByRole('button', { name: /add domain/i });
|
||||
await user.click(addButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/configure authentication method/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configure Domain', () => {
|
||||
it('opens edit modal when configure action is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.get(AUTH_DOMAINS_LIST_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockDomainsListResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<AuthDomain />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('signoz.io')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const configureLinks = await screen.findAllByText(/configure google auth/i);
|
||||
await user.click(configureLinks[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/edit google authentication/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CreateEdit from '../CreateEdit/CreateEdit';
|
||||
import {
|
||||
mockDomainWithRoleMapping,
|
||||
mockGoogleAuthDomain,
|
||||
mockGoogleAuthWithWorkspaceGroups,
|
||||
mockOidcAuthDomain,
|
||||
mockOidcWithClaimMapping,
|
||||
mockSamlAuthDomain,
|
||||
mockSamlWithAttributeMapping,
|
||||
} from './mocks';
|
||||
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
describe('CreateEdit Modal', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Provider Selection (Create Mode)', () => {
|
||||
it('renders provider selection when creating new domain', () => {
|
||||
render(<CreateEdit isCreate onClose={mockOnClose} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/configure authentication method/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/google apps authentication/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/saml authentication/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/oidc authentication/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns to provider selection when back button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<CreateEdit isCreate onClose={mockOnClose} />);
|
||||
|
||||
const configureButtons = await screen.findAllByRole('button', {
|
||||
name: /configure/i,
|
||||
});
|
||||
await user.click(configureButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/edit google authentication/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const backButton = screen.getByRole('button', { name: /back/i });
|
||||
await user.click(backButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/configure authentication method/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit Mode', () => {
|
||||
it('shows provider form directly when editing existing domain', () => {
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockGoogleAuthDomain}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/edit google authentication/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/configure authentication method/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-fills form with existing domain values', () => {
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockGoogleAuthDomain}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByDisplayValue('signoz.io')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('test-client-id')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables domain field when editing', () => {
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockGoogleAuthDomain}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
const domainInput = screen.getByDisplayValue('signoz.io');
|
||||
expect(domainInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows cancel button instead of back when editing', () => {
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockGoogleAuthDomain}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /back/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Validation', () => {
|
||||
it('shows validation error when submitting without required fields', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<CreateEdit isCreate onClose={mockOnClose} />);
|
||||
|
||||
const configureButtons = await screen.findAllByRole('button', {
|
||||
name: /configure/i,
|
||||
});
|
||||
await user.click(configureButtons[0]);
|
||||
|
||||
const saveButton = await screen.findByRole('button', {
|
||||
name: /save changes/i,
|
||||
});
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/domain is required/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Google Auth Provider', () => {
|
||||
it('shows Google Auth form fields', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<CreateEdit isCreate onClose={mockOnClose} />);
|
||||
|
||||
const configureButtons = await screen.findAllByRole('button', {
|
||||
name: /configure/i,
|
||||
});
|
||||
await user.click(configureButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/edit google authentication/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/domain/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/client id/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/client secret/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/skip email verification/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows workspace groups section when expanded', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockGoogleAuthWithWorkspaceGroups}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
const workspaceHeader = screen.getByText(/google workspace groups/i);
|
||||
await user.click(workspaceHeader);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/fetch groups/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/service account json/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SAML Provider', () => {
|
||||
it('shows SAML-specific fields when editing SAML domain', () => {
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockSamlAuthDomain}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/edit saml authentication/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByDisplayValue('https://idp.example.com/sso'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('urn:example:idp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows attribute mapping section for SAML', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockSamlWithAttributeMapping}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/attribute mapping \(advanced\)/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const attributeHeader = screen.getByText(/attribute mapping \(advanced\)/i);
|
||||
await user.click(attributeHeader);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/name attribute/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/groups attribute/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/role attribute/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OIDC Provider', () => {
|
||||
it('shows OIDC-specific fields when editing OIDC domain', () => {
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockOidcAuthDomain}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/edit oidc authentication/i)).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('https://oidc.corp.io')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('oidc-client-id')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows claim mapping section for OIDC', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockOidcWithClaimMapping}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/claim mapping \(advanced\)/i)).toBeInTheDocument();
|
||||
|
||||
const claimHeader = screen.getByText(/claim mapping \(advanced\)/i);
|
||||
await user.click(claimHeader);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/email claim/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/name claim/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/groups claim/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/role claim/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows OIDC options checkboxes', () => {
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockOidcAuthDomain}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/skip email verification/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/get user info/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role Mapping', () => {
|
||||
it('shows role mapping section in provider forms', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<CreateEdit isCreate onClose={mockOnClose} />);
|
||||
|
||||
const configureButtons = await screen.findAllByRole('button', {
|
||||
name: /configure/i,
|
||||
});
|
||||
await user.click(configureButtons[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/role mapping \(advanced\)/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('expands role mapping section to show default role selector', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockDomainWithRoleMapping}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
const roleMappingHeader = screen.getByText(/role mapping \(advanced\)/i);
|
||||
await user.click(roleMappingHeader);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/default role/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/use role attribute directly/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows group mappings section when useRoleAttribute is false', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockDomainWithRoleMapping}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
const roleMappingHeader = screen.getByText(/role mapping \(advanced\)/i);
|
||||
await user.click(roleMappingHeader);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/group to role mappings/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /add group mapping/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Modal Actions', () => {
|
||||
it('calls onClose when cancel button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<CreateEdit
|
||||
isCreate={false}
|
||||
record={mockGoogleAuthDomain}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import Toggle from '../Toggle';
|
||||
import {
|
||||
AUTH_DOMAINS_UPDATE_ENDPOINT,
|
||||
mockErrorResponse,
|
||||
mockGoogleAuthDomain,
|
||||
mockUpdateSuccessResponse,
|
||||
} from './mocks';
|
||||
|
||||
describe('Toggle', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders switch with correct initial state', () => {
|
||||
render(<Toggle isDefaultChecked={true} record={mockGoogleAuthDomain} />);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
expect(switchElement).toBeChecked();
|
||||
});
|
||||
|
||||
it('renders unchecked switch when SSO is disabled', () => {
|
||||
render(
|
||||
<Toggle
|
||||
isDefaultChecked={false}
|
||||
record={{ ...mockGoogleAuthDomain, ssoEnabled: false }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
expect(switchElement).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('calls update API when toggle is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockUpdateAPI = jest.fn();
|
||||
|
||||
server.use(
|
||||
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
mockUpdateAPI(body);
|
||||
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
render(<Toggle isDefaultChecked={true} record={mockGoogleAuthDomain} />);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
await user.click(switchElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(switchElement).not.toBeChecked();
|
||||
});
|
||||
|
||||
expect(mockUpdateAPI).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateAPI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
ssoEnabled: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows error modal when update fails', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
server.use(
|
||||
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json(mockErrorResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<Toggle isDefaultChecked={true} record={mockGoogleAuthDomain} />);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
await user.click(switchElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/failed to perform operation/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call API when record has no id', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
let apiCalled = false;
|
||||
|
||||
server.use(
|
||||
rest.put(AUTH_DOMAINS_UPDATE_ENDPOINT, (_, res, ctx) => {
|
||||
apiCalled = true;
|
||||
return res(ctx.status(200), ctx.json(mockUpdateSuccessResponse));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<Toggle
|
||||
isDefaultChecked={true}
|
||||
record={{ ...mockGoogleAuthDomain, id: undefined }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const switchElement = screen.getByRole('switch');
|
||||
await user.click(switchElement);
|
||||
|
||||
// Wait a bit to ensure no API call was made
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(apiCalled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { AuthtypesGettableAuthDomainDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
// API Endpoints
|
||||
export const AUTH_DOMAINS_LIST_ENDPOINT = '*/api/v1/domains';
|
||||
export const AUTH_DOMAINS_CREATE_ENDPOINT = '*/api/v1/domains';
|
||||
export const AUTH_DOMAINS_UPDATE_ENDPOINT = '*/api/v1/domains/:id';
|
||||
export const AUTH_DOMAINS_DELETE_ENDPOINT = '*/api/v1/domains/:id';
|
||||
|
||||
// Mock Auth Domain with Google Auth
|
||||
export const mockGoogleAuthDomain: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-1',
|
||||
name: 'signoz.io',
|
||||
ssoEnabled: true,
|
||||
ssoType: 'google_auth',
|
||||
googleAuthConfig: {
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
},
|
||||
authNProviderInfo: {
|
||||
relayStatePath: 'api/v1/sso/relay/domain-1',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock Auth Domain with SAML
|
||||
export const mockSamlAuthDomain: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-2',
|
||||
name: 'example.com',
|
||||
ssoEnabled: false,
|
||||
ssoType: 'saml',
|
||||
samlConfig: {
|
||||
samlIdp: 'https://idp.example.com/sso',
|
||||
samlEntity: 'urn:example:idp',
|
||||
samlCert: 'MOCK_CERTIFICATE',
|
||||
},
|
||||
authNProviderInfo: {
|
||||
relayStatePath: 'api/v1/sso/relay/domain-2',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock Auth Domain with OIDC
|
||||
export const mockOidcAuthDomain: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-3',
|
||||
name: 'corp.io',
|
||||
ssoEnabled: true,
|
||||
ssoType: 'oidc',
|
||||
oidcConfig: {
|
||||
issuer: 'https://oidc.corp.io',
|
||||
clientId: 'oidc-client-id',
|
||||
clientSecret: 'oidc-client-secret',
|
||||
},
|
||||
authNProviderInfo: {
|
||||
relayStatePath: 'api/v1/sso/relay/domain-3',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock Auth Domain with Role Mapping
|
||||
export const mockDomainWithRoleMapping: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-4',
|
||||
name: 'enterprise.com',
|
||||
ssoEnabled: true,
|
||||
ssoType: 'saml',
|
||||
samlConfig: {
|
||||
samlIdp: 'https://idp.enterprise.com/sso',
|
||||
samlEntity: 'urn:enterprise:idp',
|
||||
samlCert: 'MOCK_CERTIFICATE',
|
||||
},
|
||||
roleMapping: {
|
||||
defaultRole: 'EDITOR',
|
||||
useRoleAttribute: false,
|
||||
groupMappings: {
|
||||
'admin-group': 'ADMIN',
|
||||
'dev-team': 'EDITOR',
|
||||
viewers: 'VIEWER',
|
||||
},
|
||||
},
|
||||
authNProviderInfo: {
|
||||
relayStatePath: 'api/v1/sso/relay/domain-4',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock Auth Domain with useRoleAttribute enabled
|
||||
export const mockDomainWithDirectRoleAttribute: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-5',
|
||||
name: 'direct-role.com',
|
||||
ssoEnabled: true,
|
||||
ssoType: 'oidc',
|
||||
oidcConfig: {
|
||||
issuer: 'https://oidc.direct-role.com',
|
||||
clientId: 'direct-role-client-id',
|
||||
clientSecret: 'direct-role-client-secret',
|
||||
},
|
||||
roleMapping: {
|
||||
defaultRole: 'VIEWER',
|
||||
useRoleAttribute: true,
|
||||
},
|
||||
authNProviderInfo: {
|
||||
relayStatePath: 'api/v1/sso/relay/domain-5',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock OIDC domain with claim mapping
|
||||
export const mockOidcWithClaimMapping: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-6',
|
||||
name: 'oidc-claims.com',
|
||||
ssoEnabled: true,
|
||||
ssoType: 'oidc',
|
||||
oidcConfig: {
|
||||
issuer: 'https://oidc.claims.com',
|
||||
issuerAlias: 'https://alias.claims.com',
|
||||
clientId: 'claims-client-id',
|
||||
clientSecret: 'claims-client-secret',
|
||||
insecureSkipEmailVerified: true,
|
||||
getUserInfo: true,
|
||||
claimMapping: {
|
||||
email: 'user_email',
|
||||
name: 'display_name',
|
||||
groups: 'user_groups',
|
||||
role: 'user_role',
|
||||
},
|
||||
},
|
||||
authNProviderInfo: {
|
||||
relayStatePath: 'api/v1/sso/relay/domain-6',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock SAML domain with attribute mapping
|
||||
export const mockSamlWithAttributeMapping: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-7',
|
||||
name: 'saml-attrs.com',
|
||||
ssoEnabled: true,
|
||||
ssoType: 'saml',
|
||||
samlConfig: {
|
||||
samlIdp: 'https://idp.saml-attrs.com/sso',
|
||||
samlEntity: 'urn:saml-attrs:idp',
|
||||
samlCert: 'MOCK_CERTIFICATE_ATTRS',
|
||||
insecureSkipAuthNRequestsSigned: true,
|
||||
attributeMapping: {
|
||||
name: 'user_display_name',
|
||||
groups: 'member_of',
|
||||
role: 'signoz_role',
|
||||
},
|
||||
},
|
||||
authNProviderInfo: {
|
||||
relayStatePath: 'api/v1/sso/relay/domain-7',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock Google Auth with workspace groups
|
||||
export const mockGoogleAuthWithWorkspaceGroups: AuthtypesGettableAuthDomainDTO = {
|
||||
id: 'domain-8',
|
||||
name: 'google-groups.com',
|
||||
ssoEnabled: true,
|
||||
ssoType: 'google_auth',
|
||||
googleAuthConfig: {
|
||||
clientId: 'google-groups-client-id',
|
||||
clientSecret: 'google-groups-client-secret',
|
||||
insecureSkipEmailVerified: false,
|
||||
fetchGroups: true,
|
||||
serviceAccountJson: '{"type": "service_account"}',
|
||||
domainToAdminEmail: {
|
||||
'google-groups.com': 'admin@google-groups.com',
|
||||
},
|
||||
fetchTransitiveGroupMembership: true,
|
||||
allowedGroups: ['allowed-group-1', 'allowed-group-2'],
|
||||
},
|
||||
authNProviderInfo: {
|
||||
relayStatePath: 'api/v1/sso/relay/domain-8',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock empty list response
|
||||
export const mockEmptyDomainsResponse = {
|
||||
status: 'success',
|
||||
data: [],
|
||||
};
|
||||
|
||||
// Mock list response with domains
|
||||
export const mockDomainsListResponse = {
|
||||
status: 'success',
|
||||
data: [mockGoogleAuthDomain, mockSamlAuthDomain, mockOidcAuthDomain],
|
||||
};
|
||||
|
||||
// Mock single domain list response
|
||||
export const mockSingleDomainResponse = {
|
||||
status: 'success',
|
||||
data: [mockGoogleAuthDomain],
|
||||
};
|
||||
|
||||
// Mock success responses
|
||||
export const mockCreateSuccessResponse = {
|
||||
status: 'success',
|
||||
data: mockGoogleAuthDomain,
|
||||
};
|
||||
|
||||
export const mockUpdateSuccessResponse = {
|
||||
status: 'success',
|
||||
data: { ...mockGoogleAuthDomain, ssoEnabled: false },
|
||||
};
|
||||
|
||||
export const mockDeleteSuccessResponse = {
|
||||
status: 'success',
|
||||
data: 'Domain deleted successfully',
|
||||
};
|
||||
|
||||
// Mock error responses
|
||||
export const mockErrorResponse = {
|
||||
error: {
|
||||
code: 'internal_error',
|
||||
message: 'Failed to perform operation',
|
||||
url: '',
|
||||
},
|
||||
};
|
||||
|
||||
export const mockValidationErrorResponse = {
|
||||
error: {
|
||||
code: 'invalid_input',
|
||||
message: 'Domain name is required',
|
||||
url: '',
|
||||
},
|
||||
};
|
||||
@@ -1,22 +1,38 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { Button, Table, Typography } from 'antd';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Table, Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import deleteDomain from 'api/v1/domains/id/delete';
|
||||
import listAllDomain from 'api/v1/domains/list';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import {
|
||||
useDeleteAuthDomain,
|
||||
useListAuthDomains,
|
||||
} from 'api/generated/services/authdomains';
|
||||
import {
|
||||
AuthtypesGettableAuthDomainDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import ErrorContent from 'components/ErrorModal/components/ErrorContent';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import CopyToClipboard from 'periscope/components/CopyToClipboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { GettableAuthDomain, SSOType } from 'types/api/v1/domains/list';
|
||||
|
||||
import CreateEdit from './CreateEdit/CreateEdit';
|
||||
import Toggle from './Toggle';
|
||||
|
||||
import './AuthDomain.styles.scss';
|
||||
|
||||
const columns: ColumnsType<GettableAuthDomain> = [
|
||||
export const SSOType = new Map<string, string>([
|
||||
['google_auth', 'Google Auth'],
|
||||
['saml', 'SAML'],
|
||||
['email_password', 'Email Password'],
|
||||
['oidc', 'OIDC'],
|
||||
]);
|
||||
|
||||
const columns: ColumnsType<AuthtypesGettableAuthDomainDTO> = [
|
||||
{
|
||||
title: 'Domain',
|
||||
dataIndex: 'name',
|
||||
@@ -29,17 +45,18 @@ const columns: ColumnsType<GettableAuthDomain> = [
|
||||
dataIndex: 'ssoEnabled',
|
||||
key: 'ssoEnabled',
|
||||
width: 80,
|
||||
render: (value: boolean, record: GettableAuthDomain): JSX.Element => (
|
||||
<Toggle isDefaultChecked={value} record={record} />
|
||||
),
|
||||
render: (
|
||||
value: boolean,
|
||||
record: AuthtypesGettableAuthDomainDTO,
|
||||
): JSX.Element => <Toggle isDefaultChecked={value} record={record} />,
|
||||
},
|
||||
{
|
||||
title: 'IDP Initiated SSO URL',
|
||||
dataIndex: 'relayState',
|
||||
key: 'relayState',
|
||||
width: 80,
|
||||
render: (_, record: GettableAuthDomain): JSX.Element => {
|
||||
const relayPath = record.authNProviderInfo.relayStatePath;
|
||||
render: (_, record: AuthtypesGettableAuthDomainDTO): JSX.Element => {
|
||||
const relayPath = record.authNProviderInfo?.relayStatePath;
|
||||
if (!relayPath) {
|
||||
return (
|
||||
<Typography.Text style={{ paddingLeft: '6px' }}>N/A</Typography.Text>
|
||||
@@ -55,10 +72,10 @@ const columns: ColumnsType<GettableAuthDomain> = [
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
render: (_, record: GettableAuthDomain): JSX.Element => (
|
||||
render: (_, record: AuthtypesGettableAuthDomainDTO): JSX.Element => (
|
||||
<section className="auth-domain-list-column-action">
|
||||
<Typography.Link data-column-action="configure">
|
||||
Configure {SSOType.get(record.ssoType)}
|
||||
Configure {SSOType.get(record.ssoType || '')}
|
||||
</Typography.Link>
|
||||
<Typography.Link type="danger" data-column-action="delete">
|
||||
Delete
|
||||
@@ -68,58 +85,89 @@ const columns: ColumnsType<GettableAuthDomain> = [
|
||||
},
|
||||
];
|
||||
|
||||
async function deleteDomainById(
|
||||
id: string,
|
||||
showErrorModal: (error: APIError) => void,
|
||||
refetchAuthDomainListResponse: () => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await deleteDomain(id);
|
||||
refetchAuthDomainListResponse();
|
||||
} catch (error) {
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
}
|
||||
|
||||
function AuthDomain(): JSX.Element {
|
||||
const [record, setRecord] = useState<GettableAuthDomain>();
|
||||
const [record, setRecord] = useState<AuthtypesGettableAuthDomainDTO>();
|
||||
const [addDomain, setAddDomain] = useState<boolean>(false);
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const {
|
||||
data: authDomainListResponse,
|
||||
isLoading: isLoadingAuthDomainListResponse,
|
||||
isFetching: isFetchingAuthDomainListResponse,
|
||||
error: errorFetchingAuthDomainListResponse,
|
||||
refetch: refetchAuthDomainListResponse,
|
||||
} = useQuery({
|
||||
queryFn: listAllDomain,
|
||||
queryKey: ['/api/v1/domains', 'list'],
|
||||
enabled: true,
|
||||
});
|
||||
} = useListAuthDomains();
|
||||
|
||||
const { mutate: deleteAuthDomain } = useDeleteAuthDomain<
|
||||
AxiosError<RenderErrorResponseDTO>
|
||||
>();
|
||||
|
||||
const handleDeleteDomain = useCallback(
|
||||
(id: string): void => {
|
||||
deleteAuthDomain(
|
||||
{ pathParams: { id } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifications.success({
|
||||
message: 'Domain deleted successfully',
|
||||
});
|
||||
refetchAuthDomainListResponse();
|
||||
},
|
||||
onError: (error) => {
|
||||
try {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
} catch (apiError) {
|
||||
showErrorModal(apiError as APIError);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[
|
||||
deleteAuthDomain,
|
||||
notifications,
|
||||
refetchAuthDomainListResponse,
|
||||
showErrorModal,
|
||||
],
|
||||
);
|
||||
|
||||
const formattedError = useMemo(() => {
|
||||
if (!errorFetchingAuthDomainListResponse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
ErrorResponseHandlerV2(
|
||||
errorFetchingAuthDomainListResponse as AxiosError<ErrorV2Resp>,
|
||||
);
|
||||
} catch (error) {
|
||||
return error as APIError;
|
||||
}
|
||||
}, [errorFetchingAuthDomainListResponse]);
|
||||
|
||||
return (
|
||||
<div className="auth-domain">
|
||||
<section className="auth-domain-header">
|
||||
<Typography.Title level={3}>Authenticated Domains</Typography.Title>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
prefixIcon={<PlusOutlined />}
|
||||
onClick={(): void => {
|
||||
setAddDomain(true);
|
||||
}}
|
||||
className="button"
|
||||
variant="solid"
|
||||
size="sm"
|
||||
color="primary"
|
||||
>
|
||||
Add Domain
|
||||
</Button>
|
||||
</section>
|
||||
{(errorFetchingAuthDomainListResponse as APIError) && (
|
||||
<ErrorContent error={errorFetchingAuthDomainListResponse as APIError} />
|
||||
)}
|
||||
{!(errorFetchingAuthDomainListResponse as APIError) && (
|
||||
{formattedError && <ErrorContent error={formattedError} />}
|
||||
{!errorFetchingAuthDomainListResponse && (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={authDomainListResponse?.data}
|
||||
onRow={(record): any => ({
|
||||
dataSource={authDomainListResponse?.data?.data}
|
||||
onRow={(tableRecord): any => ({
|
||||
onClick: (
|
||||
event: React.SyntheticEvent<HTMLLinkElement, MouseEvent>,
|
||||
): void => {
|
||||
@@ -127,15 +175,12 @@ function AuthDomain(): JSX.Element {
|
||||
const { columnAction } = target.dataset;
|
||||
switch (columnAction) {
|
||||
case 'configure':
|
||||
setRecord(record);
|
||||
|
||||
setRecord(tableRecord);
|
||||
break;
|
||||
case 'delete':
|
||||
deleteDomainById(
|
||||
record.id,
|
||||
showErrorModal,
|
||||
refetchAuthDomainListResponse,
|
||||
);
|
||||
if (tableRecord.id) {
|
||||
handleDeleteDomain(tableRecord.id);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
console.error('Unknown action:', columnAction);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
import TimeSeriesPanel from '../DashboardContainer/visualization/panels/TimeSeriesPanel/TimeSeriesPanel';
|
||||
import HistogramPanelWrapper from './HistogramPanelWrapper';
|
||||
import ListPanelWrapper from './ListPanelWrapper';
|
||||
import PiePanelWrapper from './PiePanelWrapper';
|
||||
@@ -8,7 +9,7 @@ import UplotPanelWrapper from './UplotPanelWrapper';
|
||||
import ValuePanelWrapper from './ValuePanelWrapper';
|
||||
|
||||
export const PanelTypeVsPanelWrapper = {
|
||||
[PANEL_TYPES.TIME_SERIES]: UplotPanelWrapper,
|
||||
[PANEL_TYPES.TIME_SERIES]: TimeSeriesPanel,
|
||||
[PANEL_TYPES.TABLE]: TablePanelWrapper,
|
||||
[PANEL_TYPES.LIST]: ListPanelWrapper,
|
||||
[PANEL_TYPES.VALUE]: ValuePanelWrapper,
|
||||
|
||||
@@ -4,12 +4,12 @@ import uPlot, { Axis } from 'uplot';
|
||||
|
||||
import { uPlotXAxisValuesFormat } from '../../uPlotLib/utils/constants';
|
||||
import getGridColor from '../../uPlotLib/utils/getGridColor';
|
||||
import { buildYAxisSizeCalculator } from '../utils/axis';
|
||||
import { AxisProps, ConfigBuilder } from './types';
|
||||
|
||||
const PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT = [
|
||||
PANEL_TYPES.TIME_SERIES,
|
||||
PANEL_TYPES.BAR,
|
||||
PANEL_TYPES.PIE,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -114,81 +114,6 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate axis size from existing size property
|
||||
*/
|
||||
private getExistingAxisSize(
|
||||
self: uPlot,
|
||||
axis: Axis,
|
||||
values: string[] | undefined,
|
||||
axisIdx: number,
|
||||
cycleNum: number,
|
||||
): number {
|
||||
const internalSize = (axis as { _size?: number })._size;
|
||||
if (internalSize !== undefined) {
|
||||
return internalSize;
|
||||
}
|
||||
|
||||
const existingSize = axis.size;
|
||||
if (typeof existingSize === 'function') {
|
||||
return existingSize(self, values ?? [], axisIdx, cycleNum);
|
||||
}
|
||||
|
||||
return existingSize ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate text width for longest value
|
||||
*/
|
||||
private calculateTextWidth(
|
||||
self: uPlot,
|
||||
axis: Axis,
|
||||
values: string[] | undefined,
|
||||
): number {
|
||||
if (!values || values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Find longest value
|
||||
const longestVal = values.reduce(
|
||||
(acc, val) => (val.length > acc.length ? val : acc),
|
||||
'',
|
||||
);
|
||||
|
||||
if (longestVal === '' || !axis.font?.[0]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-destructuring, no-param-reassign
|
||||
self.ctx.font = axis.font[0];
|
||||
return self.ctx.measureText(longestVal).width / devicePixelRatio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Y-axis dynamic size calculator
|
||||
*/
|
||||
private buildYAxisSizeCalculator(): uPlot.Axis.Size {
|
||||
return (
|
||||
self: uPlot,
|
||||
values: string[] | undefined,
|
||||
axisIdx: number,
|
||||
cycleNum: number,
|
||||
): number => {
|
||||
const axis = self.axes[axisIdx];
|
||||
|
||||
// Bail out, force convergence
|
||||
if (cycleNum > 1) {
|
||||
return this.getExistingAxisSize(self, axis, values, axisIdx, cycleNum);
|
||||
}
|
||||
|
||||
const gap = this.props.gap ?? 5;
|
||||
let axisSize = (axis.ticks?.size ?? 0) + gap;
|
||||
axisSize += this.calculateTextWidth(self, axis, values);
|
||||
|
||||
return Math.ceil(axisSize);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build dynamic size calculator for Y-axis
|
||||
*/
|
||||
@@ -202,7 +127,7 @@ export class UPlotAxisBuilder extends ConfigBuilder<AxisProps, Axis> {
|
||||
|
||||
// Y-axis needs dynamic sizing based on text width
|
||||
if (scaleKey === 'y') {
|
||||
return this.buildYAxisSizeCalculator();
|
||||
return buildYAxisSizeCalculator(this.props.gap ?? 5);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
|
||||
@@ -15,7 +15,33 @@ import {
|
||||
* Builder for uPlot series configuration
|
||||
* Handles creation of series settings
|
||||
*/
|
||||
|
||||
/**
|
||||
* Path builders are static and shared across all instances of UPlotSeriesBuilder
|
||||
*/
|
||||
let builders: PathBuilders | null = null;
|
||||
export class UPlotSeriesBuilder extends ConfigBuilder<SeriesProps, Series> {
|
||||
constructor(props: SeriesProps) {
|
||||
super(props);
|
||||
const pathBuilders = uPlot.paths;
|
||||
|
||||
if (!builders) {
|
||||
const linearBuilder = pathBuilders.linear;
|
||||
const splineBuilder = pathBuilders.spline;
|
||||
const steppedBuilder = pathBuilders.stepped;
|
||||
|
||||
if (!linearBuilder || !splineBuilder || !steppedBuilder) {
|
||||
throw new Error('Required uPlot path builders are not available');
|
||||
}
|
||||
builders = {
|
||||
linear: linearBuilder(),
|
||||
spline: splineBuilder(),
|
||||
stepBefore: steppedBuilder({ align: -1 }),
|
||||
stepAfter: steppedBuilder({ align: 1 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private buildLineConfig({
|
||||
lineColor,
|
||||
lineWidth,
|
||||
@@ -198,8 +224,6 @@ interface PathBuilders {
|
||||
[key: string]: Series.PathBuilder;
|
||||
}
|
||||
|
||||
let builders: PathBuilders | null = null;
|
||||
|
||||
/**
|
||||
* Get path builder based on draw style and interpolation
|
||||
*/
|
||||
@@ -207,23 +231,8 @@ function getPathBuilder(
|
||||
style: DrawStyle,
|
||||
lineInterpolation?: LineInterpolation,
|
||||
): Series.PathBuilder {
|
||||
const pathBuilders = uPlot.paths;
|
||||
|
||||
if (!builders) {
|
||||
const linearBuilder = pathBuilders.linear;
|
||||
const splineBuilder = pathBuilders.spline;
|
||||
const steppedBuilder = pathBuilders.stepped;
|
||||
|
||||
if (!linearBuilder || !splineBuilder || !steppedBuilder) {
|
||||
throw new Error('Required uPlot path builders are not available');
|
||||
}
|
||||
|
||||
builders = {
|
||||
linear: linearBuilder(),
|
||||
spline: splineBuilder(),
|
||||
stepBefore: steppedBuilder({ align: -1 }),
|
||||
stepAfter: steppedBuilder({ align: 1 }),
|
||||
};
|
||||
throw new Error('Required uPlot path builders are not available');
|
||||
}
|
||||
|
||||
if (style === DrawStyle.Line) {
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import { getToolTipValue } from 'components/Graph/yAxisConfig';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { uPlotXAxisValuesFormat } from 'lib/uPlotLib/utils/constants';
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import type { AxisProps } from '../types';
|
||||
import { UPlotAxisBuilder } from '../UPlotAxisBuilder';
|
||||
|
||||
jest.mock('components/Graph/yAxisConfig', () => ({
|
||||
getToolTipValue: jest.fn(),
|
||||
}));
|
||||
|
||||
const createAxisProps = (overrides: Partial<AxisProps> = {}): AxisProps => ({
|
||||
scaleKey: 'x',
|
||||
label: 'Time',
|
||||
isDarkMode: false,
|
||||
show: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('UPlotAxisBuilder', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('builds basic axis config with defaults', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
label: 'Time',
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.scale).toBe('x');
|
||||
expect(config.label).toBe('Time');
|
||||
expect(config.show).toBe(true);
|
||||
expect(config.side).toBe(2);
|
||||
expect(config.gap).toBe(5);
|
||||
|
||||
// Default grid and ticks are created
|
||||
expect(config.grid).toEqual({
|
||||
stroke: 'rgba(0,0,0,0.5)',
|
||||
width: 0.2,
|
||||
show: true,
|
||||
});
|
||||
expect(config.ticks).toEqual({
|
||||
width: 0.3,
|
||||
show: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('sets config values when provided', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
label: 'Time',
|
||||
show: false,
|
||||
side: 0,
|
||||
gap: 10,
|
||||
grid: {
|
||||
stroke: '#ff0000',
|
||||
width: 1,
|
||||
show: false,
|
||||
},
|
||||
ticks: {
|
||||
stroke: '#00ff00',
|
||||
width: 1,
|
||||
show: false,
|
||||
size: 10,
|
||||
},
|
||||
values: ['1', '2', '3'],
|
||||
space: 20,
|
||||
size: 100,
|
||||
stroke: '#0000ff',
|
||||
}),
|
||||
);
|
||||
const config = builder.getConfig();
|
||||
expect(config.scale).toBe('x');
|
||||
expect(config.label).toBe('Time');
|
||||
expect(config.show).toBe(false);
|
||||
expect(config.gap).toBe(10);
|
||||
expect(config.grid).toEqual({
|
||||
stroke: '#ff0000',
|
||||
width: 1,
|
||||
show: false,
|
||||
});
|
||||
expect(config.ticks).toEqual({
|
||||
stroke: '#00ff00',
|
||||
width: 1,
|
||||
show: false,
|
||||
size: 10,
|
||||
});
|
||||
expect(config.values).toEqual(['1', '2', '3']);
|
||||
expect(config.space).toBe(20);
|
||||
expect(config.size).toBe(100);
|
||||
expect(config.stroke).toBe('#0000ff');
|
||||
});
|
||||
|
||||
it('merges custom grid config over defaults and respects isDarkMode and isLogScale', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
isDarkMode: true,
|
||||
isLogScale: true,
|
||||
grid: {
|
||||
width: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.grid).toEqual({
|
||||
// stroke falls back to theme-based default when not provided
|
||||
stroke: 'rgba(231,233,237,0.3)',
|
||||
// provided width overrides default
|
||||
width: 1,
|
||||
// show falls back to default when not provided
|
||||
show: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses provided ticks config when present and falls back to defaults otherwise', () => {
|
||||
const customTicks = { width: 1, show: false };
|
||||
const withTicks = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
ticks: customTicks,
|
||||
}),
|
||||
);
|
||||
const withoutTicks = new UPlotAxisBuilder(createAxisProps());
|
||||
|
||||
expect(withTicks.getConfig().ticks).toBe(customTicks);
|
||||
expect(withoutTicks.getConfig().ticks).toEqual({
|
||||
width: 0.3,
|
||||
show: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses time-based X-axis values formatter for time-series like panels', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.values).toBe(uPlotXAxisValuesFormat);
|
||||
});
|
||||
|
||||
it('does not attach X-axis datetime formatter when panel type is not supported', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.LIST, // not in PANEL_TYPES_WITH_X_AXIS_DATETIME_FORMAT
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.values).toBeUndefined();
|
||||
});
|
||||
|
||||
it('builds Y-axis values formatter that delegates to getToolTipValue', () => {
|
||||
const yBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'y',
|
||||
yAxisUnit: 'ms',
|
||||
decimalPrecision: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = yBuilder.getConfig();
|
||||
expect(typeof config.values).toBe('function');
|
||||
|
||||
(getToolTipValue as jest.Mock).mockImplementation(
|
||||
(value: string, unit?: string, precision?: unknown) =>
|
||||
`formatted:${value}:${unit}:${precision}`,
|
||||
);
|
||||
|
||||
// Simulate uPlot calling the values formatter
|
||||
const valuesFn = (config.values as unknown) as (
|
||||
self: uPlot,
|
||||
vals: unknown[],
|
||||
) => string[];
|
||||
const result = valuesFn({} as uPlot, [1, null, 2, Number.NaN]);
|
||||
|
||||
expect(getToolTipValue).toHaveBeenCalledTimes(2);
|
||||
expect(getToolTipValue).toHaveBeenNthCalledWith(1, '1', 'ms', 3);
|
||||
expect(getToolTipValue).toHaveBeenNthCalledWith(2, '2', 'ms', 3);
|
||||
|
||||
// Null/NaN values should map to empty strings
|
||||
expect(result).toEqual(['formatted:1:ms:3', '', 'formatted:2:ms:3', '']);
|
||||
});
|
||||
|
||||
it('adds dynamic size calculator only for Y-axis when size is not provided', () => {
|
||||
const yBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'y',
|
||||
}),
|
||||
);
|
||||
const xBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
}),
|
||||
);
|
||||
|
||||
const yConfig = yBuilder.getConfig();
|
||||
const xConfig = xBuilder.getConfig();
|
||||
|
||||
expect(typeof yConfig.size).toBe('function');
|
||||
expect(xConfig.size).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses explicit size function when provided', () => {
|
||||
const sizeFn: uPlot.Axis.Size = jest.fn(() => 100) as uPlot.Axis.Size;
|
||||
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'y',
|
||||
size: sizeFn,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
expect(config.size).toBe(sizeFn);
|
||||
});
|
||||
|
||||
it('builds stroke color based on stroke and isDarkMode', () => {
|
||||
const explicitStroke = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
stroke: '#ff0000',
|
||||
}),
|
||||
);
|
||||
const darkStroke = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
stroke: undefined,
|
||||
isDarkMode: true,
|
||||
}),
|
||||
);
|
||||
const lightStroke = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
stroke: undefined,
|
||||
isDarkMode: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(explicitStroke.getConfig().stroke).toBe('#ff0000');
|
||||
expect(darkStroke.getConfig().stroke).toBe('white');
|
||||
expect(lightStroke.getConfig().stroke).toBe('black');
|
||||
});
|
||||
|
||||
it('uses explicit values formatter when provided', () => {
|
||||
const customValues: uPlot.Axis.Values = jest.fn(() => ['a', 'b', 'c']);
|
||||
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'y',
|
||||
values: customValues,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.values).toBe(customValues);
|
||||
});
|
||||
|
||||
it('returns undefined values for scaleKey neither x nor y', () => {
|
||||
const builder = new UPlotAxisBuilder(createAxisProps({ scaleKey: 'custom' }));
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.values).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes space in config when provided', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'y', space: 50 }),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.space).toBe(50);
|
||||
});
|
||||
|
||||
it('includes PANEL_TYPES.BAR and PANEL_TYPES.TIME_SERIES in X-axis datetime formatter', () => {
|
||||
const barBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
}),
|
||||
);
|
||||
expect(barBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
|
||||
|
||||
const timeSeriesBuilder = new UPlotAxisBuilder(
|
||||
createAxisProps({
|
||||
scaleKey: 'x',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
}),
|
||||
);
|
||||
expect(timeSeriesBuilder.getConfig().values).toBe(uPlotXAxisValuesFormat);
|
||||
});
|
||||
|
||||
it('should return the existing size when cycleNum > 1', () => {
|
||||
const builder = new UPlotAxisBuilder(createAxisProps({ scaleKey: 'y' }));
|
||||
|
||||
const config = builder.getConfig();
|
||||
const sizeFn = config.size;
|
||||
expect(typeof sizeFn).toBe('function');
|
||||
|
||||
const mockAxis = {
|
||||
_size: 80,
|
||||
ticks: { size: 10 },
|
||||
font: ['12px sans-serif'],
|
||||
};
|
||||
const mockSelf = ({
|
||||
axes: [mockAxis],
|
||||
ctx: { measureText: jest.fn(() => ({ width: 60 })), font: '' },
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const result = (sizeFn as (
|
||||
s: uPlot,
|
||||
v: string[],
|
||||
a: number,
|
||||
c: number,
|
||||
) => number)(
|
||||
mockSelf,
|
||||
['100', '200'],
|
||||
0,
|
||||
2, // cycleNum > 1
|
||||
);
|
||||
|
||||
expect(result).toBe(80);
|
||||
});
|
||||
|
||||
it('should invoke the size calculator and compute from text width when cycleNum <= 1', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'y', gap: 8 }),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const sizeFn = config.size;
|
||||
expect(typeof sizeFn).toBe('function');
|
||||
|
||||
const mockAxis = {
|
||||
ticks: { size: 12 },
|
||||
font: ['12px sans-serif'],
|
||||
};
|
||||
const measureText = jest.fn(() => ({ width: 48 }));
|
||||
const mockSelf = ({
|
||||
axes: [mockAxis],
|
||||
ctx: {
|
||||
measureText,
|
||||
get font() {
|
||||
return '';
|
||||
},
|
||||
set font(_v: string) {
|
||||
/* noop */
|
||||
},
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const result = (sizeFn as (
|
||||
s: uPlot,
|
||||
v: string[],
|
||||
a: number,
|
||||
c: number,
|
||||
) => number)(
|
||||
mockSelf,
|
||||
['10', '2000ms'],
|
||||
0,
|
||||
0, // cycleNum <= 1
|
||||
);
|
||||
|
||||
expect(measureText).toHaveBeenCalledWith('2000ms');
|
||||
expect(result).toBeGreaterThanOrEqual(12 + 8);
|
||||
});
|
||||
|
||||
it('merge updates axis props', () => {
|
||||
const builder = new UPlotAxisBuilder(
|
||||
createAxisProps({ scaleKey: 'y', label: 'Original' }),
|
||||
);
|
||||
|
||||
builder.merge({ label: 'Merged', yAxisUnit: 'bytes' });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.label).toBe('Merged');
|
||||
expect(config.values).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { SeriesProps } from '../types';
|
||||
import { DrawStyle, SelectionPreferencesSource } from '../types';
|
||||
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
|
||||
|
||||
// Mock only the real boundary that hits localStorage
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
|
||||
() => ({
|
||||
getStoredSeriesVisibility: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
const getStoredSeriesVisibilityMock = jest.requireMock(
|
||||
'container/DashboardContainer/visualization/panels/utils/legendVisibilityUtils',
|
||||
) as {
|
||||
getStoredSeriesVisibility: jest.Mock;
|
||||
};
|
||||
|
||||
describe('UPlotConfigBuilder', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const createSeriesProps = (
|
||||
overrides: Partial<SeriesProps> = {},
|
||||
): SeriesProps => ({
|
||||
scaleKey: 'y',
|
||||
label: 'Requests',
|
||||
colorMapping: {},
|
||||
drawStyle: DrawStyle.Line,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('returns correct save selection preference flag from constructor args', () => {
|
||||
const builder = new UPlotConfigBuilder({
|
||||
shouldSaveSelectionPreference: true,
|
||||
});
|
||||
|
||||
expect(builder.getShouldSaveSelectionPreference()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns widgetId from constructor args', () => {
|
||||
const builder = new UPlotConfigBuilder({ widgetId: 'widget-123' });
|
||||
|
||||
expect(builder.getWidgetId()).toBe('widget-123');
|
||||
});
|
||||
|
||||
it('sets tzDate from constructor and includes it in config', () => {
|
||||
const tzDate = (ts: number): Date => new Date(ts);
|
||||
const builder = new UPlotConfigBuilder({ tzDate });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.tzDate).toBe(tzDate);
|
||||
});
|
||||
|
||||
it('does not call onDragSelect for click without drag (width === 0)', () => {
|
||||
const onDragSelect = jest.fn();
|
||||
const builder = new UPlotConfigBuilder({ onDragSelect });
|
||||
|
||||
const config = builder.getConfig();
|
||||
const setSelectHooks = config.hooks?.setSelect ?? [];
|
||||
expect(setSelectHooks.length).toBe(1);
|
||||
|
||||
const uplotInstance = ({
|
||||
select: { left: 10, width: 0 },
|
||||
posToVal: jest.fn(),
|
||||
} as unknown) as uPlot;
|
||||
|
||||
// Simulate uPlot calling the hook
|
||||
const setSelectHook = setSelectHooks[0];
|
||||
setSelectHook!(uplotInstance);
|
||||
|
||||
expect(onDragSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onDragSelect with start and end times in milliseconds for a drag selection', () => {
|
||||
const onDragSelect = jest.fn();
|
||||
const builder = new UPlotConfigBuilder({ onDragSelect });
|
||||
|
||||
const config = builder.getConfig();
|
||||
const setSelectHooks = config.hooks?.setSelect ?? [];
|
||||
expect(setSelectHooks.length).toBe(1);
|
||||
|
||||
const posToVal = jest
|
||||
.fn()
|
||||
// left position
|
||||
.mockReturnValueOnce(100)
|
||||
// left + width
|
||||
.mockReturnValueOnce(110);
|
||||
|
||||
const uplotInstance = ({
|
||||
select: { left: 50, width: 20 },
|
||||
posToVal,
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const setSelectHook = setSelectHooks[0];
|
||||
setSelectHook!(uplotInstance);
|
||||
|
||||
expect(onDragSelect).toHaveBeenCalledTimes(1);
|
||||
// 100 and 110 seconds converted to milliseconds
|
||||
expect(onDragSelect).toHaveBeenCalledWith(100_000, 110_000);
|
||||
});
|
||||
|
||||
it('adds and removes hooks via addHook, and exposes them through getConfig', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const drawHook = jest.fn();
|
||||
|
||||
const remove = builder.addHook('draw', drawHook as uPlot.Hooks.Defs['draw']);
|
||||
|
||||
let config = builder.getConfig();
|
||||
expect(config.hooks?.draw).toContain(drawHook);
|
||||
|
||||
// Remove and ensure it no longer appears in config
|
||||
remove();
|
||||
config = builder.getConfig();
|
||||
expect(config.hooks?.draw ?? []).not.toContain(drawHook);
|
||||
});
|
||||
|
||||
it('adds axes, scales, and series and wires them into the final config', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
// Add axis and scale
|
||||
builder.addAxis({ scaleKey: 'y', label: 'Requests' });
|
||||
builder.addScale({ scaleKey: 'y' });
|
||||
|
||||
// Add two series – legend indices should start from 1 (0 is the timestamp series)
|
||||
builder.addSeries(createSeriesProps({ label: 'Requests' }));
|
||||
builder.addSeries(createSeriesProps({ label: 'Errors' }));
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
// Axes
|
||||
expect(config.axes).toHaveLength(1);
|
||||
expect(config.axes?.[0].scale).toBe('y');
|
||||
|
||||
// Scales are returned as an object keyed by scaleKey
|
||||
expect(config.scales).toBeDefined();
|
||||
expect(Object.keys(config.scales ?? {})).toContain('y');
|
||||
|
||||
// Series: base timestamp + 2 data series
|
||||
expect(config.series).toHaveLength(3);
|
||||
// Base series (index 0) has a value formatter that returns empty string
|
||||
const baseSeries = config.series?.[0] as { value?: () => string };
|
||||
expect(typeof baseSeries?.value).toBe('function');
|
||||
expect(baseSeries?.value?.()).toBe('');
|
||||
|
||||
// Legend items align with series and carry label and color from series config
|
||||
const legendItems = builder.getLegendItems();
|
||||
expect(Object.keys(legendItems)).toEqual(['1', '2']);
|
||||
expect(legendItems[1].seriesIndex).toBe(1);
|
||||
expect(legendItems[1].label).toBe('Requests');
|
||||
expect(legendItems[2].label).toBe('Errors');
|
||||
});
|
||||
|
||||
it('merges axis when addAxis is called twice with same scaleKey', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
builder.addAxis({ scaleKey: 'y', label: 'Requests' });
|
||||
builder.addAxis({ scaleKey: 'y', label: 'Updated Label', show: false });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.axes).toHaveLength(1);
|
||||
expect(config.axes?.[0].label).toBe('Updated Label');
|
||||
expect(config.axes?.[0].show).toBe(false);
|
||||
});
|
||||
|
||||
it('merges scale when addScale is called twice with same scaleKey', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
builder.addScale({ scaleKey: 'y', min: 0 });
|
||||
builder.addScale({ scaleKey: 'y', max: 100 });
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
// Only one scale entry for 'y' (merge path used, no duplicate added)
|
||||
expect(config.scales).toBeDefined();
|
||||
const scales = config.scales ?? {};
|
||||
expect(Object.keys(scales)).toEqual(['y']);
|
||||
expect(scales.y?.range).toBeDefined();
|
||||
});
|
||||
|
||||
it('restores visibility state from localStorage when selectionPreferencesSource is LOCAL_STORAGE', () => {
|
||||
const visibilityMap = new Map<string, boolean>([
|
||||
['Requests', true],
|
||||
['Errors', false],
|
||||
]);
|
||||
|
||||
getStoredSeriesVisibilityMock.getStoredSeriesVisibility.mockReturnValue(
|
||||
visibilityMap,
|
||||
);
|
||||
|
||||
const builder = new UPlotConfigBuilder({
|
||||
widgetId: 'widget-1',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.LOCAL_STORAGE,
|
||||
});
|
||||
|
||||
builder.addSeries(createSeriesProps({ label: 'Requests' }));
|
||||
builder.addSeries(createSeriesProps({ label: 'Errors' }));
|
||||
|
||||
const legendItems = builder.getLegendItems();
|
||||
|
||||
// When any series is hidden, legend visibility is driven by the stored map
|
||||
expect(legendItems[1].show).toBe(true);
|
||||
expect(legendItems[2].show).toBe(false);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const [, firstSeries, secondSeries] = config.series ?? [];
|
||||
|
||||
expect(firstSeries?.show).toBe(true);
|
||||
expect(secondSeries?.show).toBe(false);
|
||||
});
|
||||
|
||||
it('does not attempt to read stored visibility when using in-memory preferences', () => {
|
||||
const builder = new UPlotConfigBuilder({
|
||||
widgetId: 'widget-1',
|
||||
selectionPreferencesSource: SelectionPreferencesSource.IN_MEMORY,
|
||||
});
|
||||
|
||||
builder.addSeries(createSeriesProps({ label: 'Requests' }));
|
||||
|
||||
builder.getLegendItems();
|
||||
builder.getConfig();
|
||||
|
||||
expect(
|
||||
getStoredSeriesVisibilityMock.getStoredSeriesVisibility,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds thresholds only once per scale key', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const thresholdsOptions = {
|
||||
scaleKey: 'y',
|
||||
thresholds: [{ thresholdValue: 100 }],
|
||||
};
|
||||
|
||||
builder.addThresholds(thresholdsOptions);
|
||||
builder.addThresholds(thresholdsOptions);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const drawHooks = config.hooks?.draw ?? [];
|
||||
|
||||
// Only a single draw hook should be registered for the same scaleKey
|
||||
expect(drawHooks.length).toBe(1);
|
||||
});
|
||||
|
||||
it('adds multiple thresholds when scale key is different', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const thresholdsOptions = {
|
||||
scaleKey: 'y',
|
||||
thresholds: [{ thresholdValue: 100 }],
|
||||
};
|
||||
builder.addThresholds(thresholdsOptions);
|
||||
const thresholdsOptions2 = {
|
||||
scaleKey: 'y2',
|
||||
thresholds: [{ thresholdValue: 200 }],
|
||||
};
|
||||
builder.addThresholds(thresholdsOptions2);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const drawHooks = config.hooks?.draw ?? [];
|
||||
|
||||
// Two draw hooks should be registered for different scaleKeys
|
||||
expect(drawHooks.length).toBe(2);
|
||||
});
|
||||
|
||||
it('merges cursor configuration with defaults instead of replacing them', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
builder.setCursor({
|
||||
drag: { setScale: false },
|
||||
});
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.cursor?.drag?.setScale).toBe(false);
|
||||
// Points configuration from DEFAULT_CURSOR_CONFIG should still be present
|
||||
expect(config.cursor?.points).toBeDefined();
|
||||
});
|
||||
|
||||
it('adds plugins and includes them in config', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
const plugin: uPlot.Plugin = {
|
||||
opts: (): void => {},
|
||||
hooks: {},
|
||||
};
|
||||
|
||||
builder.addPlugin(plugin);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.plugins).toContain(plugin);
|
||||
});
|
||||
|
||||
it('sets padding, legend, focus, select, tzDate, bands and includes them in config', () => {
|
||||
const tzDate = (ts: number): Date => new Date(ts);
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const bands: uPlot.Band[] = [{ series: [1, 2], fill: (): string => '#000' }];
|
||||
|
||||
builder.setBands(bands);
|
||||
builder.setPadding([10, 20, 30, 40]);
|
||||
builder.setLegend({ show: true, live: true });
|
||||
builder.setFocus({ alpha: 0.5 });
|
||||
builder.setSelect({ left: 0, width: 0, top: 0, height: 0 });
|
||||
builder.setTzDate(tzDate);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.bands).toEqual(bands);
|
||||
expect(config.padding).toEqual([10, 20, 30, 40]);
|
||||
expect(config.legend).toEqual({ show: true, live: true });
|
||||
expect(config.focus).toEqual({ alpha: 0.5 });
|
||||
expect(config.select).toEqual({ left: 0, width: 0, top: 0, height: 0 });
|
||||
expect(config.tzDate).toBe(tzDate);
|
||||
});
|
||||
|
||||
it('does not include plugins when none added', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.plugins).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not include bands when empty', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.bands).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
import * as scaleUtils from '../../utils/scale';
|
||||
import type { ScaleProps } from '../types';
|
||||
import { DistributionType } from '../types';
|
||||
import { UPlotScaleBuilder } from '../UPlotScaleBuilder';
|
||||
|
||||
const createScaleProps = (overrides: Partial<ScaleProps> = {}): ScaleProps => ({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
auto: undefined,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
softMin: undefined,
|
||||
softMax: undefined,
|
||||
distribution: DistributionType.Linear,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('UPlotScaleBuilder', () => {
|
||||
const getFallbackMinMaxSpy = jest.spyOn(
|
||||
scaleUtils,
|
||||
'getFallbackMinMaxTimeStamp',
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('initializes softMin/softMax correctly when both are 0 (treated as unset)', () => {
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
softMin: 0,
|
||||
softMax: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
// Non-time scale so config path uses thresholds pipeline; we just care that
|
||||
// adjustSoftLimitsWithThresholds receives null soft limits instead of 0/0.
|
||||
const adjustSpy = jest.spyOn(scaleUtils, 'adjustSoftLimitsWithThresholds');
|
||||
|
||||
builder.getConfig();
|
||||
|
||||
expect(adjustSpy).toHaveBeenCalledWith(null, null, undefined, undefined);
|
||||
});
|
||||
|
||||
it('handles time scales using explicit min/max and rounds max down to the previous minute', () => {
|
||||
const min = 1_700_000_000; // seconds
|
||||
const max = 1_700_000_600; // seconds
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
min,
|
||||
max,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const xScale = config.x;
|
||||
|
||||
expect(xScale.time).toBe(true);
|
||||
expect(xScale.auto).toBe(false);
|
||||
expect(Array.isArray(xScale.range)).toBe(true);
|
||||
|
||||
const [resolvedMin, resolvedMax] = xScale.range as [number, number];
|
||||
|
||||
// min is passed through
|
||||
expect(resolvedMin).toBe(min);
|
||||
|
||||
// max is coerced to "endTime - 1 minute" and rounded down to minute precision
|
||||
const oneMinuteAgoTimestamp = (max - 60) * 1000;
|
||||
const currentDate = new Date(oneMinuteAgoTimestamp);
|
||||
currentDate.setSeconds(0);
|
||||
currentDate.setMilliseconds(0);
|
||||
const expectedMax = Math.floor(currentDate.getTime() / 1000);
|
||||
|
||||
expect(resolvedMax).toBe(expectedMax);
|
||||
});
|
||||
|
||||
it('falls back to getFallbackMinMaxTimeStamp when time scale has no min/max', () => {
|
||||
getFallbackMinMaxSpy.mockReturnValue({
|
||||
fallbackMin: 100,
|
||||
fallbackMax: 200,
|
||||
});
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const [resolvedMin, resolvedMax] = config.x.range as [number, number];
|
||||
|
||||
expect(getFallbackMinMaxSpy).toHaveBeenCalled();
|
||||
expect(resolvedMin).toBe(100);
|
||||
// max is aligned to "fallbackMax - 60 seconds" minute boundary
|
||||
expect(resolvedMax).toBeLessThanOrEqual(200);
|
||||
expect(resolvedMax).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it('pipes limits through soft-limit adjustment and log-scale normalization before range config', () => {
|
||||
const adjustSpy = jest.spyOn(scaleUtils, 'adjustSoftLimitsWithThresholds');
|
||||
const normalizeSpy = jest.spyOn(scaleUtils, 'normalizeLogScaleLimits');
|
||||
const getRangeConfigSpy = jest.spyOn(scaleUtils, 'getRangeConfig');
|
||||
|
||||
const thresholds = {
|
||||
scaleKey: 'y',
|
||||
thresholds: [{ thresholdValue: 10 }],
|
||||
yAxisUnit: 'ms',
|
||||
};
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
softMin: 1,
|
||||
softMax: 5,
|
||||
min: 0,
|
||||
max: 100,
|
||||
distribution: DistributionType.Logarithmic,
|
||||
thresholds,
|
||||
logBase: 2,
|
||||
padMinBy: 0.1,
|
||||
padMaxBy: 0.2,
|
||||
}),
|
||||
);
|
||||
|
||||
builder.getConfig();
|
||||
|
||||
expect(adjustSpy).toHaveBeenCalledWith(1, 5, thresholds.thresholds, 'ms');
|
||||
expect(normalizeSpy).toHaveBeenCalledWith({
|
||||
distr: DistributionType.Logarithmic,
|
||||
logBase: 2,
|
||||
limits: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
softMin: expect.anything(),
|
||||
softMax: expect.anything(),
|
||||
},
|
||||
});
|
||||
expect(getRangeConfigSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('computes distribution config for non-time scales and wires range function when range is not provided', () => {
|
||||
const createRangeFnSpy = jest.spyOn(scaleUtils, 'createRangeFunction');
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'y',
|
||||
time: false,
|
||||
distribution: DistributionType.Linear,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const yScale = config.y;
|
||||
|
||||
expect(createRangeFnSpy).toHaveBeenCalled();
|
||||
|
||||
// range should be a function when not provided explicitly
|
||||
expect(typeof yScale.range).toBe('function');
|
||||
|
||||
// distribution config should be applied
|
||||
expect(yScale.distr).toBeDefined();
|
||||
expect(yScale.log).toBeDefined();
|
||||
});
|
||||
|
||||
it('respects explicit range function when provided on props', () => {
|
||||
const explicitRange: uPlot.Scale.Range = jest.fn(() => [
|
||||
0,
|
||||
10,
|
||||
]) as uPlot.Scale.Range;
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'y',
|
||||
range: explicitRange,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const yScale = config.y;
|
||||
|
||||
expect(yScale.range).toBe(explicitRange);
|
||||
});
|
||||
|
||||
it('derives auto flag when not explicitly provided, based on hasFixedRange and time', () => {
|
||||
const getRangeConfigSpy = jest.spyOn(scaleUtils, 'getRangeConfig');
|
||||
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
min: 0,
|
||||
max: 100,
|
||||
time: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
const yScale = config.y;
|
||||
|
||||
expect(getRangeConfigSpy).toHaveBeenCalled();
|
||||
// For non-time scale with fixed min/max, hasFixedRange is true → auto should remain false
|
||||
expect(yScale.auto).toBe(false);
|
||||
});
|
||||
|
||||
it('merge updates internal min/max/soft limits while preserving other props', () => {
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'y',
|
||||
min: 0,
|
||||
max: 10,
|
||||
softMin: 1,
|
||||
softMax: 9,
|
||||
time: false,
|
||||
}),
|
||||
);
|
||||
|
||||
builder.merge({
|
||||
min: 2,
|
||||
softMax: undefined,
|
||||
});
|
||||
|
||||
expect(builder.props.min).toBe(2);
|
||||
expect(builder.props.softMax).toBe(undefined);
|
||||
expect(builder.props.max).toBe(10);
|
||||
expect(builder.props.softMin).toBe(1);
|
||||
expect(builder.props.time).toBe(false);
|
||||
expect(builder.props.scaleKey).toBe('y');
|
||||
expect(builder.props.distribution).toBe(DistributionType.Linear);
|
||||
expect(builder.props.thresholds).toBe(undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { SeriesProps } from '../types';
|
||||
import {
|
||||
DrawStyle,
|
||||
LineInterpolation,
|
||||
LineStyle,
|
||||
VisibilityMode,
|
||||
} from '../types';
|
||||
import { UPlotSeriesBuilder } from '../UPlotSeriesBuilder';
|
||||
|
||||
const createBaseProps = (
|
||||
overrides: Partial<SeriesProps> = {},
|
||||
): SeriesProps => ({
|
||||
scaleKey: 'y',
|
||||
label: 'Requests',
|
||||
colorMapping: {},
|
||||
drawStyle: DrawStyle.Line,
|
||||
isDarkMode: false,
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
interface MockPath extends uPlot.Series.Paths {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
describe('UPlotSeriesBuilder', () => {
|
||||
it('maps basic props into uPlot series config', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: 'Latency',
|
||||
spanGaps: true,
|
||||
show: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.scale).toBe('y');
|
||||
expect(config.label).toBe('Latency');
|
||||
expect(config.spanGaps).toBe(true);
|
||||
expect(config.show).toBe(false);
|
||||
expect(config.pxAlign).toBe(true);
|
||||
expect(typeof config.value).toBe('function');
|
||||
});
|
||||
|
||||
it('uses explicit lineColor when provided, regardless of mapping', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
lineColor: '#ff00ff',
|
||||
colorMapping: { Requests: '#00ff00' },
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.stroke).toBe('#ff00ff');
|
||||
});
|
||||
|
||||
it('falls back to theme colors when no label is provided', () => {
|
||||
const darkBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: undefined,
|
||||
isDarkMode: true,
|
||||
lineColor: undefined,
|
||||
}),
|
||||
);
|
||||
const lightBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: undefined,
|
||||
isDarkMode: false,
|
||||
lineColor: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const darkConfig = darkBuilder.getConfig();
|
||||
const lightConfig = lightBuilder.getConfig();
|
||||
|
||||
expect(darkConfig.stroke).toBe(themeColors.white);
|
||||
expect(lightConfig.stroke).toBe(themeColors.black);
|
||||
});
|
||||
|
||||
it('uses colorMapping when available and no explicit lineColor is provided', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: 'Requests',
|
||||
colorMapping: { Requests: '#123456' },
|
||||
lineColor: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.stroke).toBe('#123456');
|
||||
});
|
||||
|
||||
it('passes through a custom pathBuilder when provided', () => {
|
||||
const customPaths = (jest.fn() as unknown) as uPlot.Series.PathBuilder;
|
||||
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
pathBuilder: customPaths,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.paths).toBe(customPaths);
|
||||
});
|
||||
|
||||
it('does not build line paths when drawStyle is Points, but still renders points', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Points,
|
||||
pointSize: 4,
|
||||
lineWidth: 2,
|
||||
lineColor: '#aa00aa',
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(typeof config.paths).toBe('function');
|
||||
expect(config.paths && config.paths({} as uPlot, 1, 0, 10)).toBeNull();
|
||||
|
||||
expect(config.points).toBeDefined();
|
||||
expect(config.points?.stroke).toBe('#aa00aa');
|
||||
expect(config.points?.fill).toBe('#aa00aa');
|
||||
expect(config.points?.show).toBe(true);
|
||||
expect(config.points?.size).toBe(4);
|
||||
});
|
||||
|
||||
it('derives point size based on lineWidth and pointSize', () => {
|
||||
const smallPointsBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
lineWidth: 4,
|
||||
pointSize: 2,
|
||||
}),
|
||||
);
|
||||
const largePointsBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
lineWidth: 2,
|
||||
pointSize: 4,
|
||||
}),
|
||||
);
|
||||
|
||||
const smallConfig = smallPointsBuilder.getConfig();
|
||||
const largeConfig = largePointsBuilder.getConfig();
|
||||
|
||||
expect(smallConfig.points?.size).toBeUndefined();
|
||||
expect(largeConfig.points?.size).toBe(4);
|
||||
});
|
||||
|
||||
it('uses pointsBuilder when provided instead of default visibility logic', () => {
|
||||
const pointsBuilder: uPlot.Series.Points.Show = jest.fn(
|
||||
() => true,
|
||||
) as uPlot.Series.Points.Show;
|
||||
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
pointsBuilder,
|
||||
drawStyle: DrawStyle.Line,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.points?.show).toBe(pointsBuilder);
|
||||
});
|
||||
|
||||
it('respects VisibilityMode for point visibility when no custom pointsBuilder is given', () => {
|
||||
const neverPointsBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
showPoints: VisibilityMode.Never,
|
||||
}),
|
||||
);
|
||||
const alwaysPointsBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
showPoints: VisibilityMode.Always,
|
||||
}),
|
||||
);
|
||||
|
||||
const neverConfig = neverPointsBuilder.getConfig();
|
||||
const alwaysConfig = alwaysPointsBuilder.getConfig();
|
||||
|
||||
expect(neverConfig.points?.show).toBe(false);
|
||||
expect(alwaysConfig.points?.show).toBe(true);
|
||||
});
|
||||
|
||||
it('applies LineStyle.Dashed and lineCap to line config', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
lineStyle: LineStyle.Dashed,
|
||||
lineCap: 'round' as CanvasLineCap,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.dash).toEqual([10, 10]);
|
||||
expect(config.cap).toBe('round');
|
||||
});
|
||||
|
||||
it('builds default paths for Line drawStyle and invokes the path builder', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
lineInterpolation: LineInterpolation.Linear,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
const result = config.paths?.({} as uPlot, 1, 0, 10);
|
||||
expect((result as MockPath).name).toBe('linear');
|
||||
});
|
||||
|
||||
it('uses StepBefore and StepAfter interpolation for line paths', () => {
|
||||
const stepBeforeBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
lineInterpolation: LineInterpolation.StepBefore,
|
||||
}),
|
||||
);
|
||||
const stepAfterBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
lineInterpolation: LineInterpolation.StepAfter,
|
||||
}),
|
||||
);
|
||||
|
||||
const stepBeforeConfig = stepBeforeBuilder.getConfig();
|
||||
const stepAfterConfig = stepAfterBuilder.getConfig();
|
||||
const stepBeforePath = stepBeforeConfig.paths?.({} as uPlot, 1, 0, 5);
|
||||
const stepAfterPath = stepAfterConfig.paths?.({} as uPlot, 1, 0, 5);
|
||||
expect((stepBeforePath as MockPath).name).toBe('stepped-(-1)');
|
||||
expect((stepAfterPath as MockPath).name).toBe('stepped-(1)');
|
||||
});
|
||||
|
||||
it('defaults to spline interpolation when lineInterpolation is Spline or undefined', () => {
|
||||
const splineBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
drawStyle: DrawStyle.Line,
|
||||
lineInterpolation: LineInterpolation.Spline,
|
||||
}),
|
||||
);
|
||||
const defaultBuilder = new UPlotSeriesBuilder(
|
||||
createBaseProps({ drawStyle: DrawStyle.Line }),
|
||||
);
|
||||
|
||||
const splineConfig = splineBuilder.getConfig();
|
||||
const defaultConfig = defaultBuilder.getConfig();
|
||||
|
||||
const splinePath = splineConfig.paths?.({} as uPlot, 1, 0, 10);
|
||||
const defaultPath = defaultConfig.paths?.({} as uPlot, 1, 0, 10);
|
||||
|
||||
expect((splinePath as MockPath).name).toBe('spline');
|
||||
expect((defaultPath as MockPath).name).toBe('spline');
|
||||
});
|
||||
|
||||
it('uses generateColor when label has no colorMapping and no lineColor', () => {
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
label: 'CustomSeries',
|
||||
colorMapping: {},
|
||||
lineColor: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
expect(config.stroke).toBe('#E64A3C');
|
||||
});
|
||||
|
||||
it('passes through pointsFilter when provided', () => {
|
||||
const pointsFilter: uPlot.Series.Points.Filter = jest.fn(
|
||||
(_self, _seriesIdx, _show) => null,
|
||||
);
|
||||
|
||||
const builder = new UPlotSeriesBuilder(
|
||||
createBaseProps({
|
||||
pointsFilter,
|
||||
drawStyle: DrawStyle.Line,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(config.points?.filter).toBe(pointsFilter);
|
||||
});
|
||||
});
|
||||
218
frontend/src/lib/uPlotV2/utils/__tests__/axis.test.ts
Normal file
218
frontend/src/lib/uPlotV2/utils/__tests__/axis.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import type uPlot from 'uplot';
|
||||
import { Axis } from 'uplot';
|
||||
|
||||
import {
|
||||
buildYAxisSizeCalculator,
|
||||
calculateTextWidth,
|
||||
getExistingAxisSize,
|
||||
} from '../axis';
|
||||
|
||||
describe('axis utils', () => {
|
||||
describe('calculateTextWidth', () => {
|
||||
it('returns 0 when values are undefined or empty', () => {
|
||||
const mockSelf = ({
|
||||
ctx: {
|
||||
measureText: jest.fn(),
|
||||
font: '',
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
// internally the type is string but it is an array of strings
|
||||
const mockAxis: Axis = { font: (['12px sans-serif'] as unknown) as string };
|
||||
|
||||
expect(calculateTextWidth(mockSelf, mockAxis, undefined)).toBe(0);
|
||||
expect(calculateTextWidth(mockSelf, mockAxis, [])).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when longest value is empty string or axis has no usable font', () => {
|
||||
const mockSelf = ({
|
||||
ctx: {
|
||||
measureText: jest.fn(),
|
||||
font: '',
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const axisWithoutFont: Axis = { font: '' };
|
||||
const axisWithEmptyFontArray: Axis = { font: '' };
|
||||
|
||||
expect(calculateTextWidth(mockSelf, axisWithoutFont, [''])).toBe(0);
|
||||
expect(
|
||||
calculateTextWidth(mockSelf, axisWithEmptyFontArray, ['a', 'bb']),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('measures longest value using canvas context and axis font', () => {
|
||||
const measureText = jest.fn(() => ({ width: 100 }));
|
||||
const mockSelf = ({
|
||||
ctx: {
|
||||
font: '',
|
||||
measureText,
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const mockAxis: Axis = { font: (['14px Arial'] as unknown) as string };
|
||||
const values = ['1', '1234', '12'];
|
||||
const dpr =
|
||||
((global as unknown) as { devicePixelRatio?: number }).devicePixelRatio ??
|
||||
1;
|
||||
|
||||
const result = calculateTextWidth(mockSelf, mockAxis, values);
|
||||
|
||||
expect(measureText).toHaveBeenCalledWith('1234');
|
||||
expect(mockSelf.ctx.font).toBe('14px Arial');
|
||||
expect(result).toBe(100 / dpr);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExistingAxisSize', () => {
|
||||
it('returns internal _size when present', () => {
|
||||
const axis: any = {
|
||||
_size: 42,
|
||||
size: 10,
|
||||
};
|
||||
|
||||
const result = getExistingAxisSize({
|
||||
uplotInstance: ({} as unknown) as uPlot,
|
||||
axis,
|
||||
axisIdx: 0,
|
||||
cycleNum: 0,
|
||||
});
|
||||
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it('invokes size function when _size is not set', () => {
|
||||
const sizeFn = jest.fn(() => 24);
|
||||
const axis: Axis = { size: sizeFn };
|
||||
const instance = ({} as unknown) as uPlot;
|
||||
|
||||
const result = getExistingAxisSize({
|
||||
uplotInstance: instance,
|
||||
axis,
|
||||
values: ['10', '20'],
|
||||
axisIdx: 1,
|
||||
cycleNum: 2,
|
||||
});
|
||||
|
||||
expect(sizeFn).toHaveBeenCalledWith(instance, ['10', '20'], 1, 2);
|
||||
expect(result).toBe(24);
|
||||
});
|
||||
|
||||
it('returns numeric size or 0 when neither _size nor size are provided', () => {
|
||||
const axisWithSize: Axis = { size: 16 };
|
||||
const axisWithoutSize: Axis = {};
|
||||
const instance = ({} as unknown) as uPlot;
|
||||
|
||||
expect(
|
||||
getExistingAxisSize({
|
||||
uplotInstance: instance,
|
||||
axis: axisWithSize,
|
||||
axisIdx: 0,
|
||||
cycleNum: 0,
|
||||
}),
|
||||
).toBe(16);
|
||||
|
||||
expect(
|
||||
getExistingAxisSize({
|
||||
uplotInstance: instance,
|
||||
axis: axisWithoutSize,
|
||||
axisIdx: 0,
|
||||
cycleNum: 0,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildYAxisSizeCalculator', () => {
|
||||
it('delegates to getExistingAxisSize when cycleNum > 1', () => {
|
||||
const sizeCalculator = buildYAxisSizeCalculator(5);
|
||||
|
||||
const axis: any = {
|
||||
_size: 80,
|
||||
ticks: { size: 10 },
|
||||
font: ['12px sans-serif'],
|
||||
};
|
||||
|
||||
const measureText = jest.fn(() => ({ width: 60 }));
|
||||
const self = ({
|
||||
axes: [axis],
|
||||
ctx: {
|
||||
font: '',
|
||||
measureText,
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
if (typeof sizeCalculator === 'number') {
|
||||
throw new Error('Size calculator is a number');
|
||||
}
|
||||
|
||||
const result = sizeCalculator(self, ['10', '20'], 0, 2);
|
||||
|
||||
expect(result).toBe(80);
|
||||
expect(measureText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('computes size from ticks, gap and text width when cycleNum <= 1', () => {
|
||||
const gap = 7;
|
||||
const sizeCalculator = buildYAxisSizeCalculator(gap);
|
||||
|
||||
const axis: Axis = {
|
||||
ticks: { size: 12 },
|
||||
font: (['12px sans-serif'] as unknown) as string,
|
||||
};
|
||||
|
||||
const measureText = jest.fn(() => ({ width: 50 }));
|
||||
const self = ({
|
||||
axes: [axis],
|
||||
ctx: {
|
||||
font: '',
|
||||
measureText,
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const dpr =
|
||||
((global as unknown) as { devicePixelRatio?: number }).devicePixelRatio ??
|
||||
1;
|
||||
const expected = Math.ceil(12 + gap + 50 / dpr);
|
||||
|
||||
if (typeof sizeCalculator === 'number') {
|
||||
throw new Error('Size calculator is a number');
|
||||
}
|
||||
|
||||
const result = sizeCalculator(self, ['short', 'the-longest'], 0, 0);
|
||||
expect(measureText).toHaveBeenCalledWith('the-longest');
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('uses 0 ticks size when ticks are not defined', () => {
|
||||
const gap = 4;
|
||||
const sizeCalculator = buildYAxisSizeCalculator(gap);
|
||||
|
||||
const axis: Axis = {
|
||||
font: (['12px sans-serif'] as unknown) as string,
|
||||
};
|
||||
|
||||
const measureText = jest.fn(() => ({ width: 40 }));
|
||||
const self = ({
|
||||
axes: [axis],
|
||||
ctx: {
|
||||
font: '',
|
||||
measureText,
|
||||
},
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const dpr =
|
||||
((global as unknown) as { devicePixelRatio?: number }).devicePixelRatio ??
|
||||
1;
|
||||
const expected = Math.ceil(gap + 40 / dpr);
|
||||
|
||||
if (typeof sizeCalculator === 'number') {
|
||||
throw new Error('Size calculator is a number');
|
||||
}
|
||||
|
||||
const result = sizeCalculator(self, ['1', '123'], 0, 1);
|
||||
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
80
frontend/src/lib/uPlotV2/utils/axis.ts
Normal file
80
frontend/src/lib/uPlotV2/utils/axis.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Axis } from 'uplot';
|
||||
|
||||
/**
|
||||
* Calculate text width for longest value
|
||||
*/
|
||||
export function calculateTextWidth(
|
||||
self: uPlot,
|
||||
axis: Axis,
|
||||
values: string[] | undefined,
|
||||
): number {
|
||||
if (!values || values.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Find longest value
|
||||
const longestVal = values.reduce(
|
||||
(acc, val) => (val.length > acc.length ? val : acc),
|
||||
'',
|
||||
);
|
||||
|
||||
if (longestVal === '' || !axis.font?.[0]) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
self.ctx.font = axis.font[0];
|
||||
return self.ctx.measureText(longestVal).width / devicePixelRatio;
|
||||
}
|
||||
|
||||
export function getExistingAxisSize({
|
||||
uplotInstance,
|
||||
axis,
|
||||
values,
|
||||
axisIdx,
|
||||
cycleNum,
|
||||
}: {
|
||||
uplotInstance: uPlot;
|
||||
axis: Axis;
|
||||
values?: string[];
|
||||
axisIdx: number;
|
||||
cycleNum: number;
|
||||
}): number {
|
||||
const internalSize = (axis as { _size?: number })._size;
|
||||
if (internalSize !== undefined) {
|
||||
return internalSize;
|
||||
}
|
||||
|
||||
const existingSize = axis.size;
|
||||
if (typeof existingSize === 'function') {
|
||||
return existingSize(uplotInstance, values ?? [], axisIdx, cycleNum);
|
||||
}
|
||||
|
||||
return existingSize ?? 0;
|
||||
}
|
||||
|
||||
export function buildYAxisSizeCalculator(gap: number): uPlot.Axis.Size {
|
||||
return (
|
||||
self: uPlot,
|
||||
values: string[] | undefined,
|
||||
axisIdx: number,
|
||||
cycleNum: number,
|
||||
): number => {
|
||||
const axis = self.axes[axisIdx];
|
||||
|
||||
// Bail out, force convergence
|
||||
if (cycleNum > 1) {
|
||||
return getExistingAxisSize({
|
||||
uplotInstance: self,
|
||||
axis,
|
||||
values,
|
||||
axisIdx,
|
||||
cycleNum,
|
||||
});
|
||||
}
|
||||
|
||||
let axisSize = (axis.ticks?.size ?? 0) + gap;
|
||||
axisSize += calculateTextWidth(self, axis, values);
|
||||
|
||||
return Math.ceil(axisSize);
|
||||
};
|
||||
}
|
||||
@@ -43,11 +43,11 @@ var (
|
||||
// FromUnit returns a converter for the given unit
|
||||
func FromUnit(u Unit) Converter {
|
||||
switch u {
|
||||
case "ns", "us", "µs", "ms", "s", "m", "h", "d", "min", "w", "wk":
|
||||
case "ns", "us", "µs", "ms", "s", "m", "h", "d", "min":
|
||||
return DurationConverter
|
||||
case "bytes", "decbytes", "bits", "bit", "decbits", "kbytes", "decKbytes", "deckbytes", "mbytes", "decMbytes", "decmbytes", "gbytes", "decGbytes", "decgbytes", "tbytes", "decTbytes", "dectbytes", "pbytes", "decPbytes", "decpbytes", "By", "kBy", "MBy", "GBy", "TBy", "PBy", "EBy", "ZBy", "YBy", "KiBy", "MiBy", "GiBy", "TiBy", "PiBy", "EiBy", "ZiBy", "YiBy", "kbit", "Mbit", "Gbit", "Tbit", "Pbit", "Ebit", "Zbit", "Ybit", "Kibit", "Mibit", "Gibit", "Tibit", "Pibit":
|
||||
case "bytes", "decbytes", "bits", "decbits", "kbytes", "decKbytes", "deckbytes", "mbytes", "decMbytes", "decmbytes", "gbytes", "decGbytes", "decgbytes", "tbytes", "decTbytes", "dectbytes", "pbytes", "decPbytes", "decpbytes", "By", "kBy", "MBy", "GBy", "TBy", "PBy":
|
||||
return DataConverter
|
||||
case "binBps", "Bps", "binbps", "bps", "KiBs", "Kibits", "KBs", "Kbits", "MiBs", "Mibits", "MBs", "Mbits", "GiBs", "Gibits", "GBs", "Gbits", "TiBs", "Tibits", "TBs", "Tbits", "PiBs", "Pibits", "PBs", "Pbits", "By/s", "kBy/s", "MBy/s", "GBy/s", "TBy/s", "PBy/s", "EBy/s", "ZBy/s", "YBy/s", "bit/s", "kbit/s", "Mbit/s", "Gbit/s", "Tbit/s", "Pbit/s", "Ebit/s", "Zbit/s", "Ybit/s", "KiBy/s", "MiBy/s", "GiBy/s", "TiBy/s", "PiBy/s", "EiBy/s", "ZiBy/s", "YiBy/s", "Kibit/s", "Mibit/s", "Gibit/s", "Tibit/s", "Pibit/s", "Eibit/s", "Zibit/s", "Yibit/s":
|
||||
case "binBps", "Bps", "binbps", "bps", "KiBs", "Kibits", "KBs", "Kbits", "MiBs", "Mibits", "MBs", "Mbits", "GiBs", "Gibits", "GBs", "Gbits", "TiBs", "Tibits", "TBs", "Tbits", "PiBs", "Pibits", "PBs", "Pbits", "By/s", "kBy/s", "MBy/s", "GBy/s", "TBy/s", "PBy/s", "bit/s", "kbit/s", "Mbit/s", "Gbit/s", "Tbit/s", "Pbit/s":
|
||||
return DataRateConverter
|
||||
case "percent", "percentunit", "%":
|
||||
return PercentConverter
|
||||
|
||||
@@ -58,80 +58,36 @@ func (*dataConverter) Name() string {
|
||||
return "data"
|
||||
}
|
||||
|
||||
// Notation followed by UCUM:
|
||||
// https://ucum.org/ucum
|
||||
// kibi = Ki, mebi = Mi, gibi = Gi, tebi = Ti, pibi = Pi
|
||||
// kilo = k, mega = M, giga = G, tera = T, peta = P
|
||||
// exa = E, zetta = Z, yotta = Y
|
||||
// byte = By, bit = bit
|
||||
func FromDataUnit(u Unit) float64 {
|
||||
switch u {
|
||||
case "bytes", "By": // base 2
|
||||
return Byte
|
||||
case "decbytes": // base 10
|
||||
return Byte
|
||||
case "bits", "bit": // base 2
|
||||
case "bits": // base 2
|
||||
return Bit
|
||||
case "decbits": // base 10
|
||||
return Bit
|
||||
case "kbytes", "KiBy": // base 2
|
||||
case "kbytes", "kBy": // base 2
|
||||
return Kibibyte
|
||||
case "decKbytes", "deckbytes", "kBy": // base 10
|
||||
case "decKbytes", "deckbytes": // base 10
|
||||
return Kilobyte
|
||||
case "mbytes", "MiBy": // base 2
|
||||
case "mbytes", "MBy": // base 2
|
||||
return Mebibyte
|
||||
case "decMbytes", "decmbytes", "MBy": // base 10
|
||||
case "decMbytes", "decmbytes": // base 10
|
||||
return Megabyte
|
||||
case "gbytes", "GiBy": // base 2
|
||||
case "gbytes", "GBy": // base 2
|
||||
return Gibibyte
|
||||
case "decGbytes", "decgbytes", "GBy": // base 10
|
||||
case "decGbytes", "decgbytes": // base 10
|
||||
return Gigabyte
|
||||
case "tbytes", "TiBy": // base 2
|
||||
case "tbytes", "TBy": // base 2
|
||||
return Tebibyte
|
||||
case "decTbytes", "dectbytes", "TBy": // base 10
|
||||
case "decTbytes", "dectbytes": // base 10
|
||||
return Terabyte
|
||||
case "pbytes", "PiBy": // base 2
|
||||
case "pbytes", "PBy": // base 2
|
||||
return Pebibyte
|
||||
case "decPbytes", "decpbytes", "PBy": // base 10
|
||||
case "decPbytes", "decpbytes": // base 10
|
||||
return Petabyte
|
||||
case "EBy": // base 10
|
||||
return Exabyte
|
||||
case "ZBy": // base 10
|
||||
return Zettabyte
|
||||
case "YBy": // base 10
|
||||
return Yottabyte
|
||||
case "Kibit": // base 2
|
||||
return Kibibit
|
||||
case "Mibit": // base 2
|
||||
return Mebibit
|
||||
case "Gibit": // base 2
|
||||
return Gibibit
|
||||
case "Tibit": // base 2
|
||||
return Tebibit
|
||||
case "Pibit": // base 2
|
||||
return Pebibit
|
||||
case "EiBy": // base 2
|
||||
return Exbibyte
|
||||
case "ZiBy": // base 2
|
||||
return Zebibyte
|
||||
case "YiBy": // base 2
|
||||
return Yobibyte
|
||||
case "kbit": // base 10
|
||||
return Kilobit
|
||||
case "Mbit": // base 10
|
||||
return Megabit
|
||||
case "Gbit": // base 10
|
||||
return Gigabit
|
||||
case "Tbit": // base 10
|
||||
return Terabit
|
||||
case "Pbit": // base 10
|
||||
return Petabit
|
||||
case "Ebit": // base 10
|
||||
return Exabit
|
||||
case "Zbit": // base 10
|
||||
return Zettabit
|
||||
case "Ybit": // base 10
|
||||
return Yottabit
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -54,12 +54,6 @@ func (*dataRateConverter) Name() string {
|
||||
return "data_rate"
|
||||
}
|
||||
|
||||
// Notation followed by UCUM:
|
||||
// https://ucum.org/ucum
|
||||
// kibi = Ki, mebi = Mi, gibi = Gi, tebi = Ti, pibi = Pi
|
||||
// kilo = k, mega = M, giga = G, tera = T, peta = P
|
||||
// exa = E, zetta = Z, yotta = Y
|
||||
// byte = By, bit = bit
|
||||
func FromDataRateUnit(u Unit) float64 {
|
||||
// See https://github.com/SigNoz/signoz/blob/5a81f5f90b34845f5b4b3bdd46acf29d04bf3987/frontend/src/container/NewWidget/RightContainer/dataFormatCategories.ts#L62-L85
|
||||
switch u {
|
||||
@@ -71,70 +65,46 @@ func FromDataRateUnit(u Unit) float64 {
|
||||
return BitPerSecond
|
||||
case "bps", "bit/s": // bits/sec(SI)
|
||||
return BitPerSecond
|
||||
case "KiBs", "KiBy/s": // kibibytes/sec
|
||||
case "KiBs": // kibibytes/sec
|
||||
return KibibytePerSecond
|
||||
case "Kibits", "Kibit/s": // kibibits/sec
|
||||
case "Kibits": // kibibits/sec
|
||||
return KibibitPerSecond
|
||||
case "KBs", "kBy/s": // kilobytes/sec
|
||||
return KilobytePerSecond
|
||||
case "Kbits", "kbit/s": // kilobits/sec
|
||||
return KilobitPerSecond
|
||||
case "MiBs", "MiBy/s": // mebibytes/sec
|
||||
case "MiBs": // mebibytes/sec
|
||||
return MebibytePerSecond
|
||||
case "Mibits", "Mibit/s": // mebibits/sec
|
||||
case "Mibits": // mebibits/sec
|
||||
return MebibitPerSecond
|
||||
case "MBs", "MBy/s": // megabytes/sec
|
||||
return MegabytePerSecond
|
||||
case "Mbits", "Mbit/s": // megabits/sec
|
||||
return MegabitPerSecond
|
||||
case "GiBs", "GiBy/s": // gibibytes/sec
|
||||
case "GiBs": // gibibytes/sec
|
||||
return GibibytePerSecond
|
||||
case "Gibits", "Gibit/s": // gibibits/sec
|
||||
case "Gibits": // gibibits/sec
|
||||
return GibibitPerSecond
|
||||
case "GBs", "GBy/s": // gigabytes/sec
|
||||
return GigabytePerSecond
|
||||
case "Gbits", "Gbit/s": // gigabits/sec
|
||||
return GigabitPerSecond
|
||||
case "TiBs", "TiBy/s": // tebibytes/sec
|
||||
case "TiBs": // tebibytes/sec
|
||||
return TebibytePerSecond
|
||||
case "Tibits", "Tibit/s": // tebibits/sec
|
||||
case "Tibits": // tebibits/sec
|
||||
return TebibitPerSecond
|
||||
case "TBs", "TBy/s": // terabytes/sec
|
||||
return TerabytePerSecond
|
||||
case "Tbits", "Tbit/s": // terabits/sec
|
||||
return TerabitPerSecond
|
||||
case "PiBs", "PiBy/s": // pebibytes/sec
|
||||
case "PiBs": // pebibytes/sec
|
||||
return PebibytePerSecond
|
||||
case "Pibits", "Pibit/s": // pebibits/sec
|
||||
case "Pibits": // pebibits/sec
|
||||
return PebibitPerSecond
|
||||
case "PBs", "PBy/s": // petabytes/sec
|
||||
return PetabytePerSecond
|
||||
case "Pbits", "Pbit/s": // petabits/sec
|
||||
return PetabitPerSecond
|
||||
case "EBy/s": // exabytes/sec
|
||||
return ExabytePerSecond
|
||||
case "Ebit/s": // exabits/sec
|
||||
return ExabitPerSecond
|
||||
case "EiBy/s": // exbibytes/sec
|
||||
return ExbibytePerSecond
|
||||
case "Eibit/s": // exbibits/sec
|
||||
return ExbibitPerSecond
|
||||
case "ZBy/s": // zettabytes/sec
|
||||
return ZettabytePerSecond
|
||||
case "Zbit/s": // zettabits/sec
|
||||
return ZettabitPerSecond
|
||||
case "ZiBy/s": // zebibytes/sec
|
||||
return ZebibytePerSecond
|
||||
case "Zibit/s": // zebibits/sec
|
||||
return ZebibitPerSecond
|
||||
case "YBy/s": // yottabytes/sec
|
||||
return YottabytePerSecond
|
||||
case "Ybit/s": // yottabits/sec
|
||||
return YottabitPerSecond
|
||||
case "YiBy/s": // yobibytes/sec
|
||||
return YobibytePerSecond
|
||||
case "Yibit/s": // yobibits/sec
|
||||
return YobibitPerSecond
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -75,83 +75,3 @@ func TestDataRate(t *testing.T) {
|
||||
// 1024 * 1024 * 1024 bytes = 1 gbytes
|
||||
assert.Equal(t, Value{F: 1, U: "GiBs"}, dataRateConverter.Convert(Value{F: 1024 * 1024 * 1024, U: "binBps"}, "GiBs"))
|
||||
}
|
||||
|
||||
func TestDataRateConversionUCUMUnit(t *testing.T) {
|
||||
dataRateConverter := NewDataRateConverter()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input Value
|
||||
toUnit Unit
|
||||
expected Value
|
||||
}{
|
||||
// Binary byte scaling
|
||||
{name: "Binary byte scaling: 1024 By/s = 1 KiBy/s", input: Value{F: 1024, U: "By/s"}, toUnit: "KiBy/s", expected: Value{F: 1, U: "KiBy/s"}},
|
||||
{name: "Kibibyte to bytes: 1 KiBy/s = 1024 By/s", input: Value{F: 1, U: "KiBy/s"}, toUnit: "By/s", expected: Value{F: 1024, U: "By/s"}},
|
||||
{name: "Binary byte scaling: 1024 KiBy/s = 1 MiBy/s", input: Value{F: 1024, U: "KiBy/s"}, toUnit: "MiBy/s", expected: Value{F: 1, U: "MiBy/s"}},
|
||||
{name: "Gibibyte to bytes: 1 GiBy/s = 1073741824 By/s", input: Value{F: 1, U: "GiBy/s"}, toUnit: "By/s", expected: Value{F: 1024 * 1024 * 1024, U: "By/s"}},
|
||||
{name: "Binary byte scaling: 1024 MiBy/s = 1 GiBy/s", input: Value{F: 1024, U: "MiBy/s"}, toUnit: "GiBy/s", expected: Value{F: 1, U: "GiBy/s"}},
|
||||
{name: "Gibibyte to mebibyte: 1 GiBy/s = 1024 MiBy/s", input: Value{F: 1, U: "GiBy/s"}, toUnit: "MiBy/s", expected: Value{F: 1024, U: "MiBy/s"}},
|
||||
{name: "Binary byte scaling: 1024 GiBy/s = 1 TiBy/s", input: Value{F: 1024, U: "GiBy/s"}, toUnit: "TiBy/s", expected: Value{F: 1, U: "TiBy/s"}},
|
||||
{name: "Tebibyte to bytes: 1 TiBy/s = 1099511627776 By/s", input: Value{F: 1, U: "TiBy/s"}, toUnit: "By/s", expected: Value{F: 1024 * 1024 * 1024 * 1024, U: "By/s"}},
|
||||
{name: "Binary byte scaling: 1024 TiBy/s = 1 PiBy/s", input: Value{F: 1024, U: "TiBy/s"}, toUnit: "PiBy/s", expected: Value{F: 1, U: "PiBy/s"}},
|
||||
{name: "Pebibyte to tebibyte: 1 PiBy/s = 1024 TiBy/s", input: Value{F: 1, U: "PiBy/s"}, toUnit: "TiBy/s", expected: Value{F: 1024, U: "TiBy/s"}},
|
||||
// Binary bit scaling
|
||||
{name: "Binary bit scaling: 1024 bit/s = 1 Kibit/s", input: Value{F: 1024, U: "bit/s"}, toUnit: "Kibit/s", expected: Value{F: 1, U: "Kibit/s"}},
|
||||
{name: "Kibibit to bits: 1 Kibit/s = 1024 bit/s", input: Value{F: 1, U: "Kibit/s"}, toUnit: "bit/s", expected: Value{F: 1024, U: "bit/s"}},
|
||||
{name: "Binary bit scaling: 1024 Kibit/s = 1 Mibit/s", input: Value{F: 1024, U: "Kibit/s"}, toUnit: "Mibit/s", expected: Value{F: 1, U: "Mibit/s"}},
|
||||
{name: "Gibibit to bits: 1 Gibit/s = 1073741824 bit/s", input: Value{F: 1, U: "Gibit/s"}, toUnit: "bit/s", expected: Value{F: 1024 * 1024 * 1024, U: "bit/s"}},
|
||||
{name: "Binary bit scaling: 1024 Mibit/s = 1 Gibit/s", input: Value{F: 1024, U: "Mibit/s"}, toUnit: "Gibit/s", expected: Value{F: 1, U: "Gibit/s"}},
|
||||
{name: "Gibibit to mebibit: 1 Gibit/s = 1024 Mibit/s", input: Value{F: 1, U: "Gibit/s"}, toUnit: "Mibit/s", expected: Value{F: 1024, U: "Mibit/s"}},
|
||||
{name: "Binary bit scaling: 1024 Gibit/s = 1 Tibit/s", input: Value{F: 1024, U: "Gibit/s"}, toUnit: "Tibit/s", expected: Value{F: 1, U: "Tibit/s"}},
|
||||
{name: "Tebibit to gibibit: 1 Tibit/s = 1024 Gibit/s", input: Value{F: 1, U: "Tibit/s"}, toUnit: "Gibit/s", expected: Value{F: 1024, U: "Gibit/s"}},
|
||||
{name: "Binary bit scaling: 1024 Tibit/s = 1 Pibit/s", input: Value{F: 1024, U: "Tibit/s"}, toUnit: "Pibit/s", expected: Value{F: 1, U: "Pibit/s"}},
|
||||
{name: "Pebibit to tebibit: 1 Pibit/s = 1024 Tibit/s", input: Value{F: 1, U: "Pibit/s"}, toUnit: "Tibit/s", expected: Value{F: 1024, U: "Tibit/s"}},
|
||||
// Bytes to bits
|
||||
{name: "Bytes to bits: 1 KiBy/s = 8 Kibit/s", input: Value{F: 1, U: "KiBy/s"}, toUnit: "Kibit/s", expected: Value{F: 8, U: "Kibit/s"}},
|
||||
{name: "Bytes to bits: 1 MiBy/s = 8 Mibit/s", input: Value{F: 1, U: "MiBy/s"}, toUnit: "Mibit/s", expected: Value{F: 8, U: "Mibit/s"}},
|
||||
{name: "Bytes to bits: 1 GiBy/s = 8 Gibit/s", input: Value{F: 1, U: "GiBy/s"}, toUnit: "Gibit/s", expected: Value{F: 8, U: "Gibit/s"}},
|
||||
// Unit alias
|
||||
{name: "Unit alias: 1 KiBs = 1 KiBy/s", input: Value{F: 1, U: "KiBs"}, toUnit: "KiBy/s", expected: Value{F: 1, U: "KiBy/s"}},
|
||||
{name: "Unit alias: 1 Kibits = 1 Kibit/s", input: Value{F: 1, U: "Kibits"}, toUnit: "Kibit/s", expected: Value{F: 1, U: "Kibit/s"}},
|
||||
// SI byte scaling (Exa, Zetta, Yotta)
|
||||
{name: "SI byte scaling: 1000 PBy/s = 1 EBy/s", input: Value{F: 1000, U: "PBy/s"}, toUnit: "EBy/s", expected: Value{F: 1, U: "EBy/s"}},
|
||||
{name: "Exabyte to bytes: 1 EBy/s = 1e18 By/s", input: Value{F: 1, U: "EBy/s"}, toUnit: "By/s", expected: Value{F: 1e18, U: "By/s"}},
|
||||
{name: "SI byte scaling: 1000 EBy/s = 1 ZBy/s", input: Value{F: 1000, U: "EBy/s"}, toUnit: "ZBy/s", expected: Value{F: 1, U: "ZBy/s"}},
|
||||
{name: "Zettabyte to petabytes: 1 ZBy/s = 1000000 PBy/s", input: Value{F: 1, U: "ZBy/s"}, toUnit: "PBy/s", expected: Value{F: 1e6, U: "PBy/s"}},
|
||||
{name: "SI byte scaling: 1000 ZBy/s = 1 YBy/s", input: Value{F: 1000, U: "ZBy/s"}, toUnit: "YBy/s", expected: Value{F: 1, U: "YBy/s"}},
|
||||
{name: "Yottabyte to zettabyte: 1 YBy/s = 1000 ZBy/s", input: Value{F: 1, U: "YBy/s"}, toUnit: "ZBy/s", expected: Value{F: 1000, U: "ZBy/s"}},
|
||||
// Binary byte scaling (Exbi, Zebi, Yobi)
|
||||
{name: "Binary byte scaling: 1024 PiBy/s = 1 EiBy/s", input: Value{F: 1024, U: "PiBy/s"}, toUnit: "EiBy/s", expected: Value{F: 1, U: "EiBy/s"}},
|
||||
{name: "Exbibyte to tebibytes: 1 EiBy/s = 1048576 TiBy/s", input: Value{F: 1, U: "EiBy/s"}, toUnit: "TiBy/s", expected: Value{F: 1024 * 1024, U: "TiBy/s"}},
|
||||
{name: "Binary byte scaling: 1024 EiBy/s = 1 ZiBy/s", input: Value{F: 1024, U: "EiBy/s"}, toUnit: "ZiBy/s", expected: Value{F: 1, U: "ZiBy/s"}},
|
||||
{name: "Zebibyte to exbibyte: 1 ZiBy/s = 1024 EiBy/s", input: Value{F: 1, U: "ZiBy/s"}, toUnit: "EiBy/s", expected: Value{F: 1024, U: "EiBy/s"}},
|
||||
{name: "Binary byte scaling: 1024 ZiBy/s = 1 YiBy/s", input: Value{F: 1024, U: "ZiBy/s"}, toUnit: "YiBy/s", expected: Value{F: 1, U: "YiBy/s"}},
|
||||
{name: "Yobibyte to zebibyte: 1 YiBy/s = 1024 ZiBy/s", input: Value{F: 1, U: "YiBy/s"}, toUnit: "ZiBy/s", expected: Value{F: 1024, U: "ZiBy/s"}},
|
||||
// SI bit scaling (Exa, Zetta, Yotta)
|
||||
{name: "SI bit scaling: 1000 Pbit/s = 1 Ebit/s", input: Value{F: 1000, U: "Pbit/s"}, toUnit: "Ebit/s", expected: Value{F: 1, U: "Ebit/s"}},
|
||||
{name: "Exabit to gigabits: 1 Ebit/s = 1e9 Gbit/s", input: Value{F: 1, U: "Ebit/s"}, toUnit: "Gbit/s", expected: Value{F: 1e9, U: "Gbit/s"}},
|
||||
{name: "SI bit scaling: 1000 Ebit/s = 1 Zbit/s", input: Value{F: 1000, U: "Ebit/s"}, toUnit: "Zbit/s", expected: Value{F: 1, U: "Zbit/s"}},
|
||||
{name: "Zettabit to exabit: 1 Zbit/s = 1000 Ebit/s", input: Value{F: 1, U: "Zbit/s"}, toUnit: "Ebit/s", expected: Value{F: 1000, U: "Ebit/s"}},
|
||||
{name: "SI bit scaling: 1000 Zbit/s = 1 Ybit/s", input: Value{F: 1000, U: "Zbit/s"}, toUnit: "Ybit/s", expected: Value{F: 1, U: "Ybit/s"}},
|
||||
{name: "Yottabit to zettabit: 1 Ybit/s = 1000 Zbit/s", input: Value{F: 1, U: "Ybit/s"}, toUnit: "Zbit/s", expected: Value{F: 1000, U: "Zbit/s"}},
|
||||
// Binary bit scaling (Exbi, Zebi, Yobi)
|
||||
{name: "Binary bit scaling: 1024 Pibit/s = 1 Eibit/s", input: Value{F: 1024, U: "Pibit/s"}, toUnit: "Eibit/s", expected: Value{F: 1, U: "Eibit/s"}},
|
||||
{name: "Exbibit to pebibit: 1 Eibit/s = 1024 Pibit/s", input: Value{F: 1, U: "Eibit/s"}, toUnit: "Pibit/s", expected: Value{F: 1024, U: "Pibit/s"}},
|
||||
{name: "Binary bit scaling: 1024 Eibit/s = 1 Zibit/s", input: Value{F: 1024, U: "Eibit/s"}, toUnit: "Zibit/s", expected: Value{F: 1, U: "Zibit/s"}},
|
||||
{name: "Zebibit to exbibit: 1 Zibit/s = 1024 Eibit/s", input: Value{F: 1, U: "Zibit/s"}, toUnit: "Eibit/s", expected: Value{F: 1024, U: "Eibit/s"}},
|
||||
{name: "Binary bit scaling: 1024 Zibit/s = 1 Yibit/s", input: Value{F: 1024, U: "Zibit/s"}, toUnit: "Yibit/s", expected: Value{F: 1, U: "Yibit/s"}},
|
||||
{name: "Yobibit to zebibit: 1 Yibit/s = 1024 Zibit/s", input: Value{F: 1, U: "Yibit/s"}, toUnit: "Zibit/s", expected: Value{F: 1024, U: "Zibit/s"}},
|
||||
// Bytes to bits (Exbi, Zebi, Yobi)
|
||||
{name: "Bytes to bits: 1 EiBy/s = 8 Eibit/s", input: Value{F: 1, U: "EiBy/s"}, toUnit: "Eibit/s", expected: Value{F: 8, U: "Eibit/s"}},
|
||||
{name: "Bytes to bits: 1 ZiBy/s = 8 Zibit/s", input: Value{F: 1, U: "ZiBy/s"}, toUnit: "Zibit/s", expected: Value{F: 8, U: "Zibit/s"}},
|
||||
{name: "Bytes to bits: 1 YiBy/s = 8 Yibit/s", input: Value{F: 1, U: "YiBy/s"}, toUnit: "Yibit/s", expected: Value{F: 8, U: "Yibit/s"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := dataRateConverter.Convert(tt.input, tt.toUnit)
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestData(t *testing.T) {
|
||||
assert.Equal(t, Value{F: 1, U: "By"}, dataConverter.Convert(Value{F: 8, U: "bits"}, "By"))
|
||||
// 1024 bytes = 1 kbytes
|
||||
assert.Equal(t, Value{F: 1, U: "kbytes"}, dataConverter.Convert(Value{F: 1024, U: "bytes"}, "kbytes"))
|
||||
assert.Equal(t, Value{F: 1, U: "kBy"}, dataConverter.Convert(Value{F: 1000, U: "bytes"}, "kBy"))
|
||||
assert.Equal(t, Value{F: 1, U: "kBy"}, dataConverter.Convert(Value{F: 1024, U: "bytes"}, "kBy"))
|
||||
// 1 byte = 8 bits
|
||||
assert.Equal(t, Value{F: 8, U: "bits"}, dataConverter.Convert(Value{F: 1, U: "bytes"}, "bits"))
|
||||
// 1 mbytes = 1024 kbytes
|
||||
@@ -22,7 +22,7 @@ func TestData(t *testing.T) {
|
||||
assert.Equal(t, Value{F: 1024, U: "bytes"}, dataConverter.Convert(Value{F: 1, U: "kbytes"}, "bytes"))
|
||||
// 1024 kbytes = 1 mbytes
|
||||
assert.Equal(t, Value{F: 1, U: "mbytes"}, dataConverter.Convert(Value{F: 1024, U: "kbytes"}, "mbytes"))
|
||||
assert.Equal(t, Value{F: 1, U: "MBy"}, dataConverter.Convert(Value{F: 1000, U: "kBy"}, "MBy"))
|
||||
assert.Equal(t, Value{F: 1, U: "MBy"}, dataConverter.Convert(Value{F: 1024, U: "kbytes"}, "MBy"))
|
||||
// 1 mbytes = 1024 * 1024 bytes
|
||||
assert.Equal(t, Value{F: 1024 * 1024, U: "bytes"}, dataConverter.Convert(Value{F: 1, U: "mbytes"}, "bytes"))
|
||||
// 1024 mbytes = 1 gbytes
|
||||
@@ -45,90 +45,10 @@ func TestData(t *testing.T) {
|
||||
assert.Equal(t, Value{F: 1024 * 1024 * 1024 * 1024, U: "bytes"}, dataConverter.Convert(Value{F: 1, U: "tbytes"}, "bytes"))
|
||||
// 1024 tbytes = 1 pbytes
|
||||
assert.Equal(t, Value{F: 1, U: "pbytes"}, dataConverter.Convert(Value{F: 1024, U: "tbytes"}, "pbytes"))
|
||||
// 1024 tbytes = 1 PiBy
|
||||
assert.Equal(t, Value{F: 1, U: "PiBy"}, dataConverter.Convert(Value{F: 1024, U: "tbytes"}, "PiBy"))
|
||||
// 1024 tbytes = 1 pbytes
|
||||
assert.Equal(t, Value{F: 1, U: "PBy"}, dataConverter.Convert(Value{F: 1024, U: "tbytes"}, "PBy"))
|
||||
// 1 pbytes = 1024 tbytes
|
||||
assert.Equal(t, Value{F: 1024, U: "tbytes"}, dataConverter.Convert(Value{F: 1, U: "pbytes"}, "tbytes"))
|
||||
// 1024 TiBy = 1 pbytes
|
||||
assert.Equal(t, Value{F: 1024, U: "TiBy"}, dataConverter.Convert(Value{F: 1, U: "pbytes"}, "TiBy"))
|
||||
}
|
||||
|
||||
func TestDataConversionUCUMUnit(t *testing.T) {
|
||||
dataConverter := NewDataConverter()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input Value
|
||||
toUnit Unit
|
||||
expected Value
|
||||
}{
|
||||
// Bits to bytes
|
||||
{name: "Bits to bytes: 8 bit = 1 By", input: Value{F: 8, U: "bit"}, toUnit: "By", expected: Value{F: 1, U: "By"}},
|
||||
{name: "Byte to bits: 1 By = 8 bit", input: Value{F: 1, U: "By"}, toUnit: "bit", expected: Value{F: 8, U: "bit"}},
|
||||
// Binary byte scaling
|
||||
{name: "Binary byte scaling: 1024 By = 1 KiBy", input: Value{F: 1024, U: "By"}, toUnit: "KiBy", expected: Value{F: 1, U: "KiBy"}},
|
||||
{name: "Kibibyte to bytes: 1 KiBy = 1024 By", input: Value{F: 1, U: "KiBy"}, toUnit: "By", expected: Value{F: 1024, U: "By"}},
|
||||
{name: "Binary byte scaling: 1024 KiBy = 1 MiBy", input: Value{F: 1024, U: "KiBy"}, toUnit: "MiBy", expected: Value{F: 1, U: "MiBy"}},
|
||||
{name: "Binary byte scaling: 1024 MiBy = 1 GiBy", input: Value{F: 1024, U: "MiBy"}, toUnit: "GiBy", expected: Value{F: 1, U: "GiBy"}},
|
||||
{name: "Gibibyte to mebibyte: 1 GiBy = 1024 MiBy", input: Value{F: 1, U: "GiBy"}, toUnit: "MiBy", expected: Value{F: 1024, U: "MiBy"}},
|
||||
{name: "Binary byte scaling: 1024 GiBy = 1 TiBy", input: Value{F: 1024, U: "GiBy"}, toUnit: "TiBy", expected: Value{F: 1, U: "TiBy"}},
|
||||
{name: "Binary byte scaling: 1024 TiBy = 1 PiBy", input: Value{F: 1024, U: "TiBy"}, toUnit: "PiBy", expected: Value{F: 1, U: "PiBy"}},
|
||||
{name: "Pebibyte to tebibyte: 1 PiBy = 1024 TiBy", input: Value{F: 1, U: "PiBy"}, toUnit: "TiBy", expected: Value{F: 1024, U: "TiBy"}},
|
||||
{name: "Gibibyte to bytes: 1 GiBy = 1073741824 By", input: Value{F: 1, U: "GiBy"}, toUnit: "By", expected: Value{F: 1024 * 1024 * 1024, U: "By"}},
|
||||
{name: "Tebibyte to bytes: 1 TiBy = 1099511627776 By", input: Value{F: 1, U: "TiBy"}, toUnit: "By", expected: Value{F: 1024 * 1024 * 1024 * 1024, U: "By"}},
|
||||
// SI bit scaling
|
||||
{name: "SI bit scaling: 1000 bit = 1 kbit", input: Value{F: 1000, U: "bit"}, toUnit: "kbit", expected: Value{F: 1, U: "kbit"}},
|
||||
{name: "Kilobit to bits: 1 kbit = 1000 bit", input: Value{F: 1, U: "kbit"}, toUnit: "bit", expected: Value{F: 1000, U: "bit"}},
|
||||
{name: "SI bit scaling: 1000 kbit = 1 Mbit", input: Value{F: 1000, U: "kbit"}, toUnit: "Mbit", expected: Value{F: 1, U: "Mbit"}},
|
||||
{name: "Gigabit to bits: 1 Gbit = 1000000000 bit", input: Value{F: 1, U: "Gbit"}, toUnit: "bit", expected: Value{F: 1000 * 1000 * 1000, U: "bit"}},
|
||||
{name: "SI bit scaling: 1000 Mbit = 1 Gbit", input: Value{F: 1000, U: "Mbit"}, toUnit: "Gbit", expected: Value{F: 1, U: "Gbit"}},
|
||||
{name: "Gigabit to megabit: 1 Gbit = 1000 Mbit", input: Value{F: 1, U: "Gbit"}, toUnit: "Mbit", expected: Value{F: 1000, U: "Mbit"}},
|
||||
{name: "SI bit scaling: 1000 Gbit = 1 Tbit", input: Value{F: 1000, U: "Gbit"}, toUnit: "Tbit", expected: Value{F: 1, U: "Tbit"}},
|
||||
{name: "Terabit to gigabit: 1 Tbit = 1000 Gbit", input: Value{F: 1, U: "Tbit"}, toUnit: "Gbit", expected: Value{F: 1000, U: "Gbit"}},
|
||||
{name: "SI bit scaling: 1000 Tbit = 1 Pbit", input: Value{F: 1000, U: "Tbit"}, toUnit: "Pbit", expected: Value{F: 1, U: "Pbit"}},
|
||||
{name: "Petabit to terabit: 1 Pbit = 1000 Tbit", input: Value{F: 1, U: "Pbit"}, toUnit: "Tbit", expected: Value{F: 1000, U: "Tbit"}},
|
||||
// Binary bit scaling
|
||||
{name: "Binary bit scaling: 1024 bit = 1 Kibit", input: Value{F: 1024, U: "bit"}, toUnit: "Kibit", expected: Value{F: 1, U: "Kibit"}},
|
||||
{name: "Kibibit to bits: 1 Kibit = 1024 bit", input: Value{F: 1, U: "Kibit"}, toUnit: "bit", expected: Value{F: 1024, U: "bit"}},
|
||||
{name: "Binary bit scaling: 1024 Kibit = 1 Mibit", input: Value{F: 1024, U: "Kibit"}, toUnit: "Mibit", expected: Value{F: 1, U: "Mibit"}},
|
||||
{name: "Mebibit to kibibit: 1 Mibit = 1024 Kibit", input: Value{F: 1, U: "Mibit"}, toUnit: "Kibit", expected: Value{F: 1024, U: "Kibit"}},
|
||||
{name: "Binary bit scaling: 1024 Mibit = 1 Gibit", input: Value{F: 1024, U: "Mibit"}, toUnit: "Gibit", expected: Value{F: 1, U: "Gibit"}},
|
||||
{name: "Gibibit to mebibit: 1 Gibit = 1024 Mibit", input: Value{F: 1, U: "Gibit"}, toUnit: "Mibit", expected: Value{F: 1024, U: "Mibit"}},
|
||||
{name: "Binary bit scaling: 1024 Gibit = 1 Tibit", input: Value{F: 1024, U: "Gibit"}, toUnit: "Tibit", expected: Value{F: 1, U: "Tibit"}},
|
||||
{name: "Tebibit to gibibit: 1 Tibit = 1024 Gibit", input: Value{F: 1, U: "Tibit"}, toUnit: "Gibit", expected: Value{F: 1024, U: "Gibit"}},
|
||||
{name: "Binary bit scaling: 1024 Tibit = 1 Pibit", input: Value{F: 1024, U: "Tibit"}, toUnit: "Pibit", expected: Value{F: 1, U: "Pibit"}},
|
||||
{name: "Pebibit to tebibit: 1 Pibit = 1024 Tibit", input: Value{F: 1, U: "Pibit"}, toUnit: "Tibit", expected: Value{F: 1024, U: "Tibit"}},
|
||||
// Bytes to bits
|
||||
{name: "Bytes to bits: 1 KiBy = 8 Kibit", input: Value{F: 1, U: "KiBy"}, toUnit: "Kibit", expected: Value{F: 8, U: "Kibit"}},
|
||||
{name: "Bytes to bits: 1 MiBy = 8 Mibit", input: Value{F: 1, U: "MiBy"}, toUnit: "Mibit", expected: Value{F: 8, U: "Mibit"}},
|
||||
{name: "Bytes to bits: 1 GiBy = 8 Gibit", input: Value{F: 1, U: "GiBy"}, toUnit: "Gibit", expected: Value{F: 8, U: "Gibit"}},
|
||||
// SI byte scaling (Exa, Zetta, Yotta)
|
||||
{name: "SI byte scaling: 1000 PBy = 1 EBy", input: Value{F: 1000, U: "PBy"}, toUnit: "EBy", expected: Value{F: 1, U: "EBy"}},
|
||||
{name: "Exabyte to bytes: 1 EBy = 1e18 By", input: Value{F: 1, U: "EBy"}, toUnit: "By", expected: Value{F: 1e18, U: "By"}},
|
||||
{name: "SI byte scaling: 1000 EBy = 1 ZBy", input: Value{F: 1000, U: "EBy"}, toUnit: "ZBy", expected: Value{F: 1, U: "ZBy"}},
|
||||
{name: "Zettabyte to petabytes: 1 ZBy = 1000000 PBy", input: Value{F: 1, U: "ZBy"}, toUnit: "PBy", expected: Value{F: 1e6, U: "PBy"}},
|
||||
{name: "SI byte scaling: 1000 ZBy = 1 YBy", input: Value{F: 1000, U: "ZBy"}, toUnit: "YBy", expected: Value{F: 1, U: "YBy"}},
|
||||
{name: "Yottabyte to zettabyte: 1 YBy = 1000 ZBy", input: Value{F: 1, U: "YBy"}, toUnit: "ZBy", expected: Value{F: 1000, U: "ZBy"}},
|
||||
// Binary byte scaling (Exbi, Zebi, Yobi)
|
||||
{name: "Binary byte scaling: 1024 PiBy = 1 EiBy", input: Value{F: 1024, U: "PiBy"}, toUnit: "EiBy", expected: Value{F: 1, U: "EiBy"}},
|
||||
{name: "Exbibyte to tebibytes: 1 EiBy = 1048576 TiBy", input: Value{F: 1, U: "EiBy"}, toUnit: "TiBy", expected: Value{F: 1024 * 1024, U: "TiBy"}},
|
||||
{name: "Binary byte scaling: 1024 EiBy = 1 ZiBy", input: Value{F: 1024, U: "EiBy"}, toUnit: "ZiBy", expected: Value{F: 1, U: "ZiBy"}},
|
||||
{name: "Zebibyte to exbibyte: 1 ZiBy = 1024 EiBy", input: Value{F: 1, U: "ZiBy"}, toUnit: "EiBy", expected: Value{F: 1024, U: "EiBy"}},
|
||||
{name: "Binary byte scaling: 1024 ZiBy = 1 YiBy", input: Value{F: 1024, U: "ZiBy"}, toUnit: "YiBy", expected: Value{F: 1, U: "YiBy"}},
|
||||
{name: "Yobibyte to zebibyte: 1 YiBy = 1024 ZiBy", input: Value{F: 1, U: "YiBy"}, toUnit: "ZiBy", expected: Value{F: 1024, U: "ZiBy"}},
|
||||
// SI bit scaling (Exa, Zetta, Yotta)
|
||||
{name: "SI bit scaling: 1000 Pbit = 1 Ebit", input: Value{F: 1000, U: "Pbit"}, toUnit: "Ebit", expected: Value{F: 1, U: "Ebit"}},
|
||||
{name: "Exabit to gigabits: 1 Ebit = 1e9 Gbit", input: Value{F: 1, U: "Ebit"}, toUnit: "Gbit", expected: Value{F: 1e9, U: "Gbit"}},
|
||||
{name: "SI bit scaling: 1000 Ebit = 1 Zbit", input: Value{F: 1000, U: "Ebit"}, toUnit: "Zbit", expected: Value{F: 1, U: "Zbit"}},
|
||||
{name: "Zettabit to exabit: 1 Zbit = 1000 Ebit", input: Value{F: 1, U: "Zbit"}, toUnit: "Ebit", expected: Value{F: 1000, U: "Ebit"}},
|
||||
{name: "SI bit scaling: 1000 Zbit = 1 Ybit", input: Value{F: 1000, U: "Zbit"}, toUnit: "Ybit", expected: Value{F: 1, U: "Ybit"}},
|
||||
{name: "Yottabit to zettabit: 1 Ybit = 1000 Zbit", input: Value{F: 1, U: "Ybit"}, toUnit: "Zbit", expected: Value{F: 1000, U: "Zbit"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := dataConverter.Convert(tt.input, tt.toUnit)
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
// 1024 pbytes = 1 tbytes
|
||||
assert.Equal(t, Value{F: 1024, U: "TBy"}, dataConverter.Convert(Value{F: 1, U: "pbytes"}, "TBy"))
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ func FromTimeUnit(u Unit) Duration {
|
||||
return Hour
|
||||
case "d":
|
||||
return Day
|
||||
case "w", "wk":
|
||||
case "w":
|
||||
return Week
|
||||
default:
|
||||
return Second
|
||||
|
||||
@@ -54,13 +54,4 @@ func TestDurationConvert(t *testing.T) {
|
||||
assert.Equal(t, Value{F: 1, U: "ms"}, timeConverter.Convert(Value{F: 1000, U: "us"}, "ms"))
|
||||
// 1000000000 ns = 1 s
|
||||
assert.Equal(t, Value{F: 1, U: "s"}, timeConverter.Convert(Value{F: 1000000000, U: "ns"}, "s"))
|
||||
|
||||
// 7 d = 1 wk
|
||||
assert.Equal(t, Value{F: 1, U: "wk"}, timeConverter.Convert(Value{F: 7, U: "d"}, "wk"))
|
||||
// 1 wk = 7 d
|
||||
assert.Equal(t, Value{F: 7, U: "d"}, timeConverter.Convert(Value{F: 1, U: "wk"}, "d"))
|
||||
// 1 wk = 168 h
|
||||
assert.Equal(t, Value{F: 168, U: "h"}, timeConverter.Convert(Value{F: 1, U: "wk"}, "h"))
|
||||
// 604800 s = 1 wk
|
||||
assert.Equal(t, Value{F: 1, U: "wk"}, timeConverter.Convert(Value{F: 604800, U: "s"}, "wk"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user