Compare commits

..

26 Commits

Author SHA1 Message Date
SagarRajput-7
d519bca656 Merge branch 'main' into sso-mapping 2026-02-18 15:59:06 +05:30
primus-bot[bot]
04643264ff chore(release): bump to v0.112.0 (#10340)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-02-18 15:50:23 +05:30
Ishan
3aa0d8a7fd feat: Improve logs browsing when the side-drawer is open (#10250)
* feat: logs keyboard handle feature

* feat: raw logs code optimised

* feat: listlogs and table view optimised

* feat: added chevron arrows in log details

* feat: added bg table scrolling

* feat: entity logs bg click bug

* feat: pr comment fixes

* feat: pr optimised

* feat: removed unwanted code

* feat: removed unwanted code
2026-02-18 14:12:00 +05:30
SagarRajput-7
59fcdcf4f2 feat: added delete confirmation modal for domain list 2026-02-18 06:28:54 +05:30
SagarRajput-7
d73a5cfb0d Merge branch 'main' into sso-mapping 2026-02-18 04:32:44 +05:30
SagarRajput-7
59061ec4db feat: addressed comments and feedback 2026-02-18 04:32:00 +05:30
SagarRajput-7
634945877e feat: refactored code and added email field 2026-02-17 04:57:02 +05:30
SagarRajput-7
acdd5dd6b2 feat: removed redirecturi and added error helper for collapsed sections 2026-02-17 04:04:33 +05:30
SagarRajput-7
78cb726414 Merge branch 'main' into sso-mapping 2026-02-16 20:57:31 +05:30
SagarRajput-7
e4e91d1b43 feat: addressed comments and feedback 2026-02-16 19:58:31 +05:30
SagarRajput-7
e5929e4fc4 Merge branch 'main' into sso-mapping 2026-02-16 18:49:13 +05:30
SagarRajput-7
5520e8086e Merge branch 'main' into sso-mapping 2026-02-16 13:48:16 +05:30
SagarRajput-7
3f5544674e feat: addressed comments and feedback 2026-02-16 13:47:29 +05:30
SagarRajput-7
c638e39ccc Merge branch 'main' into sso-mapping 2026-02-16 11:25:25 +05:30
SagarRajput-7
e5840b2193 feat: added redirect uri field in google auth 2026-02-16 11:24:41 +05:30
SagarRajput-7
ceac0f3c14 Merge branch 'main' into sso-mapping 2026-02-13 17:40:30 +05:30
SagarRajput-7
b61628e00e feat: toggle consistency fix 2026-02-13 17:31:33 +05:30
SagarRajput-7
b621e2470d feat: used signozhq instead of antd and lucide icons 2026-02-13 17:17:19 +05:30
SagarRajput-7
a75ee7bdba feat: added test cases for the auth domain flow 2026-02-13 08:36:20 +05:30
SagarRajput-7
1869e91155 Merge branch 'main' into sso-mapping 2026-02-13 07:45:22 +05:30
SagarRajput-7
e10c29399a feat: cleanup and refactor 2026-02-13 07:44:21 +05:30
SagarRajput-7
9a89cb02b0 feat: cleanup and refactor 2026-02-13 07:36:25 +05:30
SagarRajput-7
f41eb6f5b8 feat: error handling and code refactor 2026-02-12 17:53:01 +05:30
SagarRajput-7
cad51403e5 Merge branch 'main' into sso-mapping 2026-02-12 16:21:08 +05:30
SagarRajput-7
370952b7f9 feat: added error handling, integrated generate apis and form validation 2026-02-12 16:19:29 +05:30
SagarRajput-7
c57ab4d76a feat: enhancement in the authn providers with new fields and new ui 2026-02-12 08:29:42 +05:30
70 changed files with 5156 additions and 1463 deletions

View File

@@ -176,7 +176,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.111.0
image: signoz/signoz:v0.112.0
command:
- --config=/root/config/prometheus.yml
ports:

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.111.0
image: signoz/signoz:v0.112.0
command:
- --config=/root/config/prometheus.yml
ports:

View File

@@ -179,7 +179,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.111.0}
image: signoz/signoz:${VERSION:-v0.112.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

View File

@@ -111,7 +111,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.111.0}
image: signoz/signoz:${VERSION:-v0.112.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

View File

@@ -58,6 +58,7 @@
"@signozhq/radio-group": "0.0.2",
"@signozhq/resizable": "0.0.0",
"@signozhq/sonner": "0.1.0",
"@signozhq/switch": "0.0.2",
"@signozhq/table": "0.3.7",
"@signozhq/tooltip": "0.0.2",
"@tanstack/react-table": "8.20.6",

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,5 +24,6 @@ import '@signozhq/popover';
import '@signozhq/radio-group';
import '@signozhq/resizable';
import '@signozhq/sonner';
import '@signozhq/switch';
import '@signozhq/table';
import '@signozhq/tooltip';

View File

@@ -1,8 +1,9 @@
/* eslint-disable no-nested-ternary */
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useQuery } from 'react-query';
import { Virtuoso } from 'react-virtuoso';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Card } from 'antd';
import LogDetail from 'components/LogDetail';
import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
@@ -10,6 +11,8 @@ import LogsError from 'container/LogsError/LogsError';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { ILog } from 'types/api/logs/log';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
@@ -28,6 +31,15 @@ interface Props {
}
function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const {
activeLog,
onAddToQuery,
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
} = useLogDetailHandlers();
const basePayload = getHostLogsQueryPayload(
timeRange.startTime,
timeRange.endTime,
@@ -72,29 +84,40 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
setIsPaginating(false);
}, [data, setIsPaginating]);
const handleScrollToLog = useScrollToLog({
logs,
virtuosoRef,
});
const getItemContent = useCallback(
(_: number, logToRender: ILog): JSX.Element => (
<RawLogView
isTextOverflowEllipsisDisabled
key={logToRender.id}
data={logToRender}
linesPerRow={5}
fontSize={FontSize.MEDIUM}
selectedFields={[
{
dataType: 'string',
type: '',
name: 'body',
},
{
dataType: 'string',
type: '',
name: 'timestamp',
},
]}
/>
),
[],
(_: number, logToRender: ILog): JSX.Element => {
return (
<div key={logToRender.id}>
<RawLogView
isTextOverflowEllipsisDisabled
data={logToRender}
linesPerRow={5}
fontSize={FontSize.MEDIUM}
selectedFields={[
{
dataType: 'string',
type: '',
name: 'body',
},
{
dataType: 'string',
type: '',
name: 'timestamp',
},
]}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
isActiveLog={activeLog?.id === logToRender.id}
/>
</div>
);
},
[activeLog, handleSetActiveLog, handleCloseLogDetail],
);
const renderFooter = useCallback(
@@ -118,6 +141,7 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
<Virtuoso
className="host-metrics-logs-virtuoso"
key="host-metrics-logs-virtuoso"
ref={virtuosoRef}
data={logs}
endReached={loadMoreLogs}
totalCount={logs.length}
@@ -139,7 +163,24 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
{!isLoading && !isError && logs.length === 0 && <NoLogsContainer />}
{isError && !isLoading && <LogsError />}
{!isLoading && !isError && logs.length > 0 && (
<div className="host-metrics-logs-list-container">{renderContent}</div>
<div
className="host-metrics-logs-list-container"
data-log-detail-ignore="true"
>
{renderContent}
</div>
)}
{selectedTab && activeLog && (
<LogDetail
log={activeLog}
onClose={handleCloseLogDetail}
logs={logs}
onNavigateLog={handleSetActiveLog}
selectedTab={selectedTab}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onScrollToLog={handleScrollToLog}
/>
)}
</div>
);

View File

@@ -13,6 +13,9 @@ export type LogDetailProps = {
handleChangeSelectedView?: ChangeViewFunctionType;
isListViewPanel?: boolean;
listViewPanelSelectedFields?: IField[] | null;
logs?: ILog[];
onNavigateLog?: (log: ILog) => void;
onScrollToLog?: (logId: string) => void;
} & Pick<AddToQueryHOCProps, 'onAddToQuery'> &
Partial<Pick<ActionItemProps, 'onClickActionItem'>> &
Pick<DrawerProps, 'onClose'>;

View File

@@ -15,6 +15,8 @@
}
.log-detail-drawer__title-right {
display: flex;
align-items: center;
.ant-btn {
display: flex;
align-items: center;
@@ -66,6 +68,10 @@
margin-bottom: 16px;
}
.log-detail-drawer__content {
height: 100%;
}
.log-detail-drawer__log {
width: 100%;
display: flex;
@@ -183,9 +189,115 @@
.ant-drawer-close {
padding: 0px;
}
.log-detail-drawer__footer-hint {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 8px 16px;
text-align: left;
color: var(--text-vanilla-200);
background: var(--bg-ink-400);
z-index: 10;
.log-detail-drawer__footer-hint-content {
display: flex;
align-items: center;
gap: 4px;
}
.log-detail-drawer__footer-hint-icon {
display: inline;
vertical-align: middle;
color: var(--text-vanilla-200);
}
.log-detail-drawer__footer-hint-text {
font-size: 13px;
margin: 0;
}
}
.log-arrows {
display: flex;
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.1);
border-radius: 6px;
padding: 2px 6px;
align-items: center;
margin-left: 8px;
}
.log-arrow-btn {
padding: 0;
min-width: 28px;
height: 28px;
border-radius: 4px;
background: var(--bg-ink-400);
color: var(--text-vanilla-400);
border: 1px solid var(--bg-ink-300);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease-in-out;
}
.log-arrow-btn-up,
.log-arrow-btn-down {
background: var(--bg-ink-400);
}
.log-arrow-btn:active,
.log-arrow-btn:focus {
background: var(--bg-ink-300);
color: var(--text-vanilla-100);
}
.log-arrow-btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
background: var(--bg-ink-500);
color: var(--text-vanilla-200);
.log-arrow-btn:hover:not([disabled]) {
background: var(--bg-ink-300);
color: var(--text-vanilla-100);
}
}
}
.lightMode {
.log-arrows {
background: var(--bg-vanilla-100);
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.04);
}
.log-arrow-btn {
background: var(--bg-vanilla-100);
color: var(--text-ink-400);
border: 1px solid var(--bg-vanilla-300);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.04);
}
.log-arrow-btn-up,
.log-arrow-btn-down {
background: var(--bg-vanilla-100);
}
.log-arrow-btn:active,
.log-arrow-btn:focus {
background: var(--bg-vanilla-200);
color: var(--text-ink-500);
}
.log-arrow-btn:hover:not([disabled]) {
background: var(--bg-vanilla-200);
color: var(--text-ink-500);
}
.log-arrow-btn[disabled] {
background: var(--bg-vanilla-100);
color: var(--text-ink-200);
}
.ant-drawer-header {
border-bottom: 1px solid var(--bg-vanilla-400);
background: var(--bg-vanilla-100);
@@ -252,4 +364,33 @@
color: var(--text-ink-300);
}
}
.log-detail-drawer__footer-hint {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 8px 16px;
text-align: left;
color: var(--text-vanilla-700);
background: var(--bg-vanilla-100);
z-index: 10;
.log-detail-drawer__footer-hint-content {
display: flex;
align-items: center;
gap: 4px;
}
.log-detail-drawer__footer-hint-icon {
display: inline;
vertical-align: middle;
color: var(--text-vanilla-700);
}
.log-detail-drawer__footer-hint-text {
font-size: 13px;
margin: 0;
}
}
}

View File

@@ -1,5 +1,5 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { useCopyToClipboard, useLocation } from 'react-use';
import { Color, Spacing } from '@signozhq/design-tokens';
@@ -32,8 +32,12 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
import createQueryParams from 'lib/createQueryParams';
import { cloneDeep } from 'lodash-es';
import {
ArrowDown,
ArrowUp,
BarChart2,
Braces,
ChevronDown,
ChevronUp,
Compass,
Copy,
Filter,
@@ -60,6 +64,9 @@ function LogDetailInner({
isListViewPanel = false,
listViewPanelSelectedFields,
handleChangeSelectedView,
logs,
onNavigateLog,
onScrollToLog,
}: LogDetailInnerProps): JSX.Element {
const initialContextQuery = useInitialQuery(log);
const [contextQuery, setContextQuery] = useState<Query | undefined>(
@@ -74,6 +81,78 @@ function LogDetailInner({
const [isEdit, setIsEdit] = useState<boolean>(false);
const { stagedQuery, updateAllQueriesOperators } = useQueryBuilder();
// Handle clicks outside to close drawer, except on explicitly ignored regions
useEffect(() => {
const handleClickOutside = (e: MouseEvent): void => {
const target = e.target as HTMLElement;
// Don't close if clicking on explicitly ignored regions
if (target.closest('[data-log-detail-ignore="true"]')) {
return;
}
// Close the drawer for any other outside click
onClose?.(e as any);
};
document.addEventListener('mousedown', handleClickOutside);
return (): void => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
// Keyboard navigation - handle up/down arrow keys
// Only listen when in OVERVIEW tab
useEffect(() => {
if (
!logs ||
!onNavigateLog ||
logs.length === 0 ||
selectedView !== VIEW_TYPES.OVERVIEW
) {
return;
}
const handleKeyDown = (e: KeyboardEvent): void => {
const currentIndex = logs.findIndex((l) => l.id === log.id);
if (currentIndex === -1) {
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
// Navigate to previous log
if (currentIndex > 0) {
const prevLog = logs[currentIndex - 1];
onNavigateLog(prevLog);
// Trigger scroll to the log element
if (onScrollToLog) {
onScrollToLog(prevLog.id);
}
}
} else if (e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
// Navigate to next log
if (currentIndex < logs.length - 1) {
const nextLog = logs[currentIndex + 1];
onNavigateLog(nextLog);
// Trigger scroll to the log element
if (onScrollToLog) {
onScrollToLog(nextLog.id);
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return (): void => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [log.id, logs, onNavigateLog, onScrollToLog, selectedView]);
const listQuery = useMemo(() => {
if (!stagedQuery || stagedQuery.builder.queryData.length < 1) {
return null;
@@ -227,32 +306,87 @@ function LogDetailInner({
);
const logType = log?.attributes_string?.log_level || LogType.INFO;
const currentLogIndex = logs ? logs.findIndex((l) => l.id === log.id) : -1;
const isPrevDisabled =
!logs || !onNavigateLog || logs.length === 0 || currentLogIndex <= 0;
const isNextDisabled =
!logs ||
!onNavigateLog ||
logs.length === 0 ||
currentLogIndex === logs.length - 1;
type HandleNavigateLogParams = {
direction: 'next' | 'previous';
};
const handleNavigateLog = ({ direction }: HandleNavigateLogParams): void => {
if (!logs || !onNavigateLog || currentLogIndex === -1) {
return;
}
if (direction === 'previous' && !isPrevDisabled) {
const prevLog = logs[currentLogIndex - 1];
onNavigateLog(prevLog);
onScrollToLog?.(prevLog.id);
} else if (direction === 'next' && !isNextDisabled) {
const nextLog = logs[currentLogIndex + 1];
onNavigateLog(nextLog);
onScrollToLog?.(nextLog.id);
}
};
return (
<Drawer
width="60%"
maskStyle={{ background: 'none' }}
mask={false}
maskClosable={false}
title={
<div className="log-detail-drawer__title">
<div className="log-detail-drawer__title" data-log-detail-ignore="true">
<div className="log-detail-drawer__title-left">
<Divider type="vertical" className={cx('log-type-indicator', LogType)} />
<Typography.Text className="title">Log details</Typography.Text>
</div>
{showOpenInExplorerBtn && (
<div className="log-detail-drawer__title-right">
<Button
className="open-in-explorer-btn"
icon={<Compass size={16} />}
onClick={handleOpenInExplorer}
<div className="log-detail-drawer__title-right">
<div className="log-arrows">
<Tooltip
title={isPrevDisabled ? '' : 'Move to previous log'}
placement="top"
mouseLeaveDelay={0}
>
Open in Explorer
</Button>
<Button
icon={<ChevronUp size={14} />}
className="log-arrow-btn log-arrow-btn-up"
disabled={isPrevDisabled}
onClick={(): void => handleNavigateLog({ direction: 'previous' })}
/>
</Tooltip>
<Tooltip
title={isNextDisabled ? '' : 'Move to next log'}
placement="top"
mouseLeaveDelay={0}
>
<Button
icon={<ChevronDown size={14} />}
className="log-arrow-btn log-arrow-btn-down"
disabled={isNextDisabled}
onClick={(): void => handleNavigateLog({ direction: 'next' })}
/>
</Tooltip>
</div>
)}
{showOpenInExplorerBtn && (
<div>
<Button
className="open-in-explorer-btn"
icon={<Compass size={16} />}
onClick={handleOpenInExplorer}
>
Open in Explorer
</Button>
</div>
)}
</div>
</div>
}
placement="right"
// closable
onClose={drawerCloseHandler}
open={log !== null}
style={{
@@ -263,138 +397,164 @@ function LogDetailInner({
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
<div className="log-detail-drawer__log">
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
<Tooltip title={removeEscapeCharacters(log?.body)} placement="left">
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
</Tooltip>
<div className="log-detail-drawer__content" data-log-detail-ignore="true">
<div className="log-detail-drawer__log">
<Divider type="vertical" className={cx('log-type-indicator', logType)} />
<Tooltip title={removeEscapeCharacters(log?.body)} placement="left">
<div className="log-body" dangerouslySetInnerHTML={htmlBody} />
</Tooltip>
<div className="log-overflow-shadow">&nbsp;</div>
</div>
<div className="log-overflow-shadow">&nbsp;</div>
</div>
<div className="tabs-and-search">
<Radio.Group
className="views-tabs"
onChange={handleModeChange}
value={selectedView}
>
<Radio.Button
className={
// eslint-disable-next-line sonarjs/no-duplicate-string
selectedView === VIEW_TYPES.OVERVIEW ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.OVERVIEW}
<div className="tabs-and-search">
<Radio.Group
className="views-tabs"
onChange={handleModeChange}
value={selectedView}
>
<div className="view-title">
<Table size={14} />
Overview
</div>
</Radio.Button>
<Radio.Button
className={selectedView === VIEW_TYPES.JSON ? 'selected_view tab' : 'tab'}
value={VIEW_TYPES.JSON}
>
<div className="view-title">
<Braces size={14} />
JSON
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.CONTEXT ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.CONTEXT}
>
<div className="view-title">
<TextSelect size={14} />
Context
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.INFRAMETRICS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.INFRAMETRICS}
>
<div className="view-title">
<BarChart2 size={14} />
Metrics
</div>
</Radio.Button>
</Radio.Group>
<Radio.Button
className={
// eslint-disable-next-line sonarjs/no-duplicate-string
selectedView === VIEW_TYPES.OVERVIEW ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.OVERVIEW}
>
<div className="view-title">
<Table size={14} />
Overview
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.JSON ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.JSON}
>
<div className="view-title">
<Braces size={14} />
JSON
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.CONTEXT ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.CONTEXT}
>
<div className="view-title">
<TextSelect size={14} />
Context
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.INFRAMETRICS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.INFRAMETRICS}
>
<div className="view-title">
<BarChart2 size={14} />
Metrics
</div>
</Radio.Button>
</Radio.Group>
<div className="log-detail-drawer__actions">
{selectedView === VIEW_TYPES.CONTEXT && (
<Tooltip
title="Show Filters"
placement="topLeft"
aria-label="Show Filters"
>
<Button
className="action-btn"
icon={<Filter size={16} />}
onClick={handleFilterVisible}
/>
</Tooltip>
)}
<div className="log-detail-drawer__actions">
{selectedView === VIEW_TYPES.CONTEXT && (
<Tooltip
title="Show Filters"
title={selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'}
placement="topLeft"
aria-label="Show Filters"
aria-label={
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
}
>
<Button
className="action-btn"
icon={<Filter size={16} />}
onClick={handleFilterVisible}
icon={<Copy size={16} />}
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy}
/>
</Tooltip>
)}
<Tooltip
title={selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'}
placement="topLeft"
aria-label={
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
}
>
<Button
className="action-btn"
icon={<Copy size={16} />}
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy}
</div>
</div>
{isFilterVisible && contextQuery?.builder.queryData[0] && (
<div className="log-detail-drawer-query-container">
<QuerySearch
onChange={(value): void => handleQueryExpressionChange(value, 0)}
dataSource={DataSource.LOGS}
queryData={contextQuery?.builder.queryData[0]}
onRun={handleRunQuery}
/>
</Tooltip>
</div>
</div>
{isFilterVisible && contextQuery?.builder.queryData[0] && (
<div className="log-detail-drawer-query-container">
<QuerySearch
onChange={(value): void => handleQueryExpressionChange(value, 0)}
dataSource={DataSource.LOGS}
queryData={contextQuery?.builder.queryData[0]}
onRun={handleRunQuery}
</div>
)}
{selectedView === VIEW_TYPES.OVERVIEW && (
<Overview
logData={log}
onAddToQuery={onAddToQuery}
onClickActionItem={onClickActionItem}
isListViewPanel={isListViewPanel}
selectedOptions={options}
listViewPanelSelectedFields={listViewPanelSelectedFields}
handleChangeSelectedView={handleChangeSelectedView}
/>
</div>
)}
)}
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}
{selectedView === VIEW_TYPES.OVERVIEW && (
<Overview
logData={log}
onAddToQuery={onAddToQuery}
onClickActionItem={onClickActionItem}
isListViewPanel={isListViewPanel}
selectedOptions={options}
listViewPanelSelectedFields={listViewPanelSelectedFields}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView
log={log}
filters={filters}
contextQuery={contextQuery}
isEdit={isEdit}
/>
)}
{selectedView === VIEW_TYPES.INFRAMETRICS && (
<InfraMetrics
clusterName={log.resources_string?.[RESOURCE_KEYS.CLUSTER_NAME] || ''}
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
timestamp={log.timestamp.toString()}
dataSource={DataSource.LOGS}
/>
)}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView
log={log}
filters={filters}
contextQuery={contextQuery}
isEdit={isEdit}
/>
)}
{selectedView === VIEW_TYPES.INFRAMETRICS && (
<InfraMetrics
clusterName={log.resources_string?.[RESOURCE_KEYS.CLUSTER_NAME] || ''}
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
timestamp={log.timestamp.toString()}
dataSource={DataSource.LOGS}
/>
)}
{selectedView === VIEW_TYPES.OVERVIEW && (
<div className="log-detail-drawer__footer-hint">
<div className="log-detail-drawer__footer-hint-content">
<Typography.Text
type="secondary"
className="log-detail-drawer__footer-hint-text"
>
Use
</Typography.Text>
<ArrowUp size={14} className="log-detail-drawer__footer-hint-icon" />
<span>/</span>
<ArrowDown size={14} className="log-detail-drawer__footer-hint-icon" />
<Typography.Text
type="secondary"
className="log-detail-drawer__footer-hint-text"
>
to view previous/next log
</Typography.Text>
</div>
</div>
)}
</div>
</Drawer>
);
}

View File

@@ -2,13 +2,11 @@ import { memo, useCallback, useMemo } from 'react';
import { blue } from '@ant-design/colors';
import { Typography } from 'antd';
import cx from 'classnames';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsDarkMode } from 'hooks/useDarkMode';
// utils
@@ -104,12 +102,17 @@ function LogSelectedField({
type ListLogViewProps = {
logData: ILog;
selectedFields: IField[];
onSetActiveLog: (log: ILog) => void;
onSetActiveLog: (
log: ILog,
selectedTab?: typeof VIEW_TYPES[keyof typeof VIEW_TYPES],
) => void;
onAddToQuery: AddToQueryHOCProps['onAddToQuery'];
activeLog?: ILog | null;
linesPerRow: number;
fontSize: FontSize;
handleChangeSelectedView?: ChangeViewFunctionType;
isActiveLog?: boolean;
onClearActiveLog?: () => void;
};
function ListLogView({
@@ -120,7 +123,8 @@ function ListLogView({
activeLog,
linesPerRow,
fontSize,
handleChangeSelectedView,
isActiveLog,
onClearActiveLog,
}: ListLogViewProps): JSX.Element {
const flattenLogData = useMemo(() => FlatLogData(logData), [logData]);
@@ -129,35 +133,24 @@ function ListLogView({
);
const isReadOnlyLog = !isLogsExplorerPage;
const {
activeLog: activeContextLog,
onAddToQuery: handleAddToQuery,
onSetActiveLog: handleSetActiveContextLog,
onClearActiveLog: handleClearActiveContextLog,
} = useActiveLog();
const isDarkMode = useIsDarkMode();
const handlerClearActiveContextLog = useCallback(
(event: React.MouseEvent | React.KeyboardEvent) => {
event.preventDefault();
event.stopPropagation();
handleClearActiveContextLog();
},
[handleClearActiveContextLog],
);
const handleDetailedView = useCallback(() => {
if (isActiveLog) {
onClearActiveLog?.();
return;
}
onSetActiveLog(logData);
}, [logData, onSetActiveLog]);
}, [logData, onSetActiveLog, isActiveLog, onClearActiveLog]);
const handleShowContext = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
handleSetActiveContextLog(logData);
onSetActiveLog(logData, VIEW_TYPES.CONTEXT);
},
[logData, handleSetActiveContextLog],
[logData, onSetActiveLog],
);
const updatedSelecedFields = useMemo(
@@ -186,11 +179,7 @@ function ListLogView({
return (
<>
<Container
$isActiveLog={
isHighlighted ||
activeLog?.id === logData.id ||
activeContextLog?.id === logData.id
}
$isActiveLog={isHighlighted || activeLog?.id === logData.id}
$isDarkMode={isDarkMode}
$logType={logType}
onClick={handleDetailedView}
@@ -251,15 +240,6 @@ function ListLogView({
/>
)}
</Container>
{activeContextLog && (
<LogDetail
log={activeContextLog}
onAddToQuery={handleAddToQuery}
selectedTab={VIEW_TYPES.CONTEXT}
onClose={handlerClearActiveContextLog}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
</>
);
}

View File

@@ -1,19 +1,15 @@
import {
KeyboardEvent,
memo,
MouseEvent,
MouseEventHandler,
useCallback,
useMemo,
useState,
} from 'react';
import { Color } from '@signozhq/design-tokens';
import { DrawerProps, Tooltip } from 'antd';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES, VIEWS } from 'components/LogDetail/constants';
import { Tooltip } from 'antd';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
// hooks
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -39,7 +35,8 @@ function RawLogView({
selectedFields = [],
fontSize,
onLogClick,
handleChangeSelectedView,
onSetActiveLog,
onClearActiveLog,
}: RawLogViewProps): JSX.Element {
const {
isHighlighted: isUrlHighlighted,
@@ -48,15 +45,6 @@ function RawLogView({
} = useCopyLogLink(data.id);
const flattenLogData = useMemo(() => FlatLogData(data), [data]);
const {
activeLog,
onSetActiveLog,
onClearActiveLog,
onAddToQuery,
} = useActiveLog();
const [selectedTab, setSelectedTab] = useState<VIEWS | undefined>();
const isDarkMode = useIsDarkMode();
const isReadOnlyLog = !isLogsExplorerPage || isReadOnly;
@@ -134,34 +122,24 @@ function RawLogView({
// Use custom click handler if provided, otherwise use default behavior
if (onLogClick) {
onLogClick(data, event);
} else {
onSetActiveLog(data);
setSelectedTab(VIEW_TYPES.OVERVIEW);
return;
}
if (isActiveLog) {
onClearActiveLog?.();
return;
}
},
[isReadOnly, data, onSetActiveLog, onLogClick],
);
const handleCloseLogDetail: DrawerProps['onClose'] = useCallback(
(
event: MouseEvent<Element, globalThis.MouseEvent> | KeyboardEvent<Element>,
) => {
event.preventDefault();
event.stopPropagation();
onClearActiveLog();
setSelectedTab(undefined);
onSetActiveLog?.(data);
},
[onClearActiveLog],
[isReadOnly, onLogClick, isActiveLog, onSetActiveLog, data, onClearActiveLog],
);
const handleShowContext: MouseEventHandler<HTMLElement> = useCallback(
(event) => {
event.preventDefault();
event.stopPropagation();
// handleSetActiveContextLog(data);
setSelectedTab(VIEW_TYPES.CONTEXT);
onSetActiveLog(data);
onSetActiveLog?.(data, VIEW_TYPES.CONTEXT);
},
[data, onSetActiveLog],
);
@@ -181,7 +159,7 @@ function RawLogView({
$isDarkMode={isDarkMode}
$isReadOnly={isReadOnly}
$isHightlightedLog={isUrlHighlighted}
$isActiveLog={activeLog?.id === data.id || isActiveLog}
$isActiveLog={isActiveLog}
$isCustomHighlighted={isHighlighted}
$logType={logType}
fontSize={fontSize}
@@ -218,17 +196,6 @@ function RawLogView({
onLogCopy={onLogCopy}
/>
)}
{selectedTab && (
<LogDetail
selectedTab={selectedTab}
log={activeLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
</RawLogViewContainer>
);
}

View File

@@ -45,9 +45,6 @@ export const RawLogViewContainer = styled(Row)<{
: `margin: 2px 0;`}
}
${({ $isActiveLog, $logType }): string =>
getActiveLogBackground($isActiveLog, true, $logType)}
${({ $isReadOnly, $isActiveLog, $isDarkMode, $logType }): string =>
$isActiveLog
? getActiveLogBackground($isActiveLog, $isDarkMode, $logType)

View File

@@ -1,4 +1,5 @@
import { MouseEvent } from 'react';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { FontSize } from 'container/OptionsMenu/types';
import { IField } from 'types/api/logs/fields';
@@ -16,6 +17,11 @@ export interface RawLogViewProps {
selectedFields?: IField[];
onLogClick?: (log: ILog, event: MouseEvent) => void;
handleChangeSelectedView?: ChangeViewFunctionType;
onSetActiveLog?: (
log: ILog,
selectedTab?: typeof VIEW_TYPES[keyof typeof VIEW_TYPES],
) => void;
onClearActiveLog?: () => void;
}
export interface RawLogContentProps {

View File

@@ -86,7 +86,6 @@ interface QuerySearchProps {
signalSource?: string;
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
onRun?: (query: string) => void;
showFilterSuggestionsWithoutMetric?: boolean;
}
function QuerySearch({
@@ -97,7 +96,6 @@ function QuerySearch({
onRun,
signalSource,
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
}: QuerySearchProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
@@ -254,8 +252,7 @@ function QuerySearch({
async (searchText?: string): Promise<void> => {
if (
dataSource === DataSource.METRICS &&
!queryData.aggregateAttribute?.key &&
!showFilterSuggestionsWithoutMetric
!queryData.aggregateAttribute?.key
) {
setKeySuggestions([]);
return;
@@ -304,7 +301,6 @@ function QuerySearch({
queryData.aggregateAttribute?.key,
signalSource,
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
],
);
@@ -1566,7 +1562,6 @@ QuerySearch.defaultProps = {
hardcodedAttributeKeys: undefined,
placeholder:
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')",
showFilterSuggestionsWithoutMetric: false,
};
export default QuerySearch;

View File

@@ -1,8 +1,9 @@
/* eslint-disable no-nested-ternary */
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useQuery } from 'react-query';
import { Virtuoso } from 'react-virtuoso';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Card } from 'antd';
import LogDetail from 'components/LogDetail';
import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
@@ -11,6 +12,8 @@ import LogsError from 'container/LogsError/LogsError';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { ILog } from 'types/api/logs/log';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
@@ -40,6 +43,15 @@ function EntityLogs({
category,
queryKeyFilters,
}: Props): JSX.Element {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const {
activeLog,
onAddToQuery,
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
} = useLogDetailHandlers();
const basePayload = getEntityEventsOrLogsQueryPayload(
timeRange.startTime,
timeRange.endTime,
@@ -62,29 +74,40 @@ function EntityLogs({
basePayload,
});
const handleScrollToLog = useScrollToLog({
logs,
virtuosoRef,
});
const getItemContent = useCallback(
(_: number, logToRender: ILog): JSX.Element => (
<RawLogView
isTextOverflowEllipsisDisabled
key={logToRender.id}
data={logToRender}
linesPerRow={5}
fontSize={FontSize.MEDIUM}
selectedFields={[
{
dataType: 'string',
type: '',
name: 'body',
},
{
dataType: 'string',
type: '',
name: 'timestamp',
},
]}
/>
),
[],
(_: number, logToRender: ILog): JSX.Element => {
return (
<div key={logToRender.id}>
<RawLogView
isTextOverflowEllipsisDisabled
data={logToRender}
linesPerRow={5}
fontSize={FontSize.MEDIUM}
selectedFields={[
{
dataType: 'string',
type: '',
name: 'body',
},
{
dataType: 'string',
type: '',
name: 'timestamp',
},
]}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
isActiveLog={activeLog?.id === logToRender.id}
/>
</div>
);
},
[activeLog, handleSetActiveLog, handleCloseLogDetail],
);
const { data, isLoading, isFetching, isError } = useQuery({
@@ -131,6 +154,7 @@ function EntityLogs({
<Virtuoso
className="entity-logs-virtuoso"
key="entity-logs-virtuoso"
ref={virtuosoRef}
data={logs}
endReached={loadMoreLogs}
totalCount={logs.length}
@@ -154,7 +178,21 @@ function EntityLogs({
)}
{isError && !isLoading && <LogsError />}
{!isLoading && !isError && logs.length > 0 && (
<div className="entity-logs-list-container">{renderContent}</div>
<div className="entity-logs-list-container" data-log-detail-ignore="true">
{renderContent}
</div>
)}
{selectedTab && activeLog && (
<LogDetail
log={activeLog}
onClose={handleCloseLogDetail}
logs={logs}
onNavigateLog={handleSetActiveLog}
selectedTab={selectedTab}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onScrollToLog={handleScrollToLog}
/>
)}
</div>
);

View File

@@ -2,7 +2,6 @@ import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Card, Typography } from 'antd';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import ListLogView from 'components/Logs/ListLogView';
import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
@@ -14,8 +13,9 @@ import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
import { useOptionsMenu } from 'container/OptionsMenu';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { useEventSource } from 'providers/EventSource';
// interfaces
import { ILog } from 'types/api/logs/log';
@@ -38,10 +38,11 @@ function LiveLogsList({
const {
activeLog,
onClearActiveLog,
onAddToQuery,
onSetActiveLog,
} = useActiveLog();
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
} = useLogDetailHandlers();
// get only data from the logs object
const formattedLogs: ILog[] = useMemo(
@@ -65,42 +66,56 @@ function LiveLogsList({
...options.selectColumns,
]);
const handleScrollToLog = useScrollToLog({
logs: formattedLogs,
virtuosoRef: ref,
});
const getItemContent = useCallback(
(_: number, log: ILog): JSX.Element => {
if (options.format === 'raw') {
return (
<RawLogView
key={log.id}
data={log}
linesPerRow={options.maxLines}
selectedFields={selectedFields}
fontSize={options.fontSize}
handleChangeSelectedView={handleChangeSelectedView}
/>
<div key={log.id}>
<RawLogView
data={log}
isActiveLog={activeLog?.id === log.id}
linesPerRow={options.maxLines}
selectedFields={selectedFields}
fontSize={options.fontSize}
handleChangeSelectedView={handleChangeSelectedView}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
/>
</div>
);
}
return (
<ListLogView
key={log.id}
logData={log}
selectedFields={selectedFields}
linesPerRow={options.maxLines}
onAddToQuery={onAddToQuery}
onSetActiveLog={onSetActiveLog}
fontSize={options.fontSize}
handleChangeSelectedView={handleChangeSelectedView}
/>
<div key={log.id}>
<ListLogView
logData={log}
isActiveLog={activeLog?.id === log.id}
selectedFields={selectedFields}
linesPerRow={options.maxLines}
onAddToQuery={onAddToQuery}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
fontSize={options.fontSize}
handleChangeSelectedView={handleChangeSelectedView}
/>
</div>
);
},
[
handleChangeSelectedView,
onAddToQuery,
onSetActiveLog,
options.fontSize,
options.format,
options.maxLines,
options.fontSize,
activeLog?.id,
selectedFields,
onAddToQuery,
handleSetActiveLog,
handleCloseLogDetail,
handleChangeSelectedView,
],
);
@@ -156,6 +171,10 @@ function LiveLogsList({
activeLogIndex,
}}
handleChangeSelectedView={handleChangeSelectedView}
logs={formattedLogs}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
activeLog={activeLog}
/>
) : (
<Card style={{ width: '100%' }} bodyStyle={CARD_BODY_STYLE}>
@@ -173,14 +192,17 @@ function LiveLogsList({
</InfinityWrapperStyled>
)}
{activeLog && (
{activeLog && selectedTab && (
<LogDetail
selectedTab={VIEW_TYPES.OVERVIEW}
selectedTab={selectedTab}
log={activeLog}
onClose={onClearActiveLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
logs={formattedLogs}
onNavigateLog={handleSetActiveLog}
onScrollToLog={handleScrollToLog}
/>
)}
</div>

View File

@@ -395,7 +395,7 @@ export default function TableViewActions(
onOpenChange={setIsOpen}
arrow={false}
content={
<div>
<div data-log-detail-ignore="true">
<Button
className="more-filter-actions"
type="text"
@@ -481,7 +481,7 @@ export default function TableViewActions(
onOpenChange={setIsOpen}
arrow={false}
content={
<div>
<div data-log-detail-ignore="true">
<Button
className="more-filter-actions"
type="text"

View File

@@ -7,6 +7,7 @@ import {
useMemo,
} from 'react';
import { ColumnsType } from 'antd/es/table';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import LogLinesActionButtons from 'components/Logs/LogLinesActionButtons/LogLinesActionButtons';
import { ColumnTypeRender } from 'components/Logs/TableView/types';
import { FontSize } from 'container/OptionsMenu/types';
@@ -22,22 +23,27 @@ interface TableRowProps {
tableColumns: ColumnsType<Record<string, unknown>>;
index: number;
log: Record<string, unknown>;
handleSetActiveContextLog: (log: ILog) => void;
onShowLogDetails: (log: ILog) => void;
onShowLogDetails?: (
log: ILog,
selectedTab?: typeof VIEW_TYPES[keyof typeof VIEW_TYPES],
) => void;
logs: ILog[];
hasActions: boolean;
fontSize: FontSize;
isActiveLog?: boolean;
onClearActiveLog?: () => void;
}
export default function TableRow({
tableColumns,
index,
log,
handleSetActiveContextLog,
onShowLogDetails,
logs,
hasActions,
fontSize,
isActiveLog,
onClearActiveLog,
}: TableRowProps): JSX.Element {
const isDarkMode = useIsDarkMode();
@@ -52,21 +58,31 @@ export default function TableRow({
(event) => {
event.preventDefault();
event.stopPropagation();
if (!handleSetActiveContextLog || !currentLog) {
if (!currentLog) {
return;
}
handleSetActiveContextLog(currentLog);
onShowLogDetails?.(currentLog, VIEW_TYPES.CONTEXT);
},
[currentLog, handleSetActiveContextLog],
[currentLog, onShowLogDetails],
);
const handleShowLogDetails = useCallback(() => {
if (!onShowLogDetails || !currentLog) {
if (!currentLog) {
return;
}
onShowLogDetails(currentLog);
}, [currentLog, onShowLogDetails]);
// If this log is already active, close the detail drawer
if (isActiveLog && onClearActiveLog) {
onClearActiveLog();
return;
}
// Otherwise, open the detail drawer for this log
if (onShowLogDetails) {
onShowLogDetails(currentLog);
}
}, [currentLog, onShowLogDetails, isActiveLog, onClearActiveLog]);
const hasSingleColumn =
tableColumns.filter((column) => column.key !== 'state-indicator').length ===

View File

@@ -4,7 +4,6 @@ import {
TableVirtuoso,
TableVirtuosoHandle,
} from 'react-virtuoso';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
import { useTableView } from 'components/Logs/TableView/useTableView';
@@ -58,26 +57,40 @@ const CustomTableRow: TableComponents<ILog>['TableRow'] = ({
const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
function InfinityTableView(
{ isLoading, tableViewProps, infitiyTableProps, handleChangeSelectedView },
ref,
): JSX.Element | null {
const {
activeLog: activeContextLog,
onSetActiveLog: handleSetActiveContextLog,
onClearActiveLog: handleClearActiveContextLog,
onAddToQuery: handleAddToQuery,
} = useActiveLog();
const {
activeLog,
{
isLoading,
tableViewProps,
infitiyTableProps,
onSetActiveLog,
onClearActiveLog,
onAddToQuery,
} = useActiveLog();
activeLog,
},
ref,
): JSX.Element | null {
const { activeLog: activeContextLog } = useActiveLog();
const onSetActiveLogExpand = useCallback(
(log: ILog) => {
onSetActiveLog?.(log);
},
[onSetActiveLog],
);
const onSetActiveLogContext = useCallback(
(log: ILog) => {
onSetActiveLog?.(log, VIEW_TYPES.CONTEXT);
},
[onSetActiveLog],
);
const onCloseActiveLog = useCallback(() => {
onClearActiveLog?.();
}, [onClearActiveLog]);
const { dataSource, columns } = useTableView({
...tableViewProps,
onClickExpand: onSetActiveLog,
onOpenLogsContext: handleSetActiveContextLog,
onClickExpand: onSetActiveLogExpand,
onOpenLogsContext: onSetActiveLogContext,
});
const { draggedColumns, onDragColumns } = useDragColumns<
@@ -98,27 +111,32 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
);
const itemContent = useCallback(
(index: number, log: Record<string, unknown>): JSX.Element => (
<TableRow
tableColumns={tableColumns}
index={index}
log={log}
handleSetActiveContextLog={handleSetActiveContextLog}
logs={tableViewProps.logs}
hasActions
fontSize={tableViewProps.fontSize}
onShowLogDetails={onSetActiveLog}
/>
),
(index: number, log: Record<string, unknown>): JSX.Element => {
return (
<div key={log.id as string}>
<TableRow
tableColumns={tableColumns}
index={index}
log={log}
logs={tableViewProps.logs}
hasActions
fontSize={tableViewProps.fontSize}
onShowLogDetails={onSetActiveLog}
isActiveLog={activeLog?.id === log.id}
onClearActiveLog={onCloseActiveLog}
/>
</div>
);
},
[
handleSetActiveContextLog,
tableColumns,
tableViewProps.fontSize,
tableViewProps.logs,
onSetActiveLog,
tableViewProps.logs,
tableViewProps.fontSize,
activeLog?.id,
onCloseActiveLog,
],
);
const tableHeader = useCallback(
() => (
<tr>
@@ -179,24 +197,6 @@ const InfinityTable = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
? { endReached: infitiyTableProps.onEndReached }
: {})}
/>
{activeContextLog && (
<LogDetail
log={activeContextLog}
onClose={handleClearActiveContextLog}
onAddToQuery={handleAddToQuery}
selectedTab={VIEW_TYPES.CONTEXT}
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
<LogDetail
selectedTab={VIEW_TYPES.OVERVIEW}
log={activeLog}
onClose={onClearActiveLog}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
/>
</>
);
},

View File

@@ -1,5 +1,7 @@
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { UseTableViewProps } from 'components/Logs/TableView/types';
import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { ILog } from 'types/api/logs/log';
export type InfinityTableProps = {
isLoading?: boolean;
@@ -8,4 +10,11 @@ export type InfinityTableProps = {
onEndReached: (index: number) => void;
};
handleChangeSelectedView?: ChangeViewFunctionType;
logs?: ILog[];
onSetActiveLog?: (
log: ILog,
selectedTab?: typeof VIEW_TYPES[keyof typeof VIEW_TYPES],
) => void;
onClearActiveLog?: () => void;
activeLog?: ILog | null;
};

View File

@@ -4,7 +4,6 @@ import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
// components
import ListLogView from 'components/Logs/ListLogView';
import RawLogView from 'components/Logs/RawLogView';
@@ -16,8 +15,9 @@ import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { useOptionsMenu } from 'container/OptionsMenu';
import { FontSize } from 'container/OptionsMenu/types';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import APIError from 'types/api/error';
// interfaces
@@ -55,10 +55,11 @@ function LogsExplorerList({
const {
activeLog,
onClearActiveLog,
onAddToQuery,
onSetActiveLog,
} = useActiveLog();
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
} = useLogDetailHandlers();
const { options } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
@@ -82,6 +83,12 @@ function LogsExplorerList({
() => convertKeysToColumnFields(options.selectColumns),
[options],
);
const handleScrollToLog = useScrollToLog({
logs,
virtuosoRef: ref,
});
useEffect(() => {
if (!isLoading && !isFetching && !isError && logs.length !== 0) {
logEvent('Logs Explorer: Data present', {
@@ -94,40 +101,48 @@ function LogsExplorerList({
(_: number, log: ILog): JSX.Element => {
if (options.format === 'raw') {
return (
<RawLogView
key={log.id}
data={log}
linesPerRow={options.maxLines}
selectedFields={selectedFields}
fontSize={options.fontSize}
handleChangeSelectedView={handleChangeSelectedView}
/>
<div key={log.id}>
<RawLogView
data={log}
isActiveLog={activeLog?.id === log.id}
linesPerRow={options.maxLines}
selectedFields={selectedFields}
fontSize={options.fontSize}
handleChangeSelectedView={handleChangeSelectedView}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
/>
</div>
);
}
return (
<ListLogView
key={log.id}
logData={log}
selectedFields={selectedFields}
onAddToQuery={onAddToQuery}
onSetActiveLog={onSetActiveLog}
activeLog={activeLog}
fontSize={options.fontSize}
linesPerRow={options.maxLines}
handleChangeSelectedView={handleChangeSelectedView}
/>
<div key={log.id}>
<ListLogView
logData={log}
isActiveLog={activeLog?.id === log.id}
selectedFields={selectedFields}
onAddToQuery={onAddToQuery}
onSetActiveLog={handleSetActiveLog}
activeLog={activeLog}
fontSize={options.fontSize}
linesPerRow={options.maxLines}
handleChangeSelectedView={handleChangeSelectedView}
onClearActiveLog={handleCloseLogDetail}
/>
</div>
);
},
[
activeLog,
handleChangeSelectedView,
onAddToQuery,
onSetActiveLog,
options.fontSize,
options.format,
options.fontSize,
options.maxLines,
activeLog,
selectedFields,
onAddToQuery,
handleSetActiveLog,
handleChangeSelectedView,
handleCloseLogDetail,
],
);
@@ -153,6 +168,10 @@ function LogsExplorerList({
}}
infitiyTableProps={{ onEndReached }}
handleChangeSelectedView={handleChangeSelectedView}
logs={logs}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
activeLog={activeLog}
/>
);
}
@@ -199,6 +218,9 @@ function LogsExplorerList({
getItemContent,
selectedFields,
handleChangeSelectedView,
handleSetActiveLog,
handleCloseLogDetail,
activeLog,
]);
const isTraceToLogsNavigation = useMemo(() => {
@@ -278,14 +300,19 @@ function LogsExplorerList({
{renderContent}
</InfinityWrapperStyled>
<LogDetail
selectedTab={VIEW_TYPES.OVERVIEW}
log={activeLog}
onClose={onClearActiveLog}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
/>
{selectedTab && activeLog && (
<LogDetail
selectedTab={selectedTab}
log={activeLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
handleChangeSelectedView={handleChangeSelectedView}
logs={logs}
onNavigateLog={handleSetActiveLog}
onScrollToLog={handleScrollToLog}
/>
)}
</>
)}
</div>

View File

@@ -466,7 +466,10 @@ function LogsExplorerViewsContainer({
</div>
)}
<div className="logs-explorer-views-type-content">
<div
className="logs-explorer-views-type-content"
data-log-detail-ignore="true"
>
{showLiveLogs && (
<LiveLogs handleChangeSelectedView={handleChangeSelectedView} />
)}

View File

@@ -8,7 +8,6 @@ import {
} from 'react';
import { UseQueryResult } from 'react-query';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { ResizeTable } from 'components/ResizeTable';
import { SOMETHING_WENT_WRONG } from 'constants/api';
@@ -16,7 +15,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import Controls from 'container/Controls';
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
import { tableStyles } from 'container/TracesExplorer/ListView/styles';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import { useLogsData } from 'hooks/useLogsData';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { FlatLogData } from 'lib/logs/flatLogData';
@@ -83,24 +82,24 @@ function LogsPanelComponent({
() => logs.map((log) => FlatLogData(log) as RowData),
[logs],
);
const {
activeLog,
onSetActiveLog,
onClearActiveLog,
onAddToQuery,
} = useActiveLog();
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
} = useLogDetailHandlers();
const handleRow = useCallback(
(record: RowData): HTMLAttributes<RowData> => ({
onClick: (): void => {
const log = logs.find((item) => item.id === record.id);
if (log) {
onSetActiveLog(log);
handleSetActiveLog(log);
}
},
}),
[logs, onSetActiveLog],
[handleSetActiveLog, logs],
);
const handleRequestData = (newOffset: number): void => {
@@ -132,7 +131,7 @@ function LogsPanelComponent({
return (
<>
<div className="logs-table">
<div className="logs-table" data-log-detail-ignore="true">
<div className="resize-table">
<OverlayScrollbar>
<ResizeTable
@@ -166,15 +165,19 @@ function LogsPanelComponent({
</div>
)}
</div>
<LogDetail
selectedTab={VIEW_TYPES.OVERVIEW}
log={activeLog}
onClose={onClearActiveLog}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
isListViewPanel
listViewPanelSelectedFields={widget?.selectedLogFields}
/>
{selectedTab && activeLog && (
<LogDetail
selectedTab={selectedTab}
log={activeLog}
onClose={handleCloseLogDetail}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
isListViewPanel
listViewPanelSelectedFields={widget?.selectedLogFields}
logs={logs}
onNavigateLog={handleSetActiveLog}
/>
)}
</>
);
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import {
Button,
Empty,
@@ -9,24 +10,22 @@ import {
Popover,
Spin,
} from 'antd';
import { Filter } from 'api/v5/v5';
import {
convertExpressionToFilters,
convertFiltersToExpression,
} from 'components/QueryBuilderV2/utils';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetMetricsListFilterValues } from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Search } from 'lucide-react';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { SUMMARY_FILTERS_KEY } from './constants';
function MetricNameSearch({
queryFilterExpression,
onFilterChange,
queryFilters,
}: {
queryFilterExpression: Filter;
onFilterChange: (value: string) => void;
queryFilters: TagFilter;
}): JSX.Element {
const [searchParams, setSearchParams] = useSearchParams();
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const [searchString, setSearchString] = useState<string>('');
const [debouncedSearchString, setDebouncedSearchString] = useState<string>('');
@@ -68,12 +67,9 @@ function MetricNameSearch({
const handleSelect = useCallback(
(selectedMetricName: string): void => {
const queryFilters = convertExpressionToFilters(
queryFilterExpression?.expression,
);
const newFilters = {
items: [
...queryFilters,
...queryFilters.items,
{
id: 'metric_name',
op: 'CONTAINS',
@@ -87,11 +83,13 @@ function MetricNameSearch({
],
op: 'and',
};
const newFilterExpression = convertFiltersToExpression(newFilters);
onFilterChange(newFilterExpression.expression);
setSearchParams({
...Object.fromEntries(searchParams.entries()),
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
});
setIsPopoverOpen(false);
},
[queryFilterExpression, onFilterChange],
[queryFilters.items, setSearchParams, searchParams],
);
const metricNameFilterValues = useMemo(

View File

@@ -1,19 +1,23 @@
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { Button, Menu, Popover, Tooltip } from 'antd';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { Search } from 'lucide-react';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { METRIC_TYPE_LABEL_MAP_V2 } from './constants';
import {
METRIC_TYPE_LABEL_MAP,
METRIC_TYPE_VALUES_MAP,
SUMMARY_FILTERS_KEY,
} from './constants';
function MetricTypeSearch({
queryFilters,
onFilterChange,
}: {
queryFilters: TagFilter;
onFilterChange: (expression: string) => void;
}): JSX.Element {
const [searchParams, setSearchParams] = useSearchParams();
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const menuItems = useMemo(
@@ -22,9 +26,9 @@ function MetricTypeSearch({
key: 'all',
value: 'All',
},
...Object.keys(METRIC_TYPE_LABEL_MAP_V2).map((key) => ({
key: METRIC_TYPE_LABEL_MAP_V2[key as MetrictypesTypeDTO],
value: METRIC_TYPE_LABEL_MAP_V2[key as MetrictypesTypeDTO],
...Object.keys(METRIC_TYPE_LABEL_MAP).map((key) => ({
key: METRIC_TYPE_VALUES_MAP[key as MetricType],
value: METRIC_TYPE_LABEL_MAP[key as MetricType],
})),
],
[],
@@ -32,17 +36,16 @@ function MetricTypeSearch({
const handleSelect = useCallback(
(selectedMetricType: string): void => {
let newFilters;
if (selectedMetricType !== 'all') {
newFilters = {
const newFilters = {
items: [
...queryFilters.items,
{
id: 'ttype',
id: 'metric_type',
op: '=',
key: {
id: 'type',
key: 'type',
id: 'metric_type',
key: 'metric_type',
type: 'tag',
},
value: selectedMetricType,
@@ -50,17 +53,23 @@ function MetricTypeSearch({
],
op: 'AND',
};
setSearchParams({
...Object.fromEntries(searchParams.entries()),
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
});
} else {
newFilters = {
items: queryFilters.items.filter((item) => item.id !== 'type'),
const newFilters = {
items: queryFilters.items.filter((item) => item.id !== 'metric_type'),
op: 'AND',
};
setSearchParams({
...Object.fromEntries(searchParams.entries()),
[SUMMARY_FILTERS_KEY]: JSON.stringify(newFilters),
});
}
const newFilterExpression = convertFiltersToExpression(newFilters);
onFilterChange(newFilterExpression.expression);
setIsPopoverOpen(false);
},
[queryFilters.items, onFilterChange],
[queryFilters.items, setSearchParams, searchParams],
);
const menu = (

View File

@@ -1,59 +1,27 @@
import { useEffect, useState } from 'react';
import { Button, Tooltip } from 'antd';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { Tooltip } from 'antd';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { Info, Play } from 'lucide-react';
import { DataSource } from 'types/common/queryBuilder';
import { HardHat, Info } from 'lucide-react';
import { MetricsSearchProps } from './types';
function MetricsSearch({ query, onChange }: MetricsSearchProps): JSX.Element {
const [currentExpression, setCurrentExpression] = useState<string>(
query?.filter?.expression || '',
);
useEffect(() => {
const expression = query?.filter?.expression || '';
setCurrentExpression(expression);
}, [query?.filter?.expression]);
const handleOnChange = (expression: string): void => {
setCurrentExpression(expression);
};
const handleStageAndRunQuery = (): void => onChange(currentExpression);
return (
<div className="metrics-search-container">
<div data-testid="qb-search-container" className="qb-search-container">
<div className="qb-search-container">
<Tooltip
title="Use filters to refine metrics based on attributes. Example: service_name=api - Shows all metrics associated with the API service"
placement="right"
>
<Info size={16} />
</Tooltip>
<QuerySearch
onChange={handleOnChange}
dataSource={DataSource.METRICS}
queryData={{
...query,
filter: {
...query?.filter,
expression: currentExpression,
},
}}
onRun={handleOnChange}
showFilterSuggestionsWithoutMetric
<QueryBuilderSearch
query={query}
onChange={onChange}
suffixIcon={<HardHat size={16} />}
isMetricsExplorer
/>
</div>
<Button
type="primary"
onClick={handleStageAndRunQuery}
className="stage-run-query"
icon={<Play size={14} />}
>
Stage & Run Query
</Button>
<div className="metrics-search-options">
<DateTimeSelectionV2
showAutoRefresh={false}

View File

@@ -24,8 +24,7 @@ function MetricsTable({
setOrderBy,
totalCount,
openMetricDetails,
queryFilterExpression,
onFilterChange,
queryFilters,
}: MetricsTableProps): JSX.Element {
const handleTableChange: TableProps<MetricsListItemRowData>['onChange'] = useCallback(
(
@@ -37,17 +36,13 @@ function MetricsTable({
): void => {
if ('field' in sorter && sorter.order) {
setOrderBy({
key: {
name: sorter.field as string,
},
direction: sorter.order === 'ascend' ? 'asc' : 'desc',
columnName: sorter.field as string,
order: sorter.order === 'ascend' ? 'asc' : 'desc',
});
} else {
setOrderBy({
key: {
name: 'samples',
},
direction: 'desc',
columnName: 'samples',
order: 'desc',
});
}
},
@@ -56,17 +51,19 @@ function MetricsTable({
return (
<div className="metrics-table-container">
<div className="metrics-table-title" data-testid="metrics-table-title">
<Typography.Title level={4} className="metrics-table-title">
List View
</Typography.Title>
<Tooltip
title="The table displays all metrics in the selected time range. Each row represents a unique metric, and its metric name, and metadata like description, type, unit, and samples/timeseries cardinality observed in the selected time range."
placement="right"
>
<Info size={16} />
</Tooltip>
</div>
{!isError && !isLoading && (
<div className="metrics-table-title" data-testid="metrics-table-title">
<Typography.Title level={4} className="metrics-table-title">
List View
</Typography.Title>
<Tooltip
title="The table displays all metrics in the selected time range. Each row represents a unique metric, and its metric name, and metadata like description, type, unit, and samples/timeseries cardinality observed in the selected time range."
placement="right"
>
<Info size={16} />
</Tooltip>
</div>
)}
<Table
loading={{
spinning: isLoading,
@@ -78,7 +75,7 @@ function MetricsTable({
),
}}
dataSource={data}
columns={getMetricsTableColumns(queryFilterExpression, onFilterChange)}
columns={getMetricsTableColumns(queryFilters)}
locale={{
emptyText: isLoading ? null : (
<div

View File

@@ -3,7 +3,6 @@ import { useWindowSize } from 'react-use';
import { Group } from '@visx/group';
import { Treemap } from '@visx/hierarchy';
import { Empty, Select, Skeleton, Tooltip, Typography } from 'antd';
import { MetricsexplorertypesTreemapModeDTO } from 'api/generated/services/sigNoz.schemas';
import { stratify, treemapBinary } from 'd3-hierarchy';
import { Info } from 'lucide-react';
@@ -13,7 +12,7 @@ import {
TREEMAP_SQUARE_PADDING,
TREEMAP_VIEW_OPTIONS,
} from './constants';
import { MetricsTreemapProps, TreemapTile } from './types';
import { MetricsTreemapProps, TreemapTile, TreemapViewType } from './types';
import {
getTreemapTileStyle,
getTreemapTileTextStyle,
@@ -41,9 +40,9 @@ function MetricsTreemap({
const treemapData = useMemo(() => {
const extracedTreemapData =
(viewType === MetricsexplorertypesTreemapModeDTO.timeseries
? data?.timeseries
: data?.samples) || [];
(viewType === TreemapViewType.TIMESERIES
? data?.data?.[TreemapViewType.TIMESERIES]
: data?.data?.[TreemapViewType.SAMPLES]) || [];
return transformTreemapData(extracedTreemapData, viewType);
}, [data, viewType]);
@@ -55,36 +54,62 @@ function MetricsTreemap({
const xMax = treemapWidth - TREEMAP_MARGINS.LEFT - TREEMAP_MARGINS.RIGHT;
const yMax = TREEMAP_HEIGHT - TREEMAP_MARGINS.TOP - TREEMAP_MARGINS.BOTTOM;
const treemapContent = useMemo(() => {
if (isLoading) {
return (
<div data-testid="metrics-treemap-loading-state">
<Skeleton style={{ width: treemapWidth, height: TREEMAP_HEIGHT }} active />
</div>
);
}
if (isError) {
return (
<Empty
description="Error fetching metrics. If the problem persists, please contact support."
data-testid="metrics-treemap-error-state"
style={{ width: treemapWidth, height: TREEMAP_HEIGHT, paddingTop: 30 }}
/>
);
}
if (!data || !data?.[viewType]?.length) {
return (
<Empty
description="No metrics found"
data-testid="metrics-treemap-empty-state"
style={{ width: treemapWidth, height: TREEMAP_HEIGHT, paddingTop: 30 }}
/>
);
}
if (isLoading) {
return (
<div data-testid="metrics-treemap-loading-state">
<Skeleton
style={{ width: treemapWidth, height: TREEMAP_HEIGHT + 55 }}
active
/>
</div>
);
}
if (
!data ||
!data.data ||
(data?.status === 'success' && !data?.data?.[viewType])
) {
return (
<Empty
description="No metrics found"
data-testid="metrics-treemap-empty-state"
style={{ width: treemapWidth, height: TREEMAP_HEIGHT, paddingTop: 30 }}
/>
);
}
if (data?.status === 'error' || isError) {
return (
<Empty
description="Error fetching metrics. If the problem persists, please contact support."
data-testid="metrics-treemap-error-state"
style={{ width: treemapWidth, height: TREEMAP_HEIGHT, paddingTop: 30 }}
/>
);
}
return (
<div
className="metrics-treemap-container"
data-testid="metrics-treemap-container"
>
<div className="metrics-treemap-title">
<div className="metrics-treemap-title-left">
<Typography.Title level={4}>Proportion View</Typography.Title>
<Tooltip
title="The treemap displays the proportion of samples/timeseries in the selected time range. Each tile represents a unique metric, and its size indicates the percentage of samples/timeseries it contributes to the total."
placement="right"
>
<Info size={16} />
</Tooltip>
</div>
<Select
options={TREEMAP_VIEW_OPTIONS}
value={viewType}
onChange={setHeatmapView}
/>
</div>
<svg
width={treemapWidth}
height={TREEMAP_HEIGHT}
@@ -149,42 +174,6 @@ function MetricsTreemap({
)}
</Treemap>
</svg>
);
}, [
data,
isError,
isLoading,
openMetricDetails,
transformedTreemapData,
treemapWidth,
viewType,
xMax,
yMax,
]);
return (
<div
className="metrics-treemap-container"
data-testid="metrics-treemap-container"
>
<div className="metrics-treemap-title">
<div className="metrics-treemap-title-left">
<Typography.Title level={4}>Proportion View</Typography.Title>
<Tooltip
title="The treemap displays the proportion of samples/timeseries in the selected time range. Each tile represents a unique metric, and its size indicates the percentage of samples/timeseries it contributes to the total."
placement="right"
>
<Info size={16} />
</Tooltip>
</div>
<Select
options={TREEMAP_VIEW_OPTIONS}
value={viewType}
onChange={setHeatmapView}
disabled={isLoading}
/>
</div>
{treemapContent}
</div>
);
}

View File

@@ -38,7 +38,6 @@
.metrics-search-container {
display: flex;
gap: 16px;
align-items: center;
.metrics-search-options {
display: flex;

View File

@@ -4,23 +4,11 @@ import { useSelector } from 'react-redux';
import { useSearchParams } from 'react-router-dom-v5-compat';
import * as Sentry from '@sentry/react';
import logEvent from 'api/common/logEvent';
import {
useGetMetricsStats,
useGetMetricsTreemap,
} from 'api/generated/services/metrics';
import {
MetricsexplorertypesStatsRequestDTO,
MetricsexplorertypesTreemapModeDTO,
MetricsexplorertypesTreemapRequestDTO,
Querybuildertypesv5OrderByDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
convertExpressionToFilters,
convertFiltersToExpression,
} from 'components/QueryBuilderV2/utils';
import { initialQueriesMap } from 'constants/queryBuilder';
import { usePageSize } from 'container/InfraMonitoringK8s/utils';
import NoLogs from 'container/NoLogs/NoLogs';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetMetricsList } from 'hooks/metricsExplorer/useGetMetricsList';
import { useGetMetricsTreeMap } from 'hooks/metricsExplorer/useGetMetricsTreeMap';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { AppState } from 'store/reducers';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
@@ -35,38 +23,32 @@ import {
IS_INSPECT_MODAL_OPEN_KEY,
IS_METRIC_DETAILS_OPEN_KEY,
SELECTED_METRIC_NAME_KEY,
SUMMARY_FILTERS_KEY,
} from './constants';
import MetricsSearch from './MetricsSearch';
import MetricsTable from './MetricsTable';
import MetricsTreemap from './MetricsTreemap';
import { convertNanoToMilliseconds, formatDataForMetricsTable } from './utils';
import { OrderByPayload, TreemapViewType } from './types';
import {
convertNanoToMilliseconds,
formatDataForMetricsTable,
getMetricsListQuery,
} from './utils';
import './Summary.styles.scss';
const DEFAULT_ORDER_BY: Querybuildertypesv5OrderByDTO = {
key: {
name: 'samples',
},
direction: 'desc',
const DEFAULT_ORDER_BY: OrderByPayload = {
columnName: 'samples',
order: 'desc',
};
function Summary(): JSX.Element {
const { pageSize, setPageSize } = usePageSize('metricsExplorer');
const [currentPage, setCurrentPage] = useState(1);
const [orderBy, setOrderBy] = useState<Querybuildertypesv5OrderByDTO>(
DEFAULT_ORDER_BY,
const [orderBy, setOrderBy] = useState<OrderByPayload>(DEFAULT_ORDER_BY);
const [heatmapView, setHeatmapView] = useState<TreemapViewType>(
TreemapViewType.TIMESERIES,
);
const [
heatmapView,
setHeatmapView,
] = useState<MetricsexplorertypesTreemapModeDTO>(
MetricsexplorertypesTreemapModeDTO.timeseries,
);
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const query = useMemo(() => currentQuery?.builder?.queryData[0], [
currentQuery,
]);
const [searchParams, setSearchParams] = useSearchParams();
const [isMetricDetailsOpen, setIsMetricDetailsOpen] = useState(
@@ -83,6 +65,17 @@ function Summary(): JSX.Element {
(state) => state.globalTime,
);
const queryFilters: TagFilter = useMemo(() => {
const encodedFilters = searchParams.get(SUMMARY_FILTERS_KEY);
if (encodedFilters) {
return JSON.parse(encodedFilters);
}
return {
items: [],
op: 'AND',
};
}, [searchParams]);
useEffect(() => {
logEvent(MetricsExplorerEvents.TabChanged, {
[MetricsExplorerEventKeys.Tab]: 'summary',
@@ -94,112 +87,105 @@ function Summary(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const queryFilterExpression = useMemo(() => {
const filters = query?.filters || { items: [], op: 'AND' };
return convertFiltersToExpression(filters);
}, [query?.filters]);
// This is used to avoid the filters from being serialized with the id
const queryFiltersWithoutId = useMemo(() => {
const filtersWithoutId = {
...queryFilters,
items: queryFilters.items.map(({ id: _id, ...rest }) => rest),
};
return JSON.stringify(filtersWithoutId);
}, [queryFilters]);
const metricsListQuery: MetricsexplorertypesStatsRequestDTO = useMemo(() => {
const metricsListQuery = useMemo(() => {
const baseQuery = getMetricsListQuery();
return {
start: convertNanoToMilliseconds(minTime),
end: convertNanoToMilliseconds(maxTime),
...baseQuery,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
filters: queryFilters,
start: convertNanoToMilliseconds(minTime),
end: convertNanoToMilliseconds(maxTime),
orderBy,
filter: {
expression: queryFilterExpression.expression,
},
};
}, [
minTime,
maxTime,
orderBy,
pageSize,
currentPage,
queryFilterExpression.expression,
]);
}, [queryFilters, minTime, maxTime, orderBy, pageSize, currentPage]);
const metricsTreemapQuery: MetricsexplorertypesTreemapRequestDTO = useMemo(
const metricsTreemapQuery = useMemo(
() => ({
limit: 100,
filters: queryFilters,
treemap: heatmapView,
start: convertNanoToMilliseconds(minTime),
end: convertNanoToMilliseconds(maxTime),
mode: heatmapView,
filter: {
expression: queryFilterExpression.expression,
},
}),
[heatmapView, minTime, maxTime, queryFilterExpression.expression],
[queryFilters, heatmapView, minTime, maxTime],
);
const {
data: metricsData,
mutate: getMetricsStats,
isLoading: isGetMetricsStatsLoading,
isError: isGetMetricsStatsError,
} = useGetMetricsStats();
isLoading: isMetricsLoading,
isFetching: isMetricsFetching,
isError: isMetricsError,
} = useGetMetricsList(metricsListQuery, {
enabled: !!metricsListQuery && !isInspectModalOpen,
queryKey: [
'metricsList',
queryFiltersWithoutId,
orderBy,
pageSize,
currentPage,
minTime,
maxTime,
],
});
const isListViewError = useMemo(
() => isGetMetricsStatsError || metricsData?.status !== 200,
[isGetMetricsStatsError, metricsData],
() => isMetricsError || !!(metricsData && metricsData.statusCode !== 200),
[isMetricsError, metricsData],
);
const {
data: treeMapData,
mutate: getMetricsTreemap,
isLoading: isGetMetricsTreemapLoading,
isError: isGetMetricsTreemapError,
} = useGetMetricsTreemap();
useEffect(() => {
getMetricsStats({
data: metricsListQuery,
});
getMetricsTreemap({
data: metricsTreemapQuery,
});
}, [
metricsTreemapQuery,
metricsListQuery,
getMetricsTreemap,
getMetricsStats,
]);
isLoading: isTreeMapLoading,
isFetching: isTreeMapFetching,
isError: isTreeMapError,
} = useGetMetricsTreeMap(metricsTreemapQuery, {
enabled: !!metricsTreemapQuery && !isInspectModalOpen,
queryKey: [
'metricsTreemap',
queryFiltersWithoutId,
heatmapView,
minTime,
maxTime,
],
});
const isProportionViewError = useMemo(
() => isGetMetricsTreemapError || treeMapData?.status !== 200,
[isGetMetricsTreemapError, treeMapData],
() => isTreeMapError || treeMapData?.statusCode !== 200,
[isTreeMapError, treeMapData],
);
const handleFilterChange = useCallback(
(expression: string) => {
const newFilters: TagFilter = {
items: convertExpressionToFilters(expression),
op: 'AND',
};
redirectWithQueryBuilderData({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
filters: newFilters,
filter: {
expression,
},
},
],
},
(value: TagFilter) => {
setSearchParams({
...Object.fromEntries(searchParams.entries()),
[SUMMARY_FILTERS_KEY]: JSON.stringify(value),
});
setCurrentPage(1);
if (expression) {
if (value.items.length > 0) {
logEvent(MetricsExplorerEvents.FilterApplied, {
[MetricsExplorerEventKeys.Tab]: 'summary',
});
}
},
[currentQuery, redirectWithQueryBuilderData],
[setSearchParams, searchParams],
);
const searchQuery = useMemo(
() => ({
...initialQueriesMap.metrics.builder.queryData[0],
filters: queryFilters,
}),
[queryFilters],
);
const onPaginationChange = (page: number, pageSize: number): void => {
@@ -216,7 +202,7 @@ function Summary(): JSX.Element {
};
const formattedMetricsData = useMemo(
() => formatDataForMetricsTable(metricsData?.data?.data?.metrics || []),
() => formatDataForMetricsTable(metricsData?.payload?.data?.metrics || []),
[metricsData],
);
@@ -268,9 +254,7 @@ function Summary(): JSX.Element {
});
};
const handleSetHeatmapView = (
view: MetricsexplorertypesTreemapModeDTO,
): void => {
const handleSetHeatmapView = (view: TreemapViewType): void => {
setHeatmapView(view);
logEvent(MetricsExplorerEvents.TreemapViewChanged, {
[MetricsExplorerEventKeys.Tab]: 'summary',
@@ -278,71 +262,63 @@ function Summary(): JSX.Element {
});
};
const handleSetOrderBy = (orderBy: Querybuildertypesv5OrderByDTO): void => {
const handleSetOrderBy = (orderBy: OrderByPayload): void => {
setOrderBy(orderBy);
logEvent(MetricsExplorerEvents.OrderByApplied, {
[MetricsExplorerEventKeys.Tab]: 'summary',
[MetricsExplorerEventKeys.ColumnName]: orderBy.key?.name,
[MetricsExplorerEventKeys.Order]: orderBy.direction,
[MetricsExplorerEventKeys.ColumnName]: orderBy.columnName,
[MetricsExplorerEventKeys.Order]: orderBy.order,
});
};
const isMetricsListDataEmpty = useMemo(
() => formattedMetricsData.length === 0 && !isGetMetricsStatsLoading,
[formattedMetricsData, isGetMetricsStatsLoading],
() =>
formattedMetricsData.length === 0 && !isMetricsLoading && !isMetricsFetching,
[formattedMetricsData, isMetricsLoading, isMetricsFetching],
);
const isMetricsTreeMapDataEmpty = useMemo(
() =>
!treeMapData?.data?.data?.[heatmapView]?.length &&
!isGetMetricsTreemapLoading,
[treeMapData?.data?.data, heatmapView, isGetMetricsTreemapLoading],
);
const showFullScreenLoading = useMemo(
() =>
(isGetMetricsStatsLoading || isGetMetricsTreemapLoading) &&
formattedMetricsData.length === 0 &&
!treeMapData?.data?.data?.[heatmapView]?.length,
!treeMapData?.payload?.data[heatmapView]?.length &&
!isTreeMapLoading &&
!isTreeMapFetching,
[
isGetMetricsStatsLoading,
isGetMetricsTreemapLoading,
formattedMetricsData,
treeMapData,
treeMapData?.payload?.data,
heatmapView,
isTreeMapLoading,
isTreeMapFetching,
],
);
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className="metrics-explorer-summary-tab">
<MetricsSearch query={query} onChange={handleFilterChange} />
{showFullScreenLoading ? (
<MetricsSearch query={searchQuery} onChange={handleFilterChange} />
{isMetricsLoading || isTreeMapLoading ? (
<MetricsLoading />
) : isMetricsListDataEmpty && isMetricsTreeMapDataEmpty ? (
<NoLogs dataSource={DataSource.METRICS} />
) : (
<>
<MetricsTreemap
data={treeMapData?.data?.data}
isLoading={isGetMetricsTreemapLoading}
data={treeMapData?.payload}
isLoading={isTreeMapLoading || isTreeMapFetching}
isError={isProportionViewError}
viewType={heatmapView}
openMetricDetails={openMetricDetails}
setHeatmapView={handleSetHeatmapView}
/>
<MetricsTable
isLoading={isGetMetricsStatsLoading}
isLoading={isMetricsLoading || isMetricsFetching}
isError={isListViewError}
data={formattedMetricsData}
pageSize={pageSize}
currentPage={currentPage}
onPaginationChange={onPaginationChange}
setOrderBy={handleSetOrderBy}
totalCount={metricsData?.data?.data?.total || 0}
totalCount={metricsData?.payload?.data?.total || 0}
openMetricDetails={openMetricDetails}
queryFilterExpression={queryFilterExpression}
onFilterChange={handleFilterChange}
queryFilters={queryFilters}
/>
</>
)}

View File

@@ -1,10 +1,10 @@
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { fireEvent, render, screen } from '@testing-library/react';
import { Filter } from 'api/v5/v5';
import * as useGetMetricsListFilterValues from 'hooks/metricsExplorer/useGetMetricsListFilterValues';
import * as useQueryBuilderOperationsHooks from 'hooks/queryBuilder/useQueryBuilderOperations';
import store from 'store';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import MetricsTable from '../MetricsTable';
import { MetricsListItemRowData } from '../types';
@@ -30,8 +30,9 @@ const mockData: MetricsListItemRowData[] = [
},
];
const mockQueryFilterExpression: Filter = {
expression: '',
const mockQueryFilters: TagFilter = {
items: [],
op: 'AND',
};
jest.mock('react-router-dom-v5-compat', () => {
@@ -81,8 +82,7 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()}
totalCount={2}
openMetricDetails={jest.fn()}
queryFilterExpression={mockQueryFilterExpression}
onFilterChange={jest.fn()}
queryFilters={mockQueryFilters}
/>
</Provider>
</MemoryRouter>,
@@ -106,9 +106,8 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()}
totalCount={2}
openMetricDetails={jest.fn()}
queryFilterExpression={mockQueryFilterExpression}
queryFilters={mockQueryFilters}
isLoading
onFilterChange={jest.fn()}
/>
</Provider>
</MemoryRouter>,
@@ -131,8 +130,7 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()}
totalCount={2}
openMetricDetails={jest.fn()}
queryFilterExpression={mockQueryFilterExpression}
onFilterChange={jest.fn()}
queryFilters={mockQueryFilters}
/>
</Provider>
</MemoryRouter>,
@@ -160,8 +158,7 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()}
totalCount={2}
openMetricDetails={jest.fn()}
queryFilterExpression={mockQueryFilterExpression}
onFilterChange={jest.fn()}
queryFilters={mockQueryFilters}
/>
</Provider>
</MemoryRouter>,
@@ -190,8 +187,7 @@ describe('MetricsTable', () => {
setOrderBy={jest.fn()}
totalCount={2}
openMetricDetails={mockOpenMetricDetails}
queryFilterExpression={mockQueryFilterExpression}
onFilterChange={jest.fn()}
queryFilters={mockQueryFilters}
/>
</Provider>
</MemoryRouter>,
@@ -216,8 +212,7 @@ describe('MetricsTable', () => {
setOrderBy={mockSetOrderBy}
totalCount={2}
openMetricDetails={jest.fn()}
queryFilterExpression={mockQueryFilterExpression}
onFilterChange={jest.fn()}
queryFilters={mockQueryFilters}
/>
</Provider>
</MemoryRouter>,
@@ -227,10 +222,8 @@ describe('MetricsTable', () => {
fireEvent.click(samplesHeader);
expect(mockSetOrderBy).toHaveBeenCalledWith({
key: {
name: 'samples',
},
direction: 'asc',
columnName: 'samples',
order: 'asc',
});
});
});

View File

@@ -1,10 +1,10 @@
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import { MetricsexplorertypesTreemapModeDTO } from 'api/generated/services/sigNoz.schemas';
import store from 'store';
import MetricsTreemap from '../MetricsTreemap';
import { TreemapViewType } from '../types';
jest.mock('d3-hierarchy', () => ({
stratify: jest.fn().mockReturnValue({
@@ -27,14 +27,14 @@ jest.mock('react-use', () => ({
const mockData = [
{
metricName: 'Metric 1',
metric_name: 'Metric 1',
percentage: 0.5,
totalValue: 15,
total_value: 15,
},
{
metricName: 'Metric 2',
metric_name: 'Metric 2',
percentage: 0.6,
totalValue: 10,
total_value: 10,
},
];
@@ -47,11 +47,14 @@ describe('MetricsTreemap', () => {
isLoading={false}
isError={false}
data={{
timeseries: [mockData[0]],
samples: [mockData[1]],
status: 'success',
data: {
timeseries: [mockData[0]],
samples: [mockData[1]],
},
}}
openMetricDetails={jest.fn()}
viewType={MetricsexplorertypesTreemapModeDTO.samples}
viewType={TreemapViewType.SAMPLES}
setHeatmapView={jest.fn()}
/>
</Provider>
@@ -69,11 +72,14 @@ describe('MetricsTreemap', () => {
isLoading
isError={false}
data={{
timeseries: [mockData[0]],
samples: [mockData[1]],
status: 'success',
data: {
timeseries: [mockData[0]],
samples: [mockData[1]],
},
}}
openMetricDetails={jest.fn()}
viewType={MetricsexplorertypesTreemapModeDTO.samples}
viewType={TreemapViewType.SAMPLES}
setHeatmapView={jest.fn()}
/>
</Provider>
@@ -93,11 +99,14 @@ describe('MetricsTreemap', () => {
isLoading={false}
isError
data={{
timeseries: [mockData[0]],
samples: [mockData[1]],
status: 'success',
data: {
timeseries: [mockData[0]],
samples: [mockData[1]],
},
}}
openMetricDetails={jest.fn()}
viewType={MetricsexplorertypesTreemapModeDTO.samples}
viewType={TreemapViewType.SAMPLES}
setHeatmapView={jest.fn()}
/>
</Provider>
@@ -121,7 +130,7 @@ describe('MetricsTreemap', () => {
isError={false}
data={null}
openMetricDetails={jest.fn()}
viewType={MetricsexplorertypesTreemapModeDTO.samples}
viewType={TreemapViewType.SAMPLES}
setHeatmapView={jest.fn()}
/>
</Provider>

View File

@@ -1,9 +1,8 @@
import { Color } from '@signozhq/design-tokens';
import { render } from '@testing-library/react';
import { MetrictypesTypeDTO } from 'api/generated/services/sigNoz.schemas';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { Filter } from 'api/v5/v5';
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { TreemapViewType } from '../types';
import {
@@ -12,76 +11,52 @@ import {
MetricTypeRenderer,
} from '../utils';
const mockQueryExpression: Filter = {
expression: '',
};
const mockOnChange = jest.fn();
describe('metricsTableColumns', () => {
const mockQueryFilters: TagFilter = {
items: [],
op: 'AND',
};
it('should have correct column definitions', () => {
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange),
).toHaveLength(6);
expect(getMetricsTableColumns(mockQueryFilters)).toHaveLength(6);
// Metric Name column
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[0].dataIndex,
).toBe('metric_name');
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[0].width,
).toBe(400);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[0].sorter,
).toBe(false);
expect(getMetricsTableColumns(mockQueryFilters)[0].dataIndex).toBe(
'metric_name',
);
expect(getMetricsTableColumns(mockQueryFilters)[0].width).toBe(400);
expect(getMetricsTableColumns(mockQueryFilters)[0].sorter).toBe(false);
// Description column
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[1].dataIndex,
).toBe('description');
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[1].width,
).toBe(400);
expect(getMetricsTableColumns(mockQueryFilters)[1].dataIndex).toBe(
'description',
);
expect(getMetricsTableColumns(mockQueryFilters)[1].width).toBe(400);
// Type column
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[2].dataIndex,
).toBe('metric_type');
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[2].width,
).toBe(150);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[2].sorter,
).toBe(false);
expect(getMetricsTableColumns(mockQueryFilters)[2].dataIndex).toBe(
'metric_type',
);
expect(getMetricsTableColumns(mockQueryFilters)[2].width).toBe(150);
expect(getMetricsTableColumns(mockQueryFilters)[2].sorter).toBe(false);
// Unit column
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[3].dataIndex,
).toBe('unit');
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[3].width,
).toBe(150);
expect(getMetricsTableColumns(mockQueryFilters)[3].dataIndex).toBe('unit');
expect(getMetricsTableColumns(mockQueryFilters)[3].width).toBe(150);
// Samples column
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[4].dataIndex,
).toBe(TreemapViewType.SAMPLES);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[4].width,
).toBe(150);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[4].sorter,
).toBe(true);
expect(getMetricsTableColumns(mockQueryFilters)[4].dataIndex).toBe(
TreemapViewType.SAMPLES,
);
expect(getMetricsTableColumns(mockQueryFilters)[4].width).toBe(150);
expect(getMetricsTableColumns(mockQueryFilters)[4].sorter).toBe(true);
// Time Series column
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[5].dataIndex,
).toBe(TreemapViewType.TIMESERIES);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[5].width,
).toBe(150);
expect(
getMetricsTableColumns(mockQueryExpression, mockOnChange)[5].sorter,
).toBe(true);
expect(getMetricsTableColumns(mockQueryFilters)[5].dataIndex).toBe(
TreemapViewType.TIMESERIES,
);
expect(getMetricsTableColumns(mockQueryFilters)[5].width).toBe(150);
expect(getMetricsTableColumns(mockQueryFilters)[5].sorter).toBe(true);
});
describe('MetricTypeRenderer', () => {
@@ -136,12 +111,12 @@ describe('formatDataForMetricsTable', () => {
it('should format metrics data correctly', () => {
const mockData = [
{
metricName: 'test_metric',
metric_name: 'test_metric',
description: 'Test description',
type: MetrictypesTypeDTO.gauge,
type: MetricType.GAUGE,
unit: 'bytes',
samples: 1000,
timeseries: 2000,
[TreemapViewType.SAMPLES]: 1000,
[TreemapViewType.TIMESERIES]: 2000,
lastReceived: '2023-01-01T00:00:00Z',
},
];
@@ -188,12 +163,12 @@ describe('formatDataForMetricsTable', () => {
it('should handle empty/null values', () => {
const mockData = [
{
metricName: '',
metric_name: '',
description: '',
type: MetrictypesTypeDTO.gauge,
type: MetricType.GAUGE,
unit: '',
samples: 0,
timeseries: 0,
[TreemapViewType.SAMPLES]: 0,
[TreemapViewType.TIMESERIES]: 0,
lastReceived: '2023-01-01T00:00:00Z',
},
];

View File

@@ -1,17 +1,15 @@
import {
MetricsexplorertypesTreemapModeDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { TreemapViewType } from './types';
export const METRICS_TABLE_PAGE_SIZE = 10;
export const TREEMAP_VIEW_OPTIONS: {
value: MetricsexplorertypesTreemapModeDTO;
value: TreemapViewType;
label: string;
}[] = [
{ value: MetricsexplorertypesTreemapModeDTO.timeseries, label: 'Time Series' },
{ value: MetricsexplorertypesTreemapModeDTO.samples, label: 'Samples' },
{ value: TreemapViewType.TIMESERIES, label: 'Time Series' },
{ value: TreemapViewType.SAMPLES, label: 'Samples' },
];
export const TREEMAP_HEIGHT = 200;
@@ -19,7 +17,6 @@ export const TREEMAP_SQUARE_PADDING = 5;
export const TREEMAP_MARGINS = { TOP: 10, LEFT: 10, RIGHT: 10, BOTTOM: 10 };
// TODO: Remove this once API migration is complete
export const METRIC_TYPE_LABEL_MAP = {
[MetricType.SUM]: 'Sum',
[MetricType.GAUGE]: 'Gauge',
@@ -28,14 +25,6 @@ export const METRIC_TYPE_LABEL_MAP = {
[MetricType.EXPONENTIAL_HISTOGRAM]: 'Exp. Histogram',
};
export const METRIC_TYPE_LABEL_MAP_V2 = {
[MetrictypesTypeDTO.sum]: 'Sum',
[MetrictypesTypeDTO.gauge]: 'Gauge',
[MetrictypesTypeDTO.histogram]: 'Histogram',
[MetrictypesTypeDTO.summary]: 'Summary',
[MetrictypesTypeDTO.exponentialhistogram]: 'Exp. Histogram',
};
export const METRIC_TYPE_VALUES_MAP = {
[MetricType.SUM]: 'Sum',
[MetricType.GAUGE]: 'Gauge',
@@ -47,3 +36,4 @@ export const METRIC_TYPE_VALUES_MAP = {
export const IS_METRIC_DETAILS_OPEN_KEY = 'isMetricDetailsOpen';
export const IS_INSPECT_MODAL_OPEN_KEY = 'isInspectModalOpen';
export const SELECTED_METRIC_NAME_KEY = 'selectedMetricName';
export const SUMMARY_FILTERS_KEY = 'summaryFilters';

View File

@@ -1,11 +1,9 @@
import React from 'react';
import { MetricsTreeMapResponse } from 'api/metricsExplorer/getMetricsTreeMap';
import {
MetricsexplorertypesTreemapModeDTO,
MetricsexplorertypesTreemapResponseDTO,
Querybuildertypesv5OrderByDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Filter } from 'api/v5/v5';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
IBuilderQuery,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
export interface MetricsTableProps {
isLoading: boolean;
@@ -14,25 +12,24 @@ export interface MetricsTableProps {
pageSize: number;
currentPage: number;
onPaginationChange: (page: number, pageSize: number) => void;
setOrderBy: (orderBy: Querybuildertypesv5OrderByDTO) => void;
setOrderBy: (orderBy: OrderByPayload) => void;
totalCount: number;
openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void;
queryFilterExpression: Filter;
onFilterChange: (expression: string) => void;
queryFilters: TagFilter;
}
export interface MetricsSearchProps {
query: IBuilderQuery;
onChange: (expression: string) => void;
onChange: (value: TagFilter) => void;
}
export interface MetricsTreemapProps {
data: MetricsexplorertypesTreemapResponseDTO | null | undefined;
data: MetricsTreeMapResponse | null | undefined;
isLoading: boolean;
isError: boolean;
viewType: MetricsexplorertypesTreemapModeDTO;
viewType: TreemapViewType;
openMetricDetails: (metricName: string, view: 'list' | 'treemap') => void;
setHeatmapView: (value: MetricsexplorertypesTreemapModeDTO) => void;
setHeatmapView: (value: TreemapViewType) => void;
}
export interface OrderByPayload {

View File

@@ -3,16 +3,14 @@ import { Color } from '@signozhq/design-tokens';
import { Tooltip, Typography } from 'antd';
import { ColumnType } from 'antd/es/table';
import {
MetricsexplorertypesStatDTO,
MetricsexplorertypesTreemapEntryDTO,
MetricsexplorertypesTreemapModeDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
MetricsListItemData,
MetricsListPayload,
MetricType,
} from 'api/metricsExplorer/getMetricsList';
import { Filter } from 'api/v5/v5';
import {
SamplesData,
TimeseriesData,
} from 'api/metricsExplorer/getMetricsTreeMap';
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
import {
BarChart,
@@ -21,23 +19,21 @@ import {
Diff,
Gauge,
} from 'lucide-react';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { METRIC_TYPE_LABEL_MAP, METRIC_TYPE_LABEL_MAP_V2 } from './constants';
import { METRIC_TYPE_LABEL_MAP } from './constants';
import MetricNameSearch from './MetricNameSearch';
import { MetricsListItemRowData, TreemapTile } from './types';
import MetricTypeSearch from './MetricTypeSearch';
import { MetricsListItemRowData, TreemapTile, TreemapViewType } from './types';
export const getMetricsTableColumns = (
queryFilterExpression: Filter,
onFilterChange: (expression: string) => void,
queryFilters: TagFilter,
): ColumnType<MetricsListItemRowData>[] => [
{
title: (
<div className="metric-name-column-header">
<span className="metric-name-column-header-text">METRIC</span>
<MetricNameSearch
queryFilterExpression={queryFilterExpression}
onFilterChange={onFilterChange}
/>
<MetricNameSearch queryFilters={queryFilters} />
</div>
),
dataIndex: 'metric_name',
@@ -59,11 +55,7 @@ export const getMetricsTableColumns = (
title: (
<div className="metric-type-column-header">
<span className="metric-type-column-header-text">TYPE</span>
{/* TODO: @amlannandy: Re-enable once API supports metric type filtering */}
{/* <MetricTypeSearch
queryFilters={queryFilters}
onFilterChange={onFilterChange}
/> */}
<MetricTypeSearch queryFilters={queryFilters} />
</div>
),
dataIndex: 'metric_type',
@@ -77,13 +69,13 @@ export const getMetricsTableColumns = (
},
{
title: 'SAMPLES',
dataIndex: MetricsexplorertypesTreemapModeDTO.samples,
dataIndex: TreemapViewType.SAMPLES,
width: 150,
sorter: true,
},
{
title: 'TIME SERIES',
dataIndex: MetricsexplorertypesTreemapModeDTO.timeseries,
dataIndex: TreemapViewType.TIMESERIES,
width: 150,
sorter: true,
},
@@ -151,60 +143,6 @@ export function MetricTypeRenderer({
);
}
export function MetricTypeRendererV2({
type,
}: {
type: MetrictypesTypeDTO;
}): JSX.Element {
const [icon, color] = useMemo(() => {
switch (type) {
case MetrictypesTypeDTO.sum:
return [
<Diff key={type} size={12} color={Color.BG_ROBIN_500} />,
Color.BG_ROBIN_500,
];
case MetrictypesTypeDTO.gauge:
return [
<Gauge key={type} size={12} color={Color.BG_SAKURA_500} />,
Color.BG_SAKURA_500,
];
case MetrictypesTypeDTO.histogram:
return [
<BarChart2 key={type} size={12} color={Color.BG_SIENNA_500} />,
Color.BG_SIENNA_500,
];
case MetrictypesTypeDTO.summary:
return [
<BarChartHorizontal key={type} size={12} color={Color.BG_FOREST_500} />,
Color.BG_FOREST_500,
];
case MetrictypesTypeDTO.exponentialhistogram:
return [
<BarChart key={type} size={12} color={Color.BG_AQUA_500} />,
Color.BG_AQUA_500,
];
default:
return [null, ''];
}
}, [type]);
return (
<div
className="metric-type-renderer"
style={{
backgroundColor: `${color}33`,
border: `1px solid ${color}`,
color,
}}
>
{icon}
<Typography.Text style={{ color, fontSize: 12 }}>
{METRIC_TYPE_LABEL_MAP_V2[type]}
</Typography.Text>
</div>
);
}
function ValidateRowValueWrapper({
value,
children,
@@ -244,13 +182,13 @@ export const formatNumberIntoHumanReadableFormat = (
};
export const formatDataForMetricsTable = (
data: MetricsexplorertypesStatDTO[],
data: MetricsListItemData[],
): MetricsListItemRowData[] =>
data.map((metric) => ({
key: metric.metricName,
key: metric.metric_name,
metric_name: (
<ValidateRowValueWrapper value={metric.metricName}>
<Tooltip title={metric.metricName}>{metric.metricName}</Tooltip>
<ValidateRowValueWrapper value={metric.metric_name}>
<Tooltip title={metric.metric_name}>{metric.metric_name}</Tooltip>
</ValidateRowValueWrapper>
),
description: (
@@ -260,54 +198,39 @@ export const formatDataForMetricsTable = (
</Tooltip>
</ValidateRowValueWrapper>
),
metric_type: <MetricTypeRendererV2 type={metric.type} />,
metric_type: <MetricTypeRenderer type={metric.type} />,
unit: (
<ValidateRowValueWrapper value={getUniversalNameFromMetricUnit(metric.unit)}>
{getUniversalNameFromMetricUnit(metric.unit)}
</ValidateRowValueWrapper>
),
[MetricsexplorertypesTreemapModeDTO.samples]: (
<ValidateRowValueWrapper
value={metric[MetricsexplorertypesTreemapModeDTO.samples]}
>
<Tooltip
title={metric[MetricsexplorertypesTreemapModeDTO.samples].toLocaleString()}
>
{formatNumberIntoHumanReadableFormat(
metric[MetricsexplorertypesTreemapModeDTO.samples],
)}
[TreemapViewType.SAMPLES]: (
<ValidateRowValueWrapper value={metric[TreemapViewType.SAMPLES]}>
<Tooltip title={metric[TreemapViewType.SAMPLES].toLocaleString()}>
{formatNumberIntoHumanReadableFormat(metric[TreemapViewType.SAMPLES])}
</Tooltip>
</ValidateRowValueWrapper>
),
[MetricsexplorertypesTreemapModeDTO.timeseries]: (
<ValidateRowValueWrapper
value={metric[MetricsexplorertypesTreemapModeDTO.timeseries]}
>
<Tooltip
title={metric[
MetricsexplorertypesTreemapModeDTO.timeseries
].toLocaleString()}
>
{formatNumberIntoHumanReadableFormat(
metric[MetricsexplorertypesTreemapModeDTO.timeseries],
)}
[TreemapViewType.TIMESERIES]: (
<ValidateRowValueWrapper value={metric[TreemapViewType.TIMESERIES]}>
<Tooltip title={metric[TreemapViewType.TIMESERIES].toLocaleString()}>
{formatNumberIntoHumanReadableFormat(metric[TreemapViewType.TIMESERIES])}
</Tooltip>
</ValidateRowValueWrapper>
),
}));
export const transformTreemapData = (
data: MetricsexplorertypesTreemapEntryDTO[],
viewType: MetricsexplorertypesTreemapModeDTO,
data: TimeseriesData[] | SamplesData[],
viewType: TreemapViewType,
): TreemapTile[] => {
const totalSize = data.reduce(
(acc: number, item: MetricsexplorertypesTreemapEntryDTO) =>
acc + item.percentage,
const totalSize = (data as (TimeseriesData | SamplesData)[]).reduce(
(acc: number, item: TimeseriesData | SamplesData) => acc + item.percentage,
0,
);
const children = data.map((item) => ({
id: item.metricName,
id: item.metric_name,
size: totalSize > 0 ? Number((item.percentage / totalSize).toFixed(2)) : 0,
displayValue: Number(item.percentage).toFixed(2),
parent: viewType,

View File

@@ -7,6 +7,12 @@
display: flex;
align-items: center;
justify-content: space-between;
.auth-domain-title {
margin: 0;
font-size: 20px;
font-weight: 600;
}
}
}
@@ -15,5 +21,29 @@
display: flex;
flex-direction: row;
gap: 24px;
.auth-domain-list-action-link {
cursor: pointer;
color: var(--bg-robin-500);
transition: color 0.3s;
border: none;
background: none;
padding: 0;
font-size: inherit;
&:hover {
opacity: 0.8;
text-decoration: underline;
}
&.delete {
color: var(--bg-cherry-500);
}
}
}
.auth-domain-list-na {
padding-left: 6px;
color: var(--text-secondary);
}
}

View File

@@ -1,28 +1,37 @@
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,
AuthtypesRoleMappingDTO,
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 {
convertDomainMappingsToRecord,
convertGroupMappingsToRecord,
FormValues,
prepareInitialValues,
} from './CreateEdit.utils';
import ConfigureGoogleAuthAuthnProvider from './Providers/AuthnGoogleAuth';
import ConfigureOIDCAuthnProvider from './Providers/AuthnOIDC';
import ConfigureSAMLAuthnProvider from './Providers/AuthnSAML';
import './CreateEdit.styles.scss';
interface CreateOrEditProps {
isCreate: boolean;
onClose: () => void;
record?: GettableAuthDomain;
}
function configureAuthnProvider(
authnProvider: string,
isCreate: boolean,
@@ -39,64 +48,193 @@ function configureAuthnProvider(
}
}
interface CreateOrEditProps {
isCreate: boolean;
onClose: () => void;
record?: AuthtypesGettableAuthDomainDTO;
}
function CreateOrEdit(props: CreateOrEditProps): JSX.Element {
const { isCreate, record, onClose } = props;
const [form] = Form.useForm<PostableAuthDomain>();
const [form] = Form.useForm<FormValues>();
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;
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 +254,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>

View File

@@ -0,0 +1,134 @@
import {
AuthtypesGettableAuthDomainDTO,
AuthtypesGoogleConfigDTO,
AuthtypesOIDCConfigDTO,
AuthtypesRoleMappingDTO,
AuthtypesSamlConfigDTO,
} from 'api/generated/services/sigNoz.schemas';
// Form values interface for internal use (includes array-based fields for UI)
export 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 }>;
};
}
/**
* Converts groupMappingsList array to groupMappings Record for API
*/
export 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
*/
export 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
*/
export 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
*/
export 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
*/
export 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,
};
}

View File

@@ -1,20 +1,67 @@
import { useCallback, useState } from 'react';
import { Callout } from '@signozhq/callout';
import { Form, Input, Typography } from 'antd';
import { Checkbox } from '@signozhq/checkbox';
import { Color } from '@signozhq/design-tokens';
import {
ChevronDown,
ChevronRight,
CircleHelp,
TriangleAlert,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Collapse, Form, Tooltip } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
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);
}, []);
const {
hasErrors: hasWorkspaceGroupsErrors,
errorMessages: workspaceGroupsErrorMessages,
} = useCollapseSectionErrors(
['googleAuthConfig'],
[
['googleAuthConfig', 'fetchGroups'],
['googleAuthConfig', 'serviceAccountJson'],
['googleAuthConfig', 'domainToAdminEmailList'],
['googleAuthConfig', 'fetchTransitiveGroupMembership'],
['googleAuthConfig', 'allowedGroups'],
],
);
return (
<div className="google-auth">
<section className="header">
<Typography.Text className="title">
Edit Google Authentication
</Typography.Text>
<Typography.Paragraph className="description">
<div className="authn-provider">
<section className="authn-provider__header">
<h3 className="authn-provider__title">Edit Google Authentication</h3>
<p className="authn-provider__description">
Enter OAuth 2.0 credentials obtained from the Google API Console below.
Read the{' '}
<a
@@ -25,50 +72,247 @@ 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="authn-provider__columns">
{/* Left Column - Core OAuth Settings */}
<div className="authn-provider__left">
<div className="authn-provider__field-group">
<label className="authn-provider__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="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name="name"
className="authn-provider__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="authn-provider__field-group">
<label className="authn-provider__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="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['googleAuthConfig', 'clientId']}
className="authn-provider__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="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="google-client-secret">
Client Secret
<Tooltip title="It is the application's secret.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['googleAuthConfig', 'clientSecret']}
className="authn-provider__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 wont be enabled unless you enter all the attributes above"
className="callout"
/>
<div className="authn-provider__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="authn-provider__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="authn-provider__right">
<Collapse
bordered={false}
activeKey={
expandedSection === 'workspace-groups' ? ['workspace-groups'] : []
}
onChange={handleWorkspaceGroupsChange}
className="authn-provider__collapse"
expandIcon={(): null => null}
>
<Collapse.Panel
key="workspace-groups"
header={
<div className="authn-provider__collapse-header">
{expandedSection !== 'workspace-groups' ? (
<ChevronRight size={16} />
) : (
<ChevronDown size={16} />
)}
<div className="authn-provider__collapse-header-text">
<h4 className="authn-provider__section-title">
Google Workspace Groups (Advanced)
</h4>
<p className="authn-provider__section-description">
Enable group fetching to retrieve user groups from Google Workspace.
Requires a Service Account with domain-wide delegation.
</p>
</div>
{expandedSection !== 'workspace-groups' && hasWorkspaceGroupsErrors && (
<Tooltip
title={
<div>
{workspaceGroupsErrorMessages.map((msg) => (
<div key={msg}>{msg}</div>
))}
</div>
}
>
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
</Tooltip>
)}
</div>
}
>
<div className="authn-provider__group-content">
<div className="authn-provider__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="authn-provider__label-icon" />
</Tooltip>
</div>
{fetchGroups && (
<div className="authn-provider__group-fields">
<div className="authn-provider__field-group">
<label
className="authn-provider__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="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['googleAuthConfig', 'serviceAccountJson']}
className="authn-provider__form-item"
>
<TextArea
id="google-service-account-json"
rows={3}
placeholder="Paste service account JSON"
className="authn-provider__textarea"
/>
</Form.Item>
</div>
<DomainMappingList
fieldNamePrefix={['googleAuthConfig', 'domainToAdminEmailList']}
/>
<div className="authn-provider__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="authn-provider__label-icon" />
</Tooltip>
</div>
<div className="authn-provider__field-group">
<label
className="authn-provider__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="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['googleAuthConfig', 'allowedGroups']}
className="authn-provider__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>
);
}

View File

@@ -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 { CircleHelp } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Form, Tooltip } from 'antd';
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="authn-provider">
<section className="authn-provider__header">
<h3 className="authn-provider__title">Edit OIDC Authentication</h3>
<p className="authn-provider__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="authn-provider__columns">
{/* Left Column - Core OIDC Settings */}
<div className="authn-provider__left">
<div className="authn-provider__field-group">
<label className="authn-provider__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="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name="name"
className="authn-provider__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="authn-provider__field-group">
<label className="authn-provider__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="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['oidcConfig', 'issuer']}
className="authn-provider__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="authn-provider__field-group">
<label className="authn-provider__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="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['oidcConfig', 'issuerAlias']}
className="authn-provider__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="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="oidc-client-id">
Client ID
<Tooltip title="The application's client ID from your OIDC provider.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['oidcConfig', 'clientId']}
className="authn-provider__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="authn-provider__field-group">
<label className="authn-provider__label" htmlFor="oidc-client-secret">
Client Secret
<Tooltip title="The application's client secret from your OIDC provider.">
<CircleHelp size={14} className="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['oidcConfig', 'clientSecret']}
className="authn-provider__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="authn-provider__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="authn-provider__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="authn-provider__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="authn-provider__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 wont be enabled unless you enter all the attributes above"
className="callout"
/>
{/* Right Column - Advanced Settings */}
<div className="authn-provider__right">
<ClaimMappingSection
fieldNamePrefix={['oidcConfig', 'claimMapping']}
isExpanded={expandedSection === 'claim-mapping'}
onExpandChange={handleClaimMappingChange}
/>
<RoleMappingSection
fieldNamePrefix={['roleMapping']}
isExpanded={expandedSection === 'role-mapping'}
onExpandChange={handleRoleMappingChange}
/>
</div>
</div>
</div>
);
}

View File

@@ -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 { CircleHelp } from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Form, Tooltip } from 'antd';
import TextArea from 'antd/lib/input/TextArea';
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="authn-provider">
<section className="authn-provider__header">
<h3 className="authn-provider__title">Edit SAML Authentication</h3>
<p className="authn-provider__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="authn-provider__columns">
{/* Left Column - Core SAML Settings */}
<div className="authn-provider__left">
<div className="authn-provider__field-group">
<label className="authn-provider__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="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name="name"
className="authn-provider__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="authn-provider__field-group">
<label className="authn-provider__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="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'samlIdp']}
className="authn-provider__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="authn-provider__field-group">
<label className="authn-provider__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="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'samlEntity']}
className="authn-provider__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="authn-provider__field-group">
<label className="authn-provider__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="authn-provider__label-icon" />
</Tooltip>
</label>
<Form.Item
name={['samlConfig', 'samlCert']}
className="authn-provider__form-item"
rules={[
{
required: true,
message: 'SAML Certificate is required',
whitespace: true,
},
]}
>
<TextArea
id="saml-certificate"
rows={3}
placeholder="Paste X.509 certificate"
className="authn-provider__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="authn-provider__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="authn-provider__label-icon" />
</Tooltip>
</div>
<Callout
type="warning"
size="small"
showIcon
description="SAML wont 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="authn-provider__right">
<AttributeMappingSection
fieldNamePrefix={['samlConfig', 'attributeMapping']}
isExpanded={expandedSection === 'attribute-mapping'}
onExpandChange={handleAttributeMappingChange}
/>
<RoleMappingSection
fieldNamePrefix={['roleMapping']}
isExpanded={expandedSection === 'role-mapping'}
onExpandChange={handleRoleMappingChange}
/>
</div>
</div>
</div>
);
}

View File

@@ -1,24 +1,246 @@
.google-auth {
.authn-provider {
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;
position: relative;
width: 100%;
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;
.authn-provider__field-group,
.authn-provider__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 {

View File

@@ -0,0 +1,134 @@
.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;
position: relative;
width: 100%;
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;
}
// todo: https://github.com/SigNoz/components/issues/116
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;
}
}
}

View File

@@ -0,0 +1,190 @@
import { useCallback, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import {
ChevronDown,
ChevronRight,
CircleHelp,
TriangleAlert,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Collapse, Form, Tooltip } from 'antd';
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
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'] : [];
const { hasErrors, errorMessages } = useCollapseSectionErrors(fieldNamePrefix);
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"
role="button"
aria-expanded={expanded}
aria-controls="attribute-mapping-content"
>
{!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.
</p>
</div>
{!expanded && hasErrors && (
<Tooltip
title={
<div>
{errorMessages.map((msg) => (
<div key={msg}>{msg}</div>
))}
</div>
}
>
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
</Tooltip>
)}
</div>
}
>
<div
id="attribute-mapping-content"
className="attribute-mapping-section__content"
>
<div className="attribute-mapping-section__field-group">
<label
className="attribute-mapping-section__label"
htmlFor="email-attribute"
>
Email Attribute
<Tooltip title="The SAML attribute key that contains the user's email. Default: 'email'">
<CircleHelp
size={14}
className="attribute-mapping-section__label-icon"
/>
</Tooltip>
</label>
<Form.Item
name={[...fieldNamePrefix, 'email']}
className="attribute-mapping-section__form-item"
>
<Input id="email-attribute" placeholder="Email" />
</Form.Item>
</div>
{/* 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;

View File

@@ -0,0 +1,134 @@
.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;
position: relative;
width: 100%;
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;
}
// todo: https://github.com/SigNoz/components/issues/116
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;
}
}
}

View File

@@ -0,0 +1,164 @@
import { useCallback, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import {
ChevronDown,
ChevronRight,
CircleHelp,
TriangleAlert,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Collapse, Form, Tooltip } from 'antd';
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
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'] : [];
const { hasErrors, errorMessages } = useCollapseSectionErrors(fieldNamePrefix);
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"
role="button"
aria-expanded={expanded}
aria-controls="claim-mapping-content"
>
{!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>
{!expanded && hasErrors && (
<Tooltip
title={
<div>
{errorMessages.map((msg) => (
<div key={msg}>{msg}</div>
))}
</div>
}
>
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
</Tooltip>
)}
</div>
}
>
<div id="claim-mapping-content" 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;

View File

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

View File

@@ -0,0 +1,88 @@
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@]+$/;
const validateEmail = (_: 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();
};
interface DomainMappingListProps {
/** The form field name prefix for the domain mapping list */
fieldNamePrefix: string[];
}
function DomainMappingList({
fieldNamePrefix,
}: DomainMappingListProps): JSX.Element {
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 &quot;*&quot; 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;

View File

@@ -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);
}
}

View File

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

View File

@@ -0,0 +1,294 @@
.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;
position: relative;
width: 100%;
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;
}
}
// todo: https://github.com/SigNoz/components/issues/116
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;
}
}
}

View File

@@ -0,0 +1,222 @@
import { useCallback, useState } from 'react';
import { Button } from '@signozhq/button';
import { Checkbox } from '@signozhq/checkbox';
import { Color } from '@signozhq/design-tokens';
import {
ChevronDown,
ChevronRight,
CircleHelp,
Plus,
Trash2,
TriangleAlert,
} from '@signozhq/icons';
import { Input } from '@signozhq/input';
import { Collapse, Form, Select, Tooltip } from 'antd';
import { useCollapseSectionErrors } from 'hooks/useCollapseSectionErrors';
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'] : [];
const { hasErrors, errorMessages } = useCollapseSectionErrors(fieldNamePrefix);
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"
role="button"
aria-expanded={expanded}
aria-controls="role-mapping-content"
>
{!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>
{!expanded && hasErrors && (
<Tooltip
title={
<div>
{errorMessages.map((msg) => (
<div key={msg}>{msg}</div>
))}
</div>
}
>
<TriangleAlert size={16} color={Color.BG_CHERRY_500} />
</Tooltip>
)}
</div>
}
>
<div id="role-mapping-content" 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;

View File

@@ -1,45 +1,74 @@
import { useState } from 'react';
import { Switch } from 'antd';
import put from 'api/v1/domains/id/put';
import { useEffect, useState } from 'react';
import { Switch } from '@signozhq/switch';
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';
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);
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);
}
setIsLoading(false);
};
return (
<Switch loading={isLoading} checked={isChecked} onChange={onChangeHandler} />
);
}
interface ToggleProps {
isDefaultChecked: boolean;
record: GettableAuthDomain;
record: AuthtypesGettableAuthDomainDTO;
}
function Toggle({ isDefaultChecked, record }: ToggleProps): JSX.Element {
const [isChecked, setIsChecked] = useState<boolean>(isDefaultChecked);
const { showErrorModal } = useErrorModal();
useEffect(() => {
setIsChecked(isDefaultChecked);
}, [isDefaultChecked]);
const { mutate: updateAuthDomain, isLoading } = useUpdateAuthDomain<
AxiosError<RenderErrorResponseDTO>
>();
const onChangeHandler = (checked: boolean): void => {
if (!record.id) {
return;
}
setIsChecked(checked);
updateAuthDomain(
{
pathParams: { id: record.id },
data: {
config: {
ssoEnabled: checked,
ssoType: record.ssoType,
googleAuthConfig: record.googleAuthConfig,
oidcConfig: record.oidcConfig,
samlConfig: record.samlConfig,
roleMapping: record.roleMapping,
},
},
},
{
onError: (error) => {
setIsChecked(!checked);
try {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
} catch (apiError) {
showErrorModal(apiError as APIError);
}
},
},
);
};
return (
<Switch
disabled={isLoading}
checked={isChecked}
onCheckedChange={onChangeHandler}
/>
);
}
export default Toggle;

View File

@@ -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();
});
});
});
});

View File

@@ -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();
});
});
});

View File

@@ -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);
});
});

View File

@@ -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: '',
},
};

View File

@@ -1,151 +1,214 @@
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 { Trash2, X } from '@signozhq/icons';
import { Modal, Typography } from 'antd';
import { Table } 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';
import '../../IngestionSettings/IngestionSettings.styles.scss';
const columns: ColumnsType<GettableAuthDomain> = [
{
title: 'Domain',
dataIndex: 'name',
key: 'name',
width: 100,
render: (val): JSX.Element => <Typography.Text>{val}</Typography.Text>,
},
{
title: 'Enforce SSO',
dataIndex: 'ssoEnabled',
key: 'ssoEnabled',
width: 80,
render: (value: boolean, record: GettableAuthDomain): 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;
if (!relayPath) {
return (
<Typography.Text style={{ paddingLeft: '6px' }}>N/A</Typography.Text>
);
}
const href = `${window.location.origin}/${relayPath}`;
return <CopyToClipboard textToCopy={href} />;
},
},
{
title: 'Action',
dataIndex: 'action',
key: 'action',
width: 100,
render: (_, record: GettableAuthDomain): JSX.Element => (
<section className="auth-domain-list-column-action">
<Typography.Link data-column-action="configure">
Configure {SSOType.get(record.ssoType)}
</Typography.Link>
<Typography.Link type="danger" data-column-action="delete">
Delete
</Typography.Link>
</section>
),
},
];
async function deleteDomainById(
id: string,
showErrorModal: (error: APIError) => void,
refetchAuthDomainListResponse: () => void,
): Promise<void> {
try {
await deleteDomain(id);
refetchAuthDomainListResponse();
} catch (error) {
showErrorModal(error as APIError);
}
}
export const SSOType = new Map<string, string>([
['google_auth', 'Google Auth'],
['saml', 'SAML'],
['email_password', 'Email Password'],
['oidc', 'OIDC'],
]);
function AuthDomain(): JSX.Element {
const [record, setRecord] = useState<GettableAuthDomain>();
const [record, setRecord] = useState<AuthtypesGettableAuthDomainDTO>();
const [addDomain, setAddDomain] = useState<boolean>(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [
activeDomain,
setActiveDomain,
] = useState<AuthtypesGettableAuthDomainDTO | null>(null);
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, isLoading } = useDeleteAuthDomain<
AxiosError<RenderErrorResponseDTO>
>();
const showDeleteModal = useCallback(
(domain: AuthtypesGettableAuthDomainDTO): void => {
setActiveDomain(domain);
setIsDeleteModalOpen(true);
},
[],
);
const hideDeleteModal = useCallback((): void => {
setIsDeleteModalOpen(false);
setActiveDomain(null);
}, []);
const handleDeleteDomain = useCallback((): void => {
if (!activeDomain?.id) {
return;
}
deleteAuthDomain(
{ pathParams: { id: activeDomain.id } },
{
onSuccess: () => {
notifications.success({
message: 'Domain deleted successfully',
});
refetchAuthDomainListResponse();
hideDeleteModal();
},
onError: (error) => {
try {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
} catch (apiError) {
showErrorModal(apiError as APIError);
}
},
},
);
}, [
activeDomain,
deleteAuthDomain,
hideDeleteModal,
notifications,
refetchAuthDomainListResponse,
showErrorModal,
]);
const formattedError = useMemo(() => {
if (!errorFetchingAuthDomainListResponse) {
return null;
}
let errorResult: APIError | null = null;
try {
ErrorResponseHandlerV2(
errorFetchingAuthDomainListResponse as AxiosError<ErrorV2Resp>,
);
} catch (error) {
errorResult = error as APIError;
}
return errorResult;
}, [errorFetchingAuthDomainListResponse]);
const columns: ColumnsType<AuthtypesGettableAuthDomainDTO> = useMemo(
() => [
{
title: 'Domain',
dataIndex: 'name',
key: 'name',
width: 100,
render: (val): JSX.Element => <span>{val}</span>,
},
{
title: 'Enforce SSO',
dataIndex: 'ssoEnabled',
key: 'ssoEnabled',
width: 80,
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: AuthtypesGettableAuthDomainDTO): JSX.Element => {
const relayPath = record.authNProviderInfo?.relayStatePath;
if (!relayPath) {
return <span className="auth-domain-list-na">N/A</span>;
}
const href = `${window.location.origin}/${relayPath}`;
return <CopyToClipboard textToCopy={href} />;
},
},
{
title: 'Action',
dataIndex: 'action',
key: 'action',
width: 100,
render: (_, record: AuthtypesGettableAuthDomainDTO): JSX.Element => (
<section className="auth-domain-list-column-action">
<Button
className="auth-domain-list-action-link"
onClick={(): void => setRecord(record)}
variant="link"
>
Configure {SSOType.get(record.ssoType || '')}
</Button>
<Button
className="auth-domain-list-action-link delete"
onClick={(): void => showDeleteModal(record)}
variant="link"
>
Delete
</Button>
</section>
),
},
],
[showDeleteModal],
);
return (
<div className="auth-domain">
<section className="auth-domain-header">
<Typography.Title level={3}>Authenticated Domains</Typography.Title>
<h3 className="auth-domain-title">Authenticated Domains</h3>
<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 => ({
onClick: (
event: React.SyntheticEvent<HTMLLinkElement, MouseEvent>,
): void => {
const target = event.target as HTMLLinkElement;
const { columnAction } = target.dataset;
switch (columnAction) {
case 'configure':
setRecord(record);
break;
case 'delete':
deleteDomainById(
record.id,
showErrorModal,
refetchAuthDomainListResponse,
);
break;
default:
console.error('Unknown action:', columnAction);
}
},
})}
dataSource={authDomainListResponse?.data?.data}
onRow={undefined}
loading={
isLoadingAuthDomainListResponse || isFetchingAuthDomainListResponse
}
className="auth-domain-list"
rowKey="id"
/>
)}
{(addDomain || record) && (
@@ -159,6 +222,39 @@ function AuthDomain(): JSX.Element {
}}
/>
)}
<Modal
className="delete-ingestion-key-modal"
title={<span className="title">Delete Domain</span>}
open={isDeleteModalOpen}
closable
onCancel={hideDeleteModal}
destroyOnClose
footer={[
<Button
key="cancel"
onClick={hideDeleteModal}
className="cancel-btn"
prefixIcon={<X size={16} />}
>
Cancel
</Button>,
<Button
key="submit"
prefixIcon={<Trash2 size={16} />}
onClick={handleDeleteDomain}
className="delete-btn"
loading={isLoading}
>
Delete Domain
</Button>,
]}
>
<Typography.Text className="delete-text">
Are you sure you want to delete the domain{' '}
<strong>{activeDomain?.name}</strong>? This action cannot be undone.
</Typography.Text>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { useCallback, useState } from 'react';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import type { UseActiveLog } from 'hooks/logs/types';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { ILog } from 'types/api/logs/log';
type SelectedTab = typeof VIEW_TYPES[keyof typeof VIEW_TYPES] | undefined;
type UseLogDetailHandlersParams = {
defaultTab?: SelectedTab;
};
type UseLogDetailHandlersResult = {
activeLog: UseActiveLog['activeLog'];
onAddToQuery: UseActiveLog['onAddToQuery'];
selectedTab: SelectedTab;
handleSetActiveLog: (log: ILog, selectedTab?: SelectedTab) => void;
handleCloseLogDetail: () => void;
};
function useLogDetailHandlers({
defaultTab = VIEW_TYPES.OVERVIEW,
}: UseLogDetailHandlersParams = {}): UseLogDetailHandlersResult {
const {
activeLog,
onSetActiveLog,
onClearActiveLog,
onAddToQuery,
} = useActiveLog();
const [selectedTab, setSelectedTab] = useState<SelectedTab>(defaultTab);
const handleSetActiveLog = useCallback(
(log: ILog, nextTab: SelectedTab = defaultTab): void => {
if (activeLog?.id === log.id) {
onClearActiveLog();
setSelectedTab(undefined);
return;
}
onSetActiveLog(log);
setSelectedTab(nextTab ?? defaultTab);
},
[activeLog?.id, defaultTab, onClearActiveLog, onSetActiveLog],
);
const handleCloseLogDetail = useCallback((): void => {
onClearActiveLog();
setSelectedTab(undefined);
}, [onClearActiveLog]);
return {
activeLog,
onAddToQuery,
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
};
}
export default useLogDetailHandlers;

View File

@@ -0,0 +1,28 @@
import { useCallback } from 'react';
import type { VirtuosoHandle } from 'react-virtuoso';
type UseScrollToLogParams = {
logs: Array<{ id: string }>;
virtuosoRef: React.RefObject<VirtuosoHandle | null>;
};
function useScrollToLog({
logs,
virtuosoRef,
}: UseScrollToLogParams): (logId: string) => void {
return useCallback(
(logId: string): void => {
const logIndex = logs.findIndex(({ id }) => id === logId);
if (logIndex !== -1 && virtuosoRef.current) {
virtuosoRef.current.scrollToIndex({
index: logIndex,
align: 'center',
behavior: 'smooth',
});
}
},
[logs, virtuosoRef],
);
}
export default useScrollToLog;

View File

@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import { Form } from 'antd';
export interface CollapseSectionErrors {
hasErrors: boolean;
errorMessages: string[];
}
/**
* Detects validation errors in a form section
* @param fieldNamePrefix - Field path prefix for the section
* @param specificFields - Optional specific field prefixes to check (uses prefix matching)
*/
export function useCollapseSectionErrors(
fieldNamePrefix: string[],
specificFields?: string[][],
): CollapseSectionErrors {
const form = Form.useFormInstance();
// Refer: https://github.com/SigNoz/signoz/pull/10276#discussion_r2819372174
Form.useWatch([], form);
// eslint-disable-next-line sonarjs/cognitive-complexity
return useMemo(() => {
const fieldErrors = form.getFieldsError();
const messages: string[] = [];
if (specificFields?.length) {
fieldErrors.forEach((field) => {
const fieldPath = Array.isArray(field.name) ? field.name : [field.name];
const isMatch = specificFields.some((specificField) => {
if (fieldPath.length < specificField.length) {
return false;
}
return specificField.every((part, idx) => fieldPath[idx] === part);
});
if (isMatch && field.errors.length > 0) {
field.errors.forEach((error) => messages.push(error));
}
});
} else {
const prefixPath = fieldNamePrefix.join('.');
fieldErrors.forEach((field) => {
const fieldPath = Array.isArray(field.name)
? field.name.join('.')
: String(field.name);
if (fieldPath.startsWith(prefixPath) && field.errors.length > 0) {
field.errors.forEach((error) => messages.push(error));
}
});
}
return {
hasErrors: messages.length > 0,
errorMessages: messages,
};
}, [form, fieldNamePrefix, specificFields]);
}

View File

@@ -567,6 +567,15 @@ body {
border: 1px solid var(--bg-vanilla-300);
}
.ant-tooltip {
--antd-arrow-background-color: var(--bg-vanilla-100);
.ant-tooltip-inner {
background-color: var(--bg-vanilla-100);
color: var(---bg-ink-500);
}
}
.ant-dropdown-menu {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);

View File

@@ -9,10 +9,9 @@ export const getDefaultLogBackground = (
if (isReadOnly) {
return '';
}
// TODO handle the light mode here
return `&:hover {
background-color: ${
isDarkMode ? 'rgba(171, 189, 255, 0.04)' : 'var(--bg-vanilla-200)'
isDarkMode ? 'rgba(171, 189, 255, 0.04)' : 'rgba(0, 0, 0, 0.04)'
};
}`;
};
@@ -28,22 +27,38 @@ export const getActiveLogBackground = (
if (isDarkMode) {
switch (logType) {
case LogType.INFO:
return `background-color: ${Color.BG_ROBIN_500}10 !important;`;
return `background-color: ${Color.BG_ROBIN_500}40 !important;`;
case LogType.WARN:
return `background-color: ${Color.BG_AMBER_500}10 !important;`;
return `background-color: ${Color.BG_AMBER_500}40 !important;`;
case LogType.ERROR:
return `background-color: ${Color.BG_CHERRY_500}10 !important;`;
return `background-color: ${Color.BG_CHERRY_500}40 !important;`;
case LogType.TRACE:
return `background-color: ${Color.BG_FOREST_400}10 !important;`;
return `background-color: ${Color.BG_FOREST_400}40 !important;`;
case LogType.DEBUG:
return `background-color: ${Color.BG_AQUA_500}10 !important;`;
return `background-color: ${Color.BG_AQUA_500}40 !important;`;
case LogType.FATAL:
return `background-color: ${Color.BG_SAKURA_500}10 !important;`;
return `background-color: ${Color.BG_SAKURA_500}40 !important;`;
default:
return `background-color: ${Color.BG_SLATE_200} !important;`;
return `background-color: ${Color.BG_ROBIN_500}40 !important;`;
}
}
return `background-color: ${Color.BG_VANILLA_400}!important; color: ${Color.TEXT_SLATE_400} !important;`;
// Light mode - use lighter background colors
switch (logType) {
case LogType.INFO:
return `background-color: ${Color.BG_ROBIN_100} !important;`;
case LogType.WARN:
return `background-color: ${Color.BG_AMBER_100} !important;`;
case LogType.ERROR:
return `background-color: ${Color.BG_CHERRY_100} !important;`;
case LogType.TRACE:
return `background-color: ${Color.BG_FOREST_200} !important;`;
case LogType.DEBUG:
return `background-color: ${Color.BG_AQUA_100} !important;`;
case LogType.FATAL:
return `background-color: ${Color.BG_SAKURA_100} !important;`;
default:
return `background-color: ${Color.BG_VANILLA_300} !important;`;
}
};
export const getHightLightedLogBackground = (

View File

@@ -4479,6 +4479,19 @@
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-switch@^1.1.4":
version "1.2.6"
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.2.6.tgz#ff79acb831f0d5ea9216cfcc5b939912571358e3"
integrity sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-use-controllable-state" "1.2.2"
"@radix-ui/react-use-previous" "1.1.1"
"@radix-ui/react-use-size" "1.1.1"
"@radix-ui/react-tabs@1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz#993608eec55a5d1deddd446fa9978d2bc1053da2"
@@ -5164,6 +5177,20 @@
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/switch@0.0.2":
version "0.0.2"
resolved "https://registry.yarnpkg.com/@signozhq/switch/-/switch-0.0.2.tgz#58003ce9c0cd1f2ad8266a7045182607ce51df76"
integrity sha512-3B3Y5dzIyepO6EQJ7agx97bPmwg1dcOY46q2lqviHnMxNk3Sv079nSNCaztjQlo0VR0qu2JgVXhWi5Lw9WBN8A==
dependencies:
"@radix-ui/react-icons" "^1.3.0"
"@radix-ui/react-slot" "^1.1.0"
"@radix-ui/react-switch" "^1.1.4"
class-variance-authority "^0.7.0"
clsx "^2.1.1"
lucide-react "^0.445.0"
tailwind-merge "^2.5.2"
tailwindcss-animate "^1.0.7"
"@signozhq/table@0.3.7":
version "0.3.7"
resolved "https://registry.yarnpkg.com/@signozhq/table/-/table-0.3.7.tgz#895b710c02af124dfb5117e02bbc6d80ce062063"