mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-04 21:03:21 +00:00
Compare commits
5 Commits
nv/8189
...
SIG-1783-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a08170bb09 | ||
|
|
4ff2d44188 | ||
|
|
e8e85a3790 | ||
|
|
20d9d5ae38 | ||
|
|
9a046ab89d |
@@ -42,6 +42,10 @@ const config: Config.InitialOptions = {
|
||||
'<rootDir>/node_modules/@signozhq/command/dist/command.js',
|
||||
'^@signozhq/radio-group':
|
||||
'<rootDir>/node_modules/@signozhq/radio-group/dist/radio-group.js',
|
||||
'^@signozhq/toggle-group$':
|
||||
'<rootDir>/node_modules/@signozhq/toggle-group/dist/toggle-group.js',
|
||||
'^@signozhq/dialog$':
|
||||
'<rootDir>/node_modules/@signozhq/dialog/dist/dialog.js',
|
||||
},
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
testMatch: ['<rootDir>/src/**/*?(*.)(test).(ts|js)?(x)'],
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"@signozhq/combobox": "0.0.2",
|
||||
"@signozhq/command": "0.0.0",
|
||||
"@signozhq/design-tokens": "2.1.1",
|
||||
"@signozhq/dialog": "^0.0.2",
|
||||
"@signozhq/icons": "0.1.0",
|
||||
"@signozhq/input": "0.0.2",
|
||||
"@signozhq/popover": "0.0.0",
|
||||
@@ -62,6 +63,7 @@
|
||||
"@signozhq/sonner": "0.1.0",
|
||||
"@signozhq/switch": "0.0.2",
|
||||
"@signozhq/table": "0.3.7",
|
||||
"@signozhq/toggle-group": "^0.0.1",
|
||||
"@signozhq/tooltip": "0.0.2",
|
||||
"@tanstack/react-table": "8.20.6",
|
||||
"@tanstack/react-virtual": "3.11.2",
|
||||
|
||||
@@ -13,5 +13,6 @@
|
||||
"pipelines": "Pipelines",
|
||||
"archives": "Archives",
|
||||
"logs_to_metrics": "Logs To Metrics",
|
||||
"roles": "Roles"
|
||||
"roles": "Roles",
|
||||
"role_details": "Role Details"
|
||||
}
|
||||
|
||||
@@ -13,5 +13,6 @@
|
||||
"pipelines": "Pipelines",
|
||||
"archives": "Archives",
|
||||
"logs_to_metrics": "Logs To Metrics",
|
||||
"roles": "Roles"
|
||||
"roles": "Roles",
|
||||
"role_details": "Role Details"
|
||||
}
|
||||
|
||||
@@ -165,11 +165,6 @@ export const MySettings = Loadable(
|
||||
() => import(/* webpackChunkName: "All MySettings" */ 'pages/Settings'),
|
||||
);
|
||||
|
||||
export const CustomDomainSettings = Loadable(
|
||||
() =>
|
||||
import(/* webpackChunkName: "Custom Domain Settings" */ 'pages/Settings'),
|
||||
);
|
||||
|
||||
export const Logs = Loadable(
|
||||
() => import(/* webpackChunkName: "Logs" */ 'pages/LogsModulePage'),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from 'api';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import store from 'store';
|
||||
import {
|
||||
QueryKeyRequestProps,
|
||||
QueryKeySuggestionsResponseProps,
|
||||
@@ -17,6 +18,12 @@ export const getKeySuggestions = (
|
||||
signalSource = '',
|
||||
} = props;
|
||||
|
||||
const { globalTime } = store.getState();
|
||||
const resolvedTimeRange = {
|
||||
startUnixMilli: Math.floor(globalTime.minTime / 1000000),
|
||||
endUnixMilli: Math.floor(globalTime.maxTime / 1000000),
|
||||
};
|
||||
|
||||
const encodedSignal = encodeURIComponent(signal);
|
||||
const encodedSearchText = encodeURIComponent(searchText);
|
||||
const encodedMetricName = encodeURIComponent(metricName);
|
||||
@@ -24,7 +31,14 @@ export const getKeySuggestions = (
|
||||
const encodedFieldDataType = encodeURIComponent(fieldDataType);
|
||||
const encodedSource = encodeURIComponent(signalSource);
|
||||
|
||||
return axios.get(
|
||||
`/fields/keys?signal=${encodedSignal}&searchText=${encodedSearchText}&metricName=${encodedMetricName}&fieldContext=${encodedFieldContext}&fieldDataType=${encodedFieldDataType}&source=${encodedSource}`,
|
||||
);
|
||||
let url = `/fields/keys?signal=${encodedSignal}&searchText=${encodedSearchText}&metricName=${encodedMetricName}&fieldContext=${encodedFieldContext}&fieldDataType=${encodedFieldDataType}&source=${encodedSource}`;
|
||||
|
||||
if (resolvedTimeRange.startUnixMilli !== undefined) {
|
||||
url += `&startUnixMilli=${resolvedTimeRange.startUnixMilli}`;
|
||||
}
|
||||
if (resolvedTimeRange.endUnixMilli !== undefined) {
|
||||
url += `&endUnixMilli=${resolvedTimeRange.endUnixMilli}`;
|
||||
}
|
||||
|
||||
return axios.get(url);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from 'api';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import store from 'store';
|
||||
import {
|
||||
QueryKeyValueRequestProps,
|
||||
QueryKeyValueSuggestionsResponseProps,
|
||||
@@ -8,7 +9,20 @@ import {
|
||||
export const getValueSuggestions = (
|
||||
props: QueryKeyValueRequestProps,
|
||||
): Promise<AxiosResponse<QueryKeyValueSuggestionsResponseProps>> => {
|
||||
const { signal, key, searchText, signalSource, metricName } = props;
|
||||
const {
|
||||
signal,
|
||||
key,
|
||||
searchText,
|
||||
signalSource,
|
||||
metricName,
|
||||
existingQuery,
|
||||
} = props;
|
||||
|
||||
const { globalTime } = store.getState();
|
||||
const resolvedTimeRange = {
|
||||
startUnixMilli: Math.floor(globalTime.minTime / 1000000),
|
||||
endUnixMilli: Math.floor(globalTime.maxTime / 1000000),
|
||||
};
|
||||
|
||||
const encodedSignal = encodeURIComponent(signal);
|
||||
const encodedKey = encodeURIComponent(key);
|
||||
@@ -16,7 +30,17 @@ export const getValueSuggestions = (
|
||||
const encodedSearchText = encodeURIComponent(searchText);
|
||||
const encodedSource = encodeURIComponent(signalSource || '');
|
||||
|
||||
return axios.get(
|
||||
`/fields/values?signal=${encodedSignal}&name=${encodedKey}&searchText=${encodedSearchText}&metricName=${encodedMetricName}&source=${encodedSource}`,
|
||||
);
|
||||
let url = `/fields/values?signal=${encodedSignal}&name=${encodedKey}&searchText=${encodedSearchText}&metricName=${encodedMetricName}&source=${encodedSource}`;
|
||||
|
||||
if (resolvedTimeRange.startUnixMilli !== undefined) {
|
||||
url += `&startUnixMilli=${resolvedTimeRange.startUnixMilli}`;
|
||||
}
|
||||
if (resolvedTimeRange.endUnixMilli !== undefined) {
|
||||
url += `&endUnixMilli=${resolvedTimeRange.endUnixMilli}`;
|
||||
}
|
||||
if (existingQuery) {
|
||||
url += `&existingQuery=${encodeURIComponent(existingQuery)}`;
|
||||
}
|
||||
|
||||
return axios.get(url);
|
||||
};
|
||||
|
||||
2
frontend/src/auto-import-registry.d.ts
vendored
2
frontend/src/auto-import-registry.d.ts
vendored
@@ -18,6 +18,7 @@ import '@signozhq/checkbox';
|
||||
import '@signozhq/combobox';
|
||||
import '@signozhq/command';
|
||||
import '@signozhq/design-tokens';
|
||||
import '@signozhq/dialog';
|
||||
import '@signozhq/icons';
|
||||
import '@signozhq/input';
|
||||
import '@signozhq/popover';
|
||||
@@ -26,4 +27,5 @@ import '@signozhq/resizable';
|
||||
import '@signozhq/sonner';
|
||||
import '@signozhq/switch';
|
||||
import '@signozhq/table';
|
||||
import '@signozhq/toggle-group';
|
||||
import '@signozhq/tooltip';
|
||||
|
||||
@@ -272,7 +272,6 @@ function QuerySearch({
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
});
|
||||
|
||||
if (response.data.data) {
|
||||
const { keys } = response.data.data;
|
||||
const options = generateOptions(keys);
|
||||
@@ -432,6 +431,7 @@ function QuerySearch({
|
||||
}
|
||||
|
||||
const sanitizedSearchText = searchText ? searchText?.trim() : '';
|
||||
const existingQuery = queryData.filter?.expression || '';
|
||||
|
||||
try {
|
||||
const response = await getValueSuggestions({
|
||||
@@ -440,9 +440,9 @@ function QuerySearch({
|
||||
signal: dataSource,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
});
|
||||
existingQuery,
|
||||
}); // Skip updates if component unmounted or key changed
|
||||
|
||||
// Skip updates if component unmounted or key changed
|
||||
if (
|
||||
!isMountedRef.current ||
|
||||
lastKeyRef.current !== key ||
|
||||
@@ -454,7 +454,9 @@ function QuerySearch({
|
||||
// Process the response data
|
||||
const responseData = response.data as any;
|
||||
const values = responseData.data?.values || {};
|
||||
const stringValues = values.stringValues || [];
|
||||
const relatedValues = values.relatedValues || [];
|
||||
const stringValues =
|
||||
relatedValues.length > 0 ? relatedValues : values.stringValues || [];
|
||||
const numberValues = values.numberValues || [];
|
||||
|
||||
// Generate options from string values - explicitly handle empty strings
|
||||
@@ -529,11 +531,12 @@ function QuerySearch({
|
||||
},
|
||||
[
|
||||
activeKey,
|
||||
dataSource,
|
||||
isLoadingSuggestions,
|
||||
debouncedMetricName,
|
||||
signalSource,
|
||||
queryData.filter?.expression,
|
||||
toggleSuggestions,
|
||||
dataSource,
|
||||
signalSource,
|
||||
debouncedMetricName,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1240,19 +1243,17 @@ function QuerySearch({
|
||||
if (!queryContext) {
|
||||
return;
|
||||
}
|
||||
// Trigger suggestions based on context
|
||||
if (editorRef.current) {
|
||||
// Only trigger suggestions and fetch if editor is focused (i.e., user is interacting)
|
||||
if (isFocused && editorRef.current) {
|
||||
toggleSuggestions(10);
|
||||
}
|
||||
|
||||
// Handle value suggestions for value context
|
||||
if (queryContext.isInValue) {
|
||||
const { keyToken, currentToken } = queryContext;
|
||||
const key = keyToken || currentToken;
|
||||
|
||||
// Only fetch if needed and if we have a valid key
|
||||
if (key && key !== activeKey && !isLoadingSuggestions) {
|
||||
fetchValueSuggestions({ key });
|
||||
// Handle value suggestions for value context
|
||||
if (queryContext.isInValue) {
|
||||
const { keyToken, currentToken } = queryContext;
|
||||
const key = keyToken || currentToken;
|
||||
// Only fetch if needed and if we have a valid key
|
||||
if (key && key !== activeKey && !isLoadingSuggestions) {
|
||||
fetchValueSuggestions({ key });
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -1261,6 +1262,7 @@ function QuerySearch({
|
||||
isLoadingSuggestions,
|
||||
activeKey,
|
||||
fetchValueSuggestions,
|
||||
isFocused,
|
||||
]);
|
||||
|
||||
const getTooltipContent = (): JSX.Element => (
|
||||
|
||||
@@ -48,7 +48,12 @@
|
||||
.filter-separator {
|
||||
height: 1px;
|
||||
background-color: var(--bg-slate-400);
|
||||
margin: 4px 0;
|
||||
margin: 7px 0;
|
||||
|
||||
&.related-separator {
|
||||
opacity: 0.5;
|
||||
margin: 0.5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
@@ -138,6 +143,93 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.search-prompt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-top: 4px;
|
||||
border: 1px dashed var(--bg-amber-500);
|
||||
border-radius: 10px;
|
||||
color: var(--bg-amber-200);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-ink-500) 0%,
|
||||
var(--bg-ink-400) 100%
|
||||
);
|
||||
cursor: pointer;
|
||||
transition: all 0.16s ease, transform 0.12s ease;
|
||||
text-align: left;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-ink-400) 0%,
|
||||
var(--bg-ink-300) 100%
|
||||
);
|
||||
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: var(--bg-amber-400);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: var(--bg-amber-200);
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
color: var(--bg-amber-300);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
.lightMode & {
|
||||
.search-prompt {
|
||||
border: 1px dashed var(--bg-amber-500);
|
||||
color: var(--bg-amber-800);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-vanilla-200) 0%,
|
||||
var(--bg-vanilla-100) 100%
|
||||
);
|
||||
box-shadow: 0 2px 12px rgba(184, 107, 0, 0.08);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-vanilla-100) 0%,
|
||||
var(--bg-vanilla-50) 100%
|
||||
);
|
||||
box-shadow: 0 4px 16px rgba(184, 107, 0, 0.15);
|
||||
}
|
||||
|
||||
&__icon {
|
||||
color: var(--bg-amber-600);
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: var(--bg-amber-800);
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
color: var(--bg-amber-800);
|
||||
}
|
||||
}
|
||||
}
|
||||
.go-to-docs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -150,7 +150,8 @@ describe('CheckboxFilter - User Flows', () => {
|
||||
// User should see the filter is automatically opened (not collapsed)
|
||||
expect(screen.getByText('Service Name')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
expect(screen.getByPlaceholderText('Search values')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// User should see visual separator between checked and unchecked items
|
||||
@@ -184,7 +185,7 @@ describe('CheckboxFilter - User Flows', () => {
|
||||
|
||||
// Initially auto-opened due to active filters
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Search values')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// User manually closes the filter
|
||||
@@ -192,7 +193,7 @@ describe('CheckboxFilter - User Flows', () => {
|
||||
|
||||
// User should see filter is now closed (respecting user preference)
|
||||
expect(
|
||||
screen.queryByPlaceholderText('Filter values'),
|
||||
screen.queryByPlaceholderText('Search values'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// User manually opens the filter again
|
||||
@@ -200,7 +201,7 @@ describe('CheckboxFilter - User Flows', () => {
|
||||
|
||||
// User should see filter is now open (respecting user preference)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText('Search values')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { Button, Checkbox, Input, Skeleton, Typography } from 'antd';
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Button, Checkbox, Input, InputRef, Skeleton, Typography } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
@@ -8,19 +17,14 @@ import {
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { OPERATORS } from 'constants/antlrQueryConstants';
|
||||
import {
|
||||
DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { cloneDeep, isArray, isEqual, isFunction } from 'lodash-es';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
@@ -57,6 +61,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
// null = no user action, true = user opened, false = user closed
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(10);
|
||||
const [visibleUncheckedCount, setVisibleUncheckedCount] = useState<number>(5);
|
||||
|
||||
const {
|
||||
lastUsedQuery,
|
||||
@@ -78,6 +83,12 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
return lastUsedQuery || 0;
|
||||
}, [isListView, source, lastUsedQuery]);
|
||||
|
||||
// Extract current filter expression for the active query
|
||||
const currentFilterExpression = useMemo(() => {
|
||||
const queryData = currentQuery.builder.queryData?.[activeQueryIndex];
|
||||
return queryData?.filter?.expression || '';
|
||||
}, [currentQuery.builder.queryData, activeQueryIndex]);
|
||||
|
||||
// Check if this filter has active filters in the query
|
||||
const isSomeFilterPresentForCurrentAttribute = useMemo(
|
||||
() =>
|
||||
@@ -109,54 +120,125 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
filter.defaultOpen,
|
||||
]);
|
||||
|
||||
const { data, isLoading } = useGetAggregateValues(
|
||||
{
|
||||
aggregateOperator: filter.aggregateOperator || 'noop',
|
||||
dataSource: filter.dataSource || DataSource.LOGS,
|
||||
aggregateAttribute: filter.aggregateAttribute || '',
|
||||
attributeKey: filter.attributeKey.key,
|
||||
filterAttributeKeyDataType: filter.attributeKey.dataType || DataTypes.EMPTY,
|
||||
tagType: filter.attributeKey.type || '',
|
||||
searchText: searchText ?? '',
|
||||
},
|
||||
{
|
||||
enabled: isOpen && source !== QuickFiltersSource.METER_EXPLORER,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
data: keyValueSuggestions,
|
||||
isLoading: isLoadingKeyValueSuggestions,
|
||||
refetch: refetchKeyValueSuggestions,
|
||||
} = useGetQueryKeyValueSuggestions({
|
||||
key: filter.attributeKey.key,
|
||||
signal: filter.dataSource || DataSource.LOGS,
|
||||
signalSource: 'meter',
|
||||
searchText: searchText || '',
|
||||
existingQuery: currentFilterExpression,
|
||||
options: {
|
||||
enabled: isOpen && source === QuickFiltersSource.METER_EXPLORER,
|
||||
enabled: isOpen,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
});
|
||||
|
||||
const attributeValues: string[] = useMemo(() => {
|
||||
const dataType = filter.attributeKey.dataType || DataTypes.String;
|
||||
const searchInputRef = useRef<InputRef | null>(null);
|
||||
const searchContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const previousFiltersItemsRef = useRef(
|
||||
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items,
|
||||
);
|
||||
|
||||
if (source === QuickFiltersSource.METER_EXPLORER && keyValueSuggestions) {
|
||||
// Process the response data
|
||||
// Refetch when other filters change (not this filter)
|
||||
// Watch for when filters.items is different from previous value, indicating other filters changed
|
||||
useEffect(() => {
|
||||
const currentFiltersItems =
|
||||
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items;
|
||||
|
||||
const previousFiltersItems = previousFiltersItemsRef.current;
|
||||
|
||||
// Check if filters items have changed (not the same)
|
||||
const filtersChanged = !isEqual(previousFiltersItems, currentFiltersItems);
|
||||
|
||||
if (isOpen && filtersChanged) {
|
||||
// Check if OTHER filters (not this filter) have changed
|
||||
const currentOtherFilters = currentFiltersItems?.filter(
|
||||
(item) => !isEqual(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
const previousOtherFilters = previousFiltersItems?.filter(
|
||||
(item) => !isEqual(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
|
||||
// Refetch if other filters changed (not just this filter's values)
|
||||
const otherFiltersChanged = !isEqual(
|
||||
currentOtherFilters,
|
||||
previousOtherFilters,
|
||||
);
|
||||
|
||||
// Only update ref if we have valid API data or if filters actually changed
|
||||
// Don't update if search returned 0 results to preserve unchecked values
|
||||
const hasValidData = keyValueSuggestions && !isLoadingKeyValueSuggestions;
|
||||
if (otherFiltersChanged || hasValidData) {
|
||||
previousFiltersItemsRef.current = currentFiltersItems;
|
||||
}
|
||||
|
||||
if (otherFiltersChanged) {
|
||||
refetchKeyValueSuggestions();
|
||||
}
|
||||
} else {
|
||||
previousFiltersItemsRef.current = currentFiltersItems;
|
||||
}
|
||||
}, [
|
||||
activeQueryIndex,
|
||||
isOpen,
|
||||
refetchKeyValueSuggestions,
|
||||
filter.attributeKey.key,
|
||||
currentQuery.builder.queryData,
|
||||
keyValueSuggestions,
|
||||
isLoadingKeyValueSuggestions,
|
||||
]);
|
||||
|
||||
const handleSearchPromptClick = useCallback((): void => {
|
||||
if (searchContainerRef.current) {
|
||||
searchContainerRef.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
}
|
||||
if (searchInputRef.current) {
|
||||
setTimeout(() => searchInputRef.current?.focus({ cursor: 'end' }), 120);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isDataComplete = useMemo(() => {
|
||||
if (keyValueSuggestions) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const responseData = keyValueSuggestions?.data as any;
|
||||
return responseData.data?.complete || false;
|
||||
}
|
||||
return false;
|
||||
}, [keyValueSuggestions]);
|
||||
|
||||
const previousUncheckedValuesRef = useRef<string[]>([]);
|
||||
|
||||
const { attributeValues, relatedValuesSet } = useMemo(() => {
|
||||
if (keyValueSuggestions) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const responseData = keyValueSuggestions?.data as any;
|
||||
const values = responseData.data?.values || {};
|
||||
const stringValues = values.stringValues || [];
|
||||
const numberValues = values.numberValues || [];
|
||||
const relatedValues: string[] = values.relatedValues || [];
|
||||
const stringValues: string[] = values.stringValues || [];
|
||||
const numberValues: number[] = values.numberValues || [];
|
||||
|
||||
// Generate options from string values - explicitly handle empty strings
|
||||
const stringOptions = stringValues
|
||||
// Strict filtering for empty string - we'll handle it as a special case if needed
|
||||
.filter(
|
||||
(value: string | null | undefined): value is string =>
|
||||
value !== null && value !== undefined && value !== '',
|
||||
);
|
||||
const valuesToUse = [
|
||||
...relatedValues,
|
||||
...stringValues.filter(
|
||||
(value: string | null | undefined) =>
|
||||
value !== null &&
|
||||
value !== undefined &&
|
||||
value !== '' &&
|
||||
!relatedValues.includes(value),
|
||||
),
|
||||
];
|
||||
|
||||
const stringOptions = valuesToUse.filter(
|
||||
(value: string | null | undefined): value is string =>
|
||||
value !== null && value !== undefined && value !== '',
|
||||
);
|
||||
|
||||
// Generate options from number values
|
||||
const numberOptions = numberValues
|
||||
.filter(
|
||||
(value: number | null | undefined): value is number =>
|
||||
@@ -164,15 +246,27 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
)
|
||||
.map((value: number) => value.toString());
|
||||
|
||||
// Combine all options and make sure we don't have duplicate labels
|
||||
return [...stringOptions, ...numberOptions];
|
||||
}
|
||||
const filteredRelated = new Set(
|
||||
relatedValues.filter(
|
||||
(v): v is string => v !== null && v !== undefined && v !== '',
|
||||
),
|
||||
);
|
||||
|
||||
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
|
||||
return (data?.payload?.[key] || []).filter(
|
||||
(val) => val !== undefined && val !== null,
|
||||
);
|
||||
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
|
||||
const baseValues = [...stringOptions, ...numberOptions];
|
||||
const previousUnchecked = previousUncheckedValuesRef.current || [];
|
||||
const preservedUnchecked = previousUnchecked.filter(
|
||||
(value) => !baseValues.includes(value),
|
||||
);
|
||||
return {
|
||||
attributeValues: [...baseValues, ...preservedUnchecked],
|
||||
relatedValuesSet: filteredRelated,
|
||||
};
|
||||
}
|
||||
return {
|
||||
attributeValues: [] as string[],
|
||||
relatedValuesSet: new Set<string>(),
|
||||
};
|
||||
}, [keyValueSuggestions]);
|
||||
|
||||
const setSearchTextDebounced = useDebouncedFn((...args) => {
|
||||
setSearchText(args[0] as string);
|
||||
@@ -246,22 +340,51 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
const isMultipleValuesTrueForTheKey =
|
||||
Object.values(currentFilterState).filter((val) => val).length > 1;
|
||||
|
||||
// Sort checked items to the top, then unchecked items
|
||||
const currentAttributeKeys = useMemo(() => {
|
||||
// Sort checked items to the top; always show unchecked items beneath, regardless of pagination
|
||||
const {
|
||||
visibleCheckedValues,
|
||||
uncheckedValues,
|
||||
visibleUncheckedValues,
|
||||
visibleCheckedCount,
|
||||
hasMoreChecked,
|
||||
hasMoreUnchecked,
|
||||
checkedSeparatorIndex,
|
||||
} = useMemo(() => {
|
||||
const checkedValues = attributeValues.filter(
|
||||
(val) => currentFilterState[val],
|
||||
);
|
||||
const uncheckedValues = attributeValues.filter(
|
||||
(val) => !currentFilterState[val],
|
||||
);
|
||||
return [...checkedValues, ...uncheckedValues].slice(0, visibleItemsCount);
|
||||
}, [attributeValues, currentFilterState, visibleItemsCount]);
|
||||
const unchecked = attributeValues.filter((val) => !currentFilterState[val]);
|
||||
const visibleChecked = checkedValues.slice(0, visibleItemsCount);
|
||||
const visibleUnchecked = unchecked.slice(0, visibleUncheckedCount);
|
||||
|
||||
// Count of checked values in the currently visible items
|
||||
const checkedValuesCount = useMemo(
|
||||
() => currentAttributeKeys.filter((val) => currentFilterState[val]).length,
|
||||
[currentAttributeKeys, currentFilterState],
|
||||
);
|
||||
const findSeparatorIndex = (list: string[]): number => {
|
||||
if (relatedValuesSet.size === 0) {
|
||||
return -1;
|
||||
}
|
||||
const firstNonRelated = list.findIndex((v) => !relatedValuesSet.has(v));
|
||||
return firstNonRelated > 0 ? firstNonRelated : -1;
|
||||
};
|
||||
|
||||
return {
|
||||
visibleCheckedValues: visibleChecked,
|
||||
uncheckedValues: unchecked,
|
||||
visibleUncheckedValues: visibleUnchecked,
|
||||
visibleCheckedCount: visibleChecked.length,
|
||||
hasMoreChecked: checkedValues.length > visibleChecked.length,
|
||||
hasMoreUnchecked: unchecked.length > visibleUnchecked.length,
|
||||
checkedSeparatorIndex: findSeparatorIndex(visibleChecked),
|
||||
};
|
||||
}, [
|
||||
attributeValues,
|
||||
currentFilterState,
|
||||
visibleItemsCount,
|
||||
visibleUncheckedCount,
|
||||
relatedValuesSet,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
previousUncheckedValuesRef.current = uncheckedValues;
|
||||
}, [uncheckedValues]);
|
||||
|
||||
const handleClearFilterAttribute = (): void => {
|
||||
const preparedQuery: Query = {
|
||||
@@ -302,6 +425,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
isOnlyOrAllClicked: boolean,
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): void => {
|
||||
setVisibleUncheckedCount(5);
|
||||
const query = cloneDeep(currentQuery.builder.queryData?.[activeQueryIndex]);
|
||||
|
||||
// if only or all are clicked we do not need to worry about anything just override whatever we have
|
||||
@@ -562,6 +686,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
if (isOpen) {
|
||||
setUserToggleState(false);
|
||||
setVisibleItemsCount(10);
|
||||
setVisibleUncheckedCount(5);
|
||||
} else {
|
||||
setUserToggleState(true);
|
||||
}
|
||||
@@ -590,35 +715,93 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
{isOpen &&
|
||||
(isLoading || isLoadingKeyValueSuggestions) &&
|
||||
!attributeValues.length && (
|
||||
<section className="loading">
|
||||
<Skeleton paragraph={{ rows: 4 }} />
|
||||
</section>
|
||||
)}
|
||||
{isOpen && !isLoading && !isLoadingKeyValueSuggestions && (
|
||||
{isOpen && isLoadingKeyValueSuggestions && !attributeValues.length && (
|
||||
<section className="loading">
|
||||
<Skeleton paragraph={{ rows: 4 }} />
|
||||
</section>
|
||||
)}
|
||||
{isOpen && !isLoadingKeyValueSuggestions && (
|
||||
<>
|
||||
{!isEmptyStateWithDocsEnabled && (
|
||||
<section className="search">
|
||||
<section className="search" ref={searchContainerRef}>
|
||||
<Input
|
||||
placeholder="Filter values"
|
||||
placeholder="Search values"
|
||||
onChange={(e): void => setSearchTextDebounced(e.target.value)}
|
||||
disabled={isFilterDisabled}
|
||||
ref={searchInputRef}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{attributeValues.length > 0 ? (
|
||||
<section className="values">
|
||||
{currentAttributeKeys.map((value: string, index: number) => (
|
||||
{visibleCheckedValues.map((value: string, index: number) => (
|
||||
<Fragment key={value}>
|
||||
{index === checkedValuesCount && checkedValuesCount > 0 && (
|
||||
<div
|
||||
key="separator"
|
||||
className="filter-separator"
|
||||
data-testid="filter-separator"
|
||||
/>
|
||||
{index === checkedSeparatorIndex && (
|
||||
<div className="filter-separator related-separator" />
|
||||
)}
|
||||
<div className="value">
|
||||
<Checkbox
|
||||
onChange={(e): void => onChange(value, e.target.checked, false)}
|
||||
checked={currentFilterState[value]}
|
||||
disabled={isFilterDisabled}
|
||||
rootClassName="check-box"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
'checkbox-value-section',
|
||||
isFilterDisabled ? 'filter-disabled' : '',
|
||||
)}
|
||||
onClick={(): void => {
|
||||
if (isFilterDisabled) {
|
||||
return;
|
||||
}
|
||||
onChange(value, currentFilterState[value], true);
|
||||
}}
|
||||
>
|
||||
<div className={`${filter.title} label-${value}`} />
|
||||
{filter.customRendererForValue ? (
|
||||
filter.customRendererForValue(value)
|
||||
) : (
|
||||
<Typography.Text
|
||||
className="value-string"
|
||||
ellipsis={{ tooltip: { placement: 'top' } }}
|
||||
>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Button type="text" className="only-btn">
|
||||
{isSomeFilterPresentForCurrentAttribute
|
||||
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only'}
|
||||
</Button>
|
||||
<Button type="text" className="toggle-btn">
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{hasMoreChecked && (
|
||||
<section className="show-more">
|
||||
<Typography.Text
|
||||
className="show-more-text"
|
||||
onClick={(): void => setVisibleItemsCount((prev) => prev + 10)}
|
||||
>
|
||||
Show More...
|
||||
</Typography.Text>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{visibleCheckedCount > 0 && uncheckedValues.length > 0 && (
|
||||
<div className="filter-separator" data-testid="filter-separator" />
|
||||
)}
|
||||
|
||||
{visibleUncheckedValues.map((value: string) => (
|
||||
<Fragment key={value}>
|
||||
<div className="value">
|
||||
<Checkbox
|
||||
onChange={(e): void => onChange(value, e.target.checked, false)}
|
||||
@@ -670,6 +853,17 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{hasMoreUnchecked && (
|
||||
<section className="show-more">
|
||||
<Typography.Text
|
||||
className="show-more-text"
|
||||
onClick={(): void => setVisibleUncheckedCount((prev) => prev + 5)}
|
||||
>
|
||||
Show More...
|
||||
</Typography.Text>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
) : isEmptyStateWithDocsEnabled ? (
|
||||
<LogsQuickFilterEmptyState attributeKey={filter.attributeKey.key} />
|
||||
@@ -678,16 +872,18 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
<Typography.Text>No values found</Typography.Text>{' '}
|
||||
</section>
|
||||
)}
|
||||
{visibleItemsCount < attributeValues?.length && (
|
||||
<section className="show-more">
|
||||
<Typography.Text
|
||||
className="show-more-text"
|
||||
onClick={(): void => setVisibleItemsCount((prev) => prev + 10)}
|
||||
>
|
||||
Show More...
|
||||
</Typography.Text>
|
||||
</section>
|
||||
)}
|
||||
{visibleItemsCount >= attributeValues?.length &&
|
||||
attributeValues?.length > 0 &&
|
||||
!isDataComplete && (
|
||||
<section className="search-prompt" onClick={handleSearchPromptClick}>
|
||||
<AlertTriangle size={16} className="search-prompt__icon" />
|
||||
<span className="search-prompt__text">
|
||||
<Typography.Text className="search-prompt__subtitle">
|
||||
Tap to search and load more suggestions.
|
||||
</Typography.Text>
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -127,6 +127,34 @@
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.filters-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px 0 10px;
|
||||
color: var(--bg-vanilla-400);
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.filters-info-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
color: var(--bg-vanilla-400);
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
|
||||
.filters-info-text {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: 13px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.perilin-bg {
|
||||
@@ -180,5 +208,30 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filters-info {
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
.filters-info-toggle {
|
||||
color: var(--bg-ink-400);
|
||||
|
||||
&:hover {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
.filters-info-text {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filters-info-tooltip-title {
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.filters-info-tooltip-detail {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { isFunction, isNull } from 'lodash-es';
|
||||
import { Frown, Settings2 as SettingsIcon } from 'lucide-react';
|
||||
import { Frown, Lightbulb, Settings2 as SettingsIcon } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
@@ -291,6 +291,27 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<section className="filters-info">
|
||||
<Tooltip
|
||||
title={
|
||||
<div className="filters-info-tooltip">
|
||||
<div className="filters-info-tooltip-title">Adaptive Filters</div>
|
||||
<div>Values update automatically as you apply filters.</div>
|
||||
<div className="filters-info-tooltip-detail">
|
||||
The most relevant values are shown first, followed by all other
|
||||
available options.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
mouseEnterDelay={0.3}
|
||||
>
|
||||
<Typography.Text className="filters-info-toggle">
|
||||
<Lightbulb size={15} />
|
||||
Adaptive filters
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</section>
|
||||
<section className="filters">
|
||||
{filterConfig.map((filter) => {
|
||||
switch (filter.type) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useApiMonitoringParams,
|
||||
} from 'container/ApiMonitoring/queryParams';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
|
||||
import {
|
||||
otherFiltersResponse,
|
||||
quickFiltersAttributeValuesResponse,
|
||||
@@ -24,6 +25,8 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
}));
|
||||
jest.mock('container/ApiMonitoring/queryParams');
|
||||
|
||||
jest.mock('hooks/querySuggestions/useGetQueryKeyValueSuggestions');
|
||||
|
||||
const handleFilterVisibilityChange = jest.fn();
|
||||
const redirectWithQueryBuilderData = jest.fn();
|
||||
const putHandler = jest.fn();
|
||||
@@ -32,13 +35,15 @@ const mockSetApiMonitoringParams = jest.fn() as jest.MockedFunction<
|
||||
>;
|
||||
const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
|
||||
|
||||
const mockUseGetQueryKeyValueSuggestions = jest.mocked(
|
||||
useGetQueryKeyValueSuggestions,
|
||||
);
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const SIGNAL = SignalType.LOGS;
|
||||
const quickFiltersListURL = `${BASE_URL}/api/v1/orgs/me/filters/${SIGNAL}`;
|
||||
const saveQuickFiltersURL = `${BASE_URL}/api/v1/orgs/me/filters`;
|
||||
const quickFiltersSuggestionsURL = `${BASE_URL}/api/v3/filter_suggestions`;
|
||||
const quickFiltersAttributeValuesURL = `${BASE_URL}/api/v3/autocomplete/attribute_values`;
|
||||
const fieldsValuesURL = `${BASE_URL}/api/v1/fields/values`;
|
||||
|
||||
const FILTER_OS_DESCRIPTION = 'os.description';
|
||||
const FILTER_K8S_DEPLOYMENT_NAME = 'k8s.deployment.name';
|
||||
@@ -62,10 +67,7 @@ const setupServer = (): void => {
|
||||
putHandler(await req.json());
|
||||
return res(ctx.status(200), ctx.json({}));
|
||||
}),
|
||||
rest.get(quickFiltersAttributeValuesURL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
||||
),
|
||||
rest.get(fieldsValuesURL, (_req, res, ctx) =>
|
||||
rest.get('*/api/v1/fields/values*', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
||||
),
|
||||
);
|
||||
@@ -135,18 +137,28 @@ beforeEach(() => {
|
||||
queryData: [
|
||||
{
|
||||
queryName: QUERY_NAME,
|
||||
filters: { items: [{ key: 'test', value: 'value' }] },
|
||||
filters: { items: [], op: 'AND' },
|
||||
filter: { expression: '' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
lastUsedQuery: 0,
|
||||
panelType: 'logs',
|
||||
redirectWithQueryBuilderData,
|
||||
});
|
||||
mockUseApiMonitoringParams.mockReturnValue([
|
||||
{ showIP: true } as ApiMonitoringParams,
|
||||
mockSetApiMonitoringParams,
|
||||
]);
|
||||
|
||||
// Mock the hook to return data with mq-kafka
|
||||
mockUseGetQueryKeyValueSuggestions.mockReturnValue({
|
||||
data: quickFiltersAttributeValuesResponse,
|
||||
isLoading: false,
|
||||
refetch: jest.fn(),
|
||||
} as any);
|
||||
|
||||
setupServer();
|
||||
});
|
||||
|
||||
@@ -259,8 +271,9 @@ describe('Quick Filters', () => {
|
||||
|
||||
render(<TestQuickFilters />);
|
||||
|
||||
// Prefer role if possible; if label text isn’t wired to input, clicking the label text is OK
|
||||
const target = await screen.findByText('mq-kafka');
|
||||
// Wait for the filter to load with data
|
||||
const target = await screen.findByText('mq-kafka', {}, { timeout: 5000 });
|
||||
|
||||
await user.click(target);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -38,7 +38,6 @@ const ROUTES = {
|
||||
SETTINGS: '/settings',
|
||||
MY_SETTINGS: '/settings/my-settings',
|
||||
ORG_SETTINGS: '/settings/org-settings',
|
||||
CUSTOM_DOMAIN_SETTINGS: '/settings/custom-domain-settings',
|
||||
API_KEYS: '/settings/api-keys',
|
||||
INGESTION_SETTINGS: '/settings/ingestion-settings',
|
||||
SOMETHING_WENT_WRONG: '/something-went-wrong',
|
||||
@@ -56,6 +55,7 @@ const ROUTES = {
|
||||
TRACE_EXPLORER: '/trace-explorer',
|
||||
BILLING: '/settings/billing',
|
||||
ROLES_SETTINGS: '/settings/roles',
|
||||
ROLE_DETAILS: '/settings/roles/:roleId',
|
||||
SUPPORT: '/support',
|
||||
LOGS_SAVE_VIEWS: '/logs/saved-views',
|
||||
TRACES_SAVE_VIEWS: '/traces/saved-views',
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { DialogWrapper } from '@signozhq/dialog';
|
||||
import { CircleAlert, CircleCheck, LoaderCircle } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { AxiosError } from 'axios';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
|
||||
interface CustomDomainEditModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
customDomainSubdomain?: string;
|
||||
dnsSuffix: string;
|
||||
isLoading: boolean;
|
||||
updateDomainError: AxiosError<RenderErrorResponseDTO> | null;
|
||||
onClearError: () => void;
|
||||
onSubmit: (subdomain: string) => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export default function CustomDomainEditModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
customDomainSubdomain,
|
||||
dnsSuffix,
|
||||
isLoading,
|
||||
updateDomainError,
|
||||
onClearError,
|
||||
onSubmit,
|
||||
}: CustomDomainEditModalProps): JSX.Element {
|
||||
const [value, setValue] = useState(customDomainSubdomain ?? '');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setValue(customDomainSubdomain ?? '');
|
||||
}
|
||||
}, [isOpen, customDomainSubdomain]);
|
||||
|
||||
const handleClose = (): void => {
|
||||
setValidationError(null);
|
||||
onClearError();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
setValue(e.target.value);
|
||||
setValidationError(null);
|
||||
onClearError();
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (): void => {
|
||||
if (!value) {
|
||||
setValidationError('This field is required');
|
||||
return;
|
||||
}
|
||||
if (value.length < 3) {
|
||||
setValidationError('Minimum 3 characters required');
|
||||
return;
|
||||
}
|
||||
onSubmit(value);
|
||||
};
|
||||
|
||||
const is409 = updateDomainError?.status === 409;
|
||||
|
||||
const apiErrorMessage =
|
||||
(updateDomainError?.response?.data as RenderErrorResponseDTO)?.error
|
||||
?.message ?? null;
|
||||
|
||||
const errorMessage =
|
||||
validationError ??
|
||||
(is409
|
||||
? apiErrorMessage ??
|
||||
"You've already updated the custom domain once today. Please contact support."
|
||||
: apiErrorMessage);
|
||||
|
||||
const hasError = Boolean(errorMessage);
|
||||
|
||||
const statusIcon = ((): JSX.Element => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<LoaderCircle size={16} className="animate-spin edit-modal-status-icon" />
|
||||
);
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
return <CircleAlert size={16} color={Color.BG_CHERRY_500} />;
|
||||
}
|
||||
|
||||
return <CircleCheck size={16} color={Color.BG_FOREST_500} />;
|
||||
})();
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
className="edit-workspace-modal"
|
||||
title="Edit Workspace Link"
|
||||
open={isOpen}
|
||||
onOpenChange={(open: boolean): void => {
|
||||
if (!open) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
width="base"
|
||||
>
|
||||
<div className="edit-workspace-modal-content">
|
||||
<p className="edit-modal-description">
|
||||
Enter your preferred subdomain to create a unique URL for your team. Need
|
||||
help?{' '}
|
||||
<a
|
||||
href="https://signoz.io/support"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="edit-modal-link"
|
||||
>
|
||||
Contact support.
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div className="edit-modal-field">
|
||||
<label
|
||||
htmlFor="workspace-url-input"
|
||||
className={`edit-modal-label${
|
||||
hasError ? ' edit-modal-label--error' : ''
|
||||
}`}
|
||||
>
|
||||
Workspace URL
|
||||
</label>
|
||||
|
||||
<div
|
||||
className={`edit-modal-input-wrapper${
|
||||
hasError ? ' edit-modal-input-wrapper--error' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="edit-modal-input-field">
|
||||
{statusIcon}
|
||||
<Input
|
||||
id="workspace-url-input"
|
||||
aria-describedby="workspace-url-helper"
|
||||
aria-invalid={hasError}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="edit-modal-input-suffix">{dnsSuffix}</div>
|
||||
</div>
|
||||
|
||||
<span
|
||||
id="workspace-url-helper"
|
||||
className={`edit-modal-helper${
|
||||
hasError ? ' edit-modal-helper--error' : ''
|
||||
}`}
|
||||
>
|
||||
{hasError
|
||||
? errorMessage
|
||||
: "To help you easily explore SigNoz, we've selected a tenant sub domain name for you."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="edit-modal-note">
|
||||
<span className="edit-modal-note-emoji">🚧</span>
|
||||
<span className="edit-modal-note-text">
|
||||
Note that your previous URL still remains accessible. Your access
|
||||
credentials for the new URL remain the same.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="edit-modal-footer">
|
||||
{is409 ? (
|
||||
<LaunchChatSupport
|
||||
attributes={{ screen: 'Custom Domain Settings' }}
|
||||
eventName="Custom Domain Settings: Facing Issues Updating Custom Domain"
|
||||
message="Hi Team, I need help with updating custom domain"
|
||||
buttonText="Contact Support"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="solid"
|
||||
size="md"
|
||||
color="primary"
|
||||
className="edit-modal-apply-btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
Apply Changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,262 +1,460 @@
|
||||
.custom-domain-settings-container {
|
||||
margin-top: 24px;
|
||||
.beacon {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 1px;
|
||||
border-radius: 50%;
|
||||
background: rgba(78, 116, 248, 0.2);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 5px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-card {
|
||||
width: 100%;
|
||||
max-width: 768px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
overflow: hidden;
|
||||
|
||||
&--loading {
|
||||
padding: var(--padding-3);
|
||||
}
|
||||
|
||||
.custom-domain-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: var(--padding-3);
|
||||
gap: var(--spacing-6);
|
||||
|
||||
.custom-domain-edit-button {
|
||||
border: 1px solid var(--l3-border);
|
||||
background: var(--l3-background);
|
||||
|
||||
&:hover {
|
||||
background: var(--l3-background-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-card-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
}
|
||||
|
||||
.custom-domain-card-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
.custom-domain-card-org-name {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
letter-spacing: var(--paragraph-base-500-letter-spacing);
|
||||
}
|
||||
|
||||
.custom-domain-card-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-10);
|
||||
padding-left: 26px;
|
||||
}
|
||||
|
||||
.custom-domain-card-meta-timezone {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
color: var(--l1-foreground);
|
||||
font-variant-numeric: lining-nums tabular-nums slashed-zero;
|
||||
font-feature-settings: 'dlig' on, 'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: var(--paragraph-small-400-font-size);
|
||||
font-style: normal;
|
||||
font-weight: var(--paragraph-small-400-font-weight);
|
||||
line-height: var(--paragraph-small-400-line-height);
|
||||
letter-spacing: var(--paragraph-small-400-letter-spacing);
|
||||
text-transform: uppercase;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-callout {
|
||||
margin: 0 var(--margin-3) var(--margin-3);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
max-width: 742px;
|
||||
--callout-background: var(--primary);
|
||||
--callout-border-color: var(--callout-primary-border);
|
||||
--callout-icon-color: var(--primary);
|
||||
--callout-title-color: var(--callout-primary-title);
|
||||
}
|
||||
|
||||
.custom-domain-card-divider {
|
||||
height: 1px;
|
||||
background: var(--l2-border);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.custom-domain-card-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--padding-3);
|
||||
}
|
||||
|
||||
.custom-domain-card-license {
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.custom-domain-plan-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-url-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-18);
|
||||
letter-spacing: -0.06px;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-url-dropdown {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l1-background);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
padding: var(--padding-2) 0;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-header {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
padding: 0 var(--padding-3) var(--padding-2);
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
margin-bottom: var(--margin-1);
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-4);
|
||||
padding: 5px var(--padding-3);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--l1-background-hover);
|
||||
|
||||
.workspace-url-dropdown-item-label {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-item-external {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&--active {
|
||||
background: var(--l1-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-item-external {
|
||||
color: var(--l2-foreground);
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-item-label {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
.workspace-url-dropdown-item--active & {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-url-dropdown-item-check {
|
||||
color: var(--l1-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.edit-workspace-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
// Description
|
||||
.edit-modal-description {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.edit-modal-link {
|
||||
color: var(--primary);
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
// Input field group
|
||||
.edit-modal-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.edit-modal-label {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
|
||||
&--error {
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-modal-input-wrapper {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
.edit-modal-input-field {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
height: 44px;
|
||||
padding: 6px var(--padding-3);
|
||||
background: var(--l1-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-right: none;
|
||||
border-radius: 2px 0 0 2px;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
padding: 0;
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit-modal-input-suffix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px var(--padding-3);
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-left: none;
|
||||
border-radius: 0 2px 2px 0;
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.edit-modal-helper {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: var(--line-height-20);
|
||||
|
||||
&--error {
|
||||
color: var(--destructive);
|
||||
}
|
||||
}
|
||||
|
||||
.edit-modal-status-icon {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.edit-modal-note {
|
||||
display: flex;
|
||||
gap: var(--spacing-6);
|
||||
align-items: flex-start;
|
||||
padding: var(--padding-3);
|
||||
border-radius: 4px;
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.edit-modal-note-emoji {
|
||||
font-size: 16px;
|
||||
line-height: var(--line-height-20);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.edit-modal-note-text {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.edit-modal-footer {
|
||||
.facing-issue-button {
|
||||
width: 100%;
|
||||
|
||||
.periscope-btn {
|
||||
width: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
color: var(--primary-foreground);
|
||||
font-size: var(--paragraph-base-500-font-size);
|
||||
font-weight: var(--paragraph-base-500-font-weight);
|
||||
line-height: var(--paragraph-base-500-line-height);
|
||||
height: 36px;
|
||||
|
||||
.ant-btn-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--primary) !important;
|
||||
border: none !important;
|
||||
color: var(--primary-foreground) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.edit-modal-apply-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.custom-domain-settings-content {
|
||||
width: calc(100% - 30px);
|
||||
max-width: 736px;
|
||||
.custom-domain-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 0 16px;
|
||||
height: 40px;
|
||||
width: min(942px, calc(100vw - 32px));
|
||||
border-radius: 4px;
|
||||
background: var(--primary-background);
|
||||
color: var(--bg-base-white);
|
||||
|
||||
.title {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: var(--font-size-lg);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 28px; /* 155.556% */
|
||||
letter-spacing: -0.09px;
|
||||
}
|
||||
.custom-domain-toast-message {
|
||||
font-size: var(--paragraph-base-400-font-size);
|
||||
line-height: var(--line-height-20);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
.custom-domain-toast-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.custom-domain-toast-visit-btn {
|
||||
text-decoration: none;
|
||||
background: var(--bg-robin-600);
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-settings-card {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-ink-400);
|
||||
.custom-domain-toast-dismiss-btn {
|
||||
color: var(--callout-primary-icon);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
|
||||
.ant-card-body {
|
||||
padding: 12px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.custom-domain-settings-content-header {
|
||||
color: var(--bg-vanilla-100);
|
||||
font-size: var(--font-size-sm);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.custom-domain-settings-content-body {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
|
||||
.custom-domain-url-edit-btn {
|
||||
.periscope-btn {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--Slate-200, #2c3140);
|
||||
background: var(--Ink-200, #23262e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-urls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.custom-domain-url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
|
||||
line-height: 24px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.custom-domain-update-status {
|
||||
margin-top: 12px;
|
||||
|
||||
color: var(--bg-robin-400);
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(78, 116, 248, 0.1);
|
||||
background: rgba(78, 116, 248, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-settings-modal {
|
||||
.ant-modal-content {
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
background: var(--bg-ink-400);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
padding: 0;
|
||||
|
||||
.ant-modal-header {
|
||||
background: none;
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
padding: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-modal-close-x {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 12px 16px;
|
||||
|
||||
.custom-domain-settings-modal-body {
|
||||
margin-bottom: 48px;
|
||||
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-settings-modal-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
|
||||
.update-limit-reached-error {
|
||||
display: flex;
|
||||
padding: 20px 24px 24px 24px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
align-self: stretch;
|
||||
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 205, 86, 0.2);
|
||||
background: rgba(255, 205, 86, 0.1);
|
||||
|
||||
color: var(--bg-amber-400);
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
line-height: 20px; /* 142.857% */
|
||||
}
|
||||
|
||||
.ant-alert-message::first-letter {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-settings-modal-footer {
|
||||
padding: 16px 0;
|
||||
margin-top: 0;
|
||||
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.apply-changes-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.facing-issue-button {
|
||||
width: 100%;
|
||||
|
||||
.periscope-btn {
|
||||
width: 100%;
|
||||
|
||||
border-radius: 2px;
|
||||
background: var(--bg-robin-500);
|
||||
border: none;
|
||||
|
||||
color: var(--bg-vanilla-100);
|
||||
line-height: 20px;
|
||||
|
||||
.ant-btn-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-robin-500) !important;
|
||||
border: none !important;
|
||||
color: var(--bg-vanilla-100) !important;
|
||||
line-height: 20px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.custom-domain-settings-container {
|
||||
.custom-domain-settings-content {
|
||||
.title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--bg-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-settings-card {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
.ant-card-body {
|
||||
.custom-domain-settings-content-header {
|
||||
color: var(--bg-ink-100);
|
||||
}
|
||||
|
||||
.custom-domain-update-status {
|
||||
color: var(--bg-robin-400);
|
||||
border: 1px solid rgba(78, 116, 248, 0.1);
|
||||
background: rgba(78, 116, 248, 0.1);
|
||||
}
|
||||
|
||||
.custom-domain-url-edit-btn {
|
||||
.periscope-btn {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-domain-settings-modal {
|
||||
.ant-modal-content {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-100);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.ant-modal-header {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.custom-domain-settings-modal-error {
|
||||
.update-limit-reached-error {
|
||||
border: 1px solid rgba(255, 205, 86, 0.2);
|
||||
background: rgba(255, 205, 86, 0.1);
|
||||
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background: var(--primary-background-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,88 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Callout } from '@signozhq/callout';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Skeleton,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
Check,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
FilePenLine,
|
||||
Link2,
|
||||
SolidAlertCircle,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Dropdown, Skeleton } from 'antd';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
ZeustypesHostDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useGetHosts, usePutHost } from 'api/generated/services/zeus';
|
||||
import { AxiosError } from 'axios';
|
||||
import LaunchChatSupport from 'components/LaunchChatSupport/LaunchChatSupport';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { InfoIcon, Link2, Pencil } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import CustomDomainEditModal from './CustomDomainEditModal';
|
||||
|
||||
import './CustomDomainSettings.styles.scss';
|
||||
|
||||
interface CustomDomainSettingsProps {
|
||||
subdomain: string;
|
||||
function DomainUpdateToast({
|
||||
toastId,
|
||||
url,
|
||||
}: {
|
||||
toastId: string | number;
|
||||
url: string;
|
||||
}): JSX.Element {
|
||||
const displayUrl = url?.split('://')[1] ?? url;
|
||||
|
||||
return (
|
||||
<div className="custom-domain-toast">
|
||||
<span className="custom-domain-toast-message">
|
||||
Your workspace URL is being updated to <strong>{displayUrl}</strong>. This
|
||||
may take a few minutes.
|
||||
</span>
|
||||
<div className="custom-domain-toast-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="custom-domain-toast-visit-btn"
|
||||
suffixIcon={<ExternalLink size={12} />}
|
||||
onClick={(): void => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}}
|
||||
>
|
||||
Visit new URL
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="custom-domain-toast-dismiss-btn"
|
||||
onClick={(): void => {
|
||||
toast.dismiss(toastId);
|
||||
}}
|
||||
aria-label="Dismiss"
|
||||
prefixIcon={<X size={14} />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CustomDomainSettings(): JSX.Element {
|
||||
const { org } = useAppContext();
|
||||
const { notifications } = useNotifications();
|
||||
const { org, activeLicense } = useAppContext();
|
||||
const { timezone } = useTimezone();
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isPollingEnabled, setIsPollingEnabled] = useState(false);
|
||||
const [hosts, setHosts] = useState<ZeustypesHostDTO[] | null>(null);
|
||||
|
||||
const [updateDomainError, setUpdateDomainError] = useState<AxiosError | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [, setCopyUrl] = useCopyToClipboard();
|
||||
|
||||
const [
|
||||
customDomainDetails,
|
||||
setCustomDomainDetails,
|
||||
] = useState<CustomDomainSettingsProps | null>();
|
||||
updateDomainError,
|
||||
setUpdateDomainError,
|
||||
] = useState<AxiosError<RenderErrorResponseDTO> | null>(null);
|
||||
|
||||
const [editForm] = Form.useForm();
|
||||
|
||||
const handleModalClose = (): void => {
|
||||
setIsEditModalOpen(false);
|
||||
editForm.resetFields();
|
||||
setUpdateDomainError(null);
|
||||
};
|
||||
const [customDomainSubdomain, setCustomDomainSubdomain] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
|
||||
const {
|
||||
data: hostsData,
|
||||
@@ -67,9 +96,7 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
isLoading: isLoadingUpdateCustomDomain,
|
||||
} = usePutHost<AxiosError<RenderErrorResponseDTO>>();
|
||||
|
||||
const stripProtocol = (url: string): string => {
|
||||
return url?.split('://')[1] ?? url;
|
||||
};
|
||||
const stripProtocol = (url: string): string => url?.split('://')[1] ?? url;
|
||||
|
||||
const dnsSuffix = useMemo(() => {
|
||||
const defaultHost = hosts?.find((h) => h.is_default);
|
||||
@@ -78,6 +105,11 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
: '';
|
||||
}, [hosts]);
|
||||
|
||||
const activeHost = useMemo(
|
||||
() => hosts?.find((h) => !h.is_default) ?? hosts?.find((h) => h.is_default),
|
||||
[hosts],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFetchingHosts || !hostsData) {
|
||||
return;
|
||||
@@ -85,22 +117,14 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
|
||||
if (hostsData.status === 'success') {
|
||||
setHosts(hostsData.data.hosts ?? null);
|
||||
|
||||
const activeCustomDomain = hostsData.data.hosts?.find(
|
||||
(host) => !host.is_default,
|
||||
);
|
||||
|
||||
if (activeCustomDomain) {
|
||||
setCustomDomainDetails({
|
||||
subdomain: activeCustomDomain?.name || '',
|
||||
});
|
||||
const customHost = hostsData.data.hosts?.find((h) => !h.is_default);
|
||||
if (customHost) {
|
||||
setCustomDomainSubdomain(customHost.name || '');
|
||||
}
|
||||
}
|
||||
|
||||
if (hostsData.data.state !== 'HEALTHY' && isPollingEnabled) {
|
||||
setTimeout(() => {
|
||||
refetchHosts();
|
||||
}, 3000);
|
||||
setTimeout(() => refetchHosts(), 3000);
|
||||
}
|
||||
|
||||
if (hostsData.data.state === 'HEALTHY') {
|
||||
@@ -108,206 +132,175 @@ export default function CustomDomainSettings(): JSX.Element {
|
||||
}
|
||||
}, [hostsData, refetchHosts, isPollingEnabled, isFetchingHosts]);
|
||||
|
||||
const onUpdateCustomDomainSettings = (): void => {
|
||||
editForm
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
if (values.subdomain) {
|
||||
updateSubDomain(
|
||||
{ data: { name: values.subdomain } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsPollingEnabled(true);
|
||||
refetchHosts();
|
||||
setIsEditModalOpen(false);
|
||||
},
|
||||
onError: (error: AxiosError<RenderErrorResponseDTO>) => {
|
||||
setUpdateDomainError(error as AxiosError);
|
||||
setIsPollingEnabled(false);
|
||||
},
|
||||
},
|
||||
const handleSubmit = (subdomain: string): void => {
|
||||
updateSubDomain(
|
||||
{ data: { name: subdomain } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setIsPollingEnabled(true);
|
||||
refetchHosts();
|
||||
setIsEditModalOpen(false);
|
||||
setCustomDomainSubdomain(subdomain);
|
||||
const newUrl = `https://${subdomain}.${dnsSuffix}`;
|
||||
toast.custom(
|
||||
(toastId) => <DomainUpdateToast toastId={toastId} url={newUrl} />,
|
||||
{ duration: 5000, position: 'bottom-right' }, // this 5 sec is as per design
|
||||
);
|
||||
},
|
||||
onError: (error: AxiosError<RenderErrorResponseDTO>) => {
|
||||
setUpdateDomainError(error as AxiosError<RenderErrorResponseDTO>);
|
||||
setIsPollingEnabled(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
setCustomDomainDetails({
|
||||
subdomain: values.subdomain,
|
||||
});
|
||||
const sortedHosts = useMemo(
|
||||
() =>
|
||||
[...(hosts ?? [])].sort((a, b) => {
|
||||
if (a.name === activeHost?.name) {
|
||||
return -1;
|
||||
}
|
||||
})
|
||||
.catch((errorInfo) => {
|
||||
console.error('error info', errorInfo);
|
||||
});
|
||||
};
|
||||
if (b.name === activeHost?.name) {
|
||||
return 1;
|
||||
}
|
||||
if (a.is_default && !b.is_default) {
|
||||
return 1;
|
||||
}
|
||||
if (!a.is_default && b.is_default) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}),
|
||||
[hosts, activeHost],
|
||||
);
|
||||
|
||||
const onCopyUrlHandler = (url: string): void => {
|
||||
setCopyUrl(stripProtocol(url));
|
||||
notifications.success({
|
||||
message: 'Copied to clipboard',
|
||||
});
|
||||
};
|
||||
const planName = activeLicense?.plan?.name;
|
||||
|
||||
if (isLoadingHosts) {
|
||||
return (
|
||||
<div className="custom-domain-card custom-domain-card--loading">
|
||||
<Skeleton
|
||||
active
|
||||
title={{ width: '40%' }}
|
||||
paragraph={{ rows: 1, width: '60%' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="custom-domain-settings-container">
|
||||
<div className="custom-domain-settings-content">
|
||||
<header>
|
||||
<Typography.Title className="title">
|
||||
Custom Domain Settings
|
||||
</Typography.Title>
|
||||
<Typography.Text className="subtitle">
|
||||
Personalize your workspace domain effortlessly.
|
||||
</Typography.Text>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div className="custom-domain-settings-content">
|
||||
{!isLoadingHosts && (
|
||||
<Card className="custom-domain-settings-card">
|
||||
<div className="custom-domain-settings-content-header">
|
||||
Team {org?.[0]?.displayName} Information
|
||||
<>
|
||||
<div className="custom-domain-card">
|
||||
<div className="custom-domain-card-top">
|
||||
<div className="custom-domain-card-info">
|
||||
<div className="custom-domain-card-name-row">
|
||||
<span className="beacon" />
|
||||
<span className="custom-domain-card-org-name">
|
||||
{org?.[0]?.displayName ? org?.[0]?.displayName : customDomainSubdomain}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="custom-domain-settings-content-body">
|
||||
<div className="custom-domain-urls">
|
||||
{hosts?.map((host) => (
|
||||
<div
|
||||
className="custom-domain-url"
|
||||
key={host.name}
|
||||
onClick={(): void => onCopyUrlHandler(host.url || '')}
|
||||
>
|
||||
<Link2 size={12} /> {stripProtocol(host.url || '')}
|
||||
{host.is_default && <Tag color={Color.BG_ROBIN_500}>Default</Tag>}
|
||||
<div className="custom-domain-card-meta-row">
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
dropdownRender={(): JSX.Element => (
|
||||
<div className="workspace-url-dropdown">
|
||||
<span className="workspace-url-dropdown-header">
|
||||
All Workspace URLs
|
||||
</span>
|
||||
<div className="workspace-url-dropdown-divider" />
|
||||
{sortedHosts.map((host) => {
|
||||
const isActive = host.name === activeHost?.name;
|
||||
return (
|
||||
<a
|
||||
key={host.name}
|
||||
href={host.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`workspace-url-dropdown-item${
|
||||
isActive ? ' workspace-url-dropdown-item--active' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="workspace-url-dropdown-item-label">
|
||||
{stripProtocol(host.url ?? '')}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Check size={14} className="workspace-url-dropdown-item-check" />
|
||||
) : (
|
||||
<ExternalLink
|
||||
size={12}
|
||||
className="workspace-url-dropdown-item-external"
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="custom-domain-url-edit-btn">
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
className="periscope-btn"
|
||||
disabled={isLoadingHosts || isFetchingHosts || isPollingEnabled}
|
||||
type="default"
|
||||
icon={<Pencil size={10} />}
|
||||
onClick={(): void => setIsEditModalOpen(true)}
|
||||
type="button"
|
||||
size="xs"
|
||||
className="workspace-url-trigger"
|
||||
disabled={isFetchingHosts}
|
||||
>
|
||||
Customize team’s URL
|
||||
<Link2 size={12} />
|
||||
<span>{stripProtocol(activeHost?.url ?? '')}</span>
|
||||
<ChevronDown size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<span className="custom-domain-card-meta-timezone">
|
||||
<Clock size={11} />
|
||||
{timezone.offset}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPollingEnabled && (
|
||||
<Alert
|
||||
className="custom-domain-update-status"
|
||||
message={`Updating your URL to ⎯ ${customDomainDetails?.subdomain}.${dnsSuffix}. This may take a few mins.`}
|
||||
type="info"
|
||||
icon={<InfoIcon size={12} />}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
className="custom-domain-edit-button"
|
||||
prefixIcon={<FilePenLine size={12} />}
|
||||
disabled={isFetchingHosts || isPollingEnabled}
|
||||
onClick={(): void => setIsEditModalOpen(true)}
|
||||
>
|
||||
Edit workspace link
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isPollingEnabled && (
|
||||
<Callout
|
||||
type="info"
|
||||
showIcon
|
||||
className="custom-domain-callout"
|
||||
size="small"
|
||||
icon={<SolidAlertCircle size={13} color="primary" />}
|
||||
message={`Updating your URL to ⎯ ${customDomainSubdomain}.${dnsSuffix}. This may take a few mins.`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoadingHosts && (
|
||||
<Card className="custom-domain-settings-card">
|
||||
<Skeleton
|
||||
className="custom-domain-settings-skeleton"
|
||||
active
|
||||
paragraph={{ rows: 2 }}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
<div className="custom-domain-card-divider" />
|
||||
|
||||
<div className="custom-domain-card-bottom">
|
||||
<span className="beacon" />
|
||||
<span className="custom-domain-card-license">
|
||||
{planName && <code className="custom-domain-plan-badge">{planName}</code>}{' '}
|
||||
license is currently active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Update Custom Domain Modal */}
|
||||
<Modal
|
||||
className="custom-domain-settings-modal"
|
||||
title="Customize your team’s URL"
|
||||
open={isEditModalOpen}
|
||||
key="edit-custom-domain-settings-modal"
|
||||
afterClose={handleModalClose}
|
||||
// closable
|
||||
onCancel={handleModalClose}
|
||||
destroyOnClose
|
||||
footer={null}
|
||||
>
|
||||
<Form
|
||||
name="edit-custom-domain-settings-form"
|
||||
key={customDomainDetails?.subdomain}
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
autoComplete="off"
|
||||
initialValues={{
|
||||
subdomain: customDomainDetails?.subdomain,
|
||||
}}
|
||||
>
|
||||
{updateDomainError?.status !== 409 && (
|
||||
<>
|
||||
<div className="custom-domain-settings-modal-body">
|
||||
Enter your preferred subdomain to create a unique URL for your team.
|
||||
Need help? Contact support.
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="subdomain"
|
||||
label="Team’s URL subdomain"
|
||||
rules={[{ required: true }, { type: 'string', min: 3 }]}
|
||||
>
|
||||
<Input
|
||||
addonBefore={updateDomainError && <InfoIcon size={12} color="red" />}
|
||||
placeholder="Enter Domain"
|
||||
onChange={(): void => setUpdateDomainError(null)}
|
||||
addonAfter={dnsSuffix}
|
||||
autoFocus
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{updateDomainError && (
|
||||
<div className="custom-domain-settings-modal-error">
|
||||
{updateDomainError.status === 409 ? (
|
||||
<Alert
|
||||
message={
|
||||
(updateDomainError?.response?.data as RenderErrorResponseDTO)?.error
|
||||
?.message ||
|
||||
'You’ve already updated the custom domain once today. To make further changes, please contact our support team for assistance.'
|
||||
}
|
||||
type="warning"
|
||||
className="update-limit-reached-error"
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text type="danger">
|
||||
{
|
||||
(updateDomainError?.response?.data as RenderErrorResponseDTO)?.error
|
||||
?.message
|
||||
}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateDomainError?.status !== 409 && (
|
||||
<div className="custom-domain-settings-modal-footer">
|
||||
<Button
|
||||
className="periscope-btn primary apply-changes-btn"
|
||||
onClick={onUpdateCustomDomainSettings}
|
||||
loading={isLoadingUpdateCustomDomain}
|
||||
>
|
||||
Apply Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateDomainError?.status === 409 && (
|
||||
<div className="custom-domain-settings-modal-footer">
|
||||
<LaunchChatSupport
|
||||
attributes={{
|
||||
screen: 'Custom Domain Settings',
|
||||
}}
|
||||
eventName="Custom Domain Settings: Facing Issues Updating Custom Domain"
|
||||
message="Hi Team, I need help with updating custom domain"
|
||||
buttonText="Contact Support"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
<CustomDomainEditModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={(): void => setIsEditModalOpen(false)}
|
||||
customDomainSubdomain={customDomainSubdomain}
|
||||
dnsSuffix={dnsSuffix}
|
||||
isLoading={isLoadingUpdateCustomDomain}
|
||||
updateDomainError={updateDomainError}
|
||||
onClearError={(): void => setUpdateDomainError(null)}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,21 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
|
||||
import CustomDomainSettings from '../CustomDomainSettings';
|
||||
|
||||
jest.mock('components/LaunchChatSupport/LaunchChatSupport', () => ({
|
||||
__esModule: true,
|
||||
default: ({ buttonText }: { buttonText?: string }): JSX.Element => (
|
||||
<button type="button">{buttonText ?? 'Facing issues?'}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockToastCustom = jest.fn();
|
||||
jest.mock('@signozhq/sonner', () => ({
|
||||
toast: {
|
||||
custom: (...args: unknown[]): unknown => mockToastCustom(...args),
|
||||
dismiss: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const ZEUS_HOSTS_ENDPOINT = '*/api/v2/zeus/hosts';
|
||||
|
||||
const mockHostsResponse: GetHosts200 = {
|
||||
@@ -28,9 +43,12 @@ const mockHostsResponse: GetHosts200 = {
|
||||
};
|
||||
|
||||
describe('CustomDomainSettings', () => {
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
mockToastCustom.mockClear();
|
||||
});
|
||||
|
||||
it('renders host URLs with protocol stripped and marks the default host', async () => {
|
||||
it('renders active host URL in the trigger button', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
@@ -39,12 +57,11 @@ describe('CustomDomainSettings', () => {
|
||||
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/accepted-starfish\.test\.cloud/i);
|
||||
// The active host is the non-default one (custom-host)
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
expect(screen.getByText('Default')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens edit modal with DNS suffix derived from the default host', async () => {
|
||||
it('opens edit modal when clicking the edit button', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
@@ -54,14 +71,14 @@ describe('CustomDomainSettings', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/accepted-starfish\.test\.cloud/i);
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /customize team['’]s url/i }),
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: /customize your team['’]s url/i }),
|
||||
screen.getByRole('dialog', { name: /edit workspace link/i }),
|
||||
).toBeInTheDocument();
|
||||
// DNS suffix is the part of the default host URL after the name prefix
|
||||
expect(screen.getByText('test.cloud')).toBeInTheDocument();
|
||||
@@ -83,12 +100,13 @@ describe('CustomDomainSettings', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/accepted-starfish\.test\.cloud/i);
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /customize team['’]s url/i }),
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText(/enter domain/i);
|
||||
// The input is inside the modal — find it by its role
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'myteam');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
@@ -114,15 +132,111 @@ describe('CustomDomainSettings', () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/accepted-starfish\.test\.cloud/i);
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /customize team['’]s url/i }),
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
await user.type(screen.getByPlaceholderText(/enter domain/i), 'myteam');
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'myteam');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /contact support/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation error when subdomain is less than 3 characters', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'ab');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
expect(
|
||||
screen.getByText(/minimum 3 characters required/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all workspace URLs as links in the dropdown', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
|
||||
// Open the URL dropdown
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /custom-host\.test\.cloud/i }),
|
||||
);
|
||||
|
||||
// Both host URLs should appear as links in the dropdown
|
||||
const links = await screen.findAllByRole('link');
|
||||
const hostLinks = links.filter(
|
||||
(link) =>
|
||||
link.getAttribute('href')?.includes('test.cloud') &&
|
||||
link.getAttribute('target') === '_blank',
|
||||
);
|
||||
expect(hostLinks).toHaveLength(2);
|
||||
|
||||
// Verify the URLs
|
||||
const hrefs = hostLinks.map((link) => link.getAttribute('href'));
|
||||
expect(hrefs).toContain('https://accepted-starfish.test.cloud');
|
||||
expect(hrefs).toContain('https://custom-host.test.cloud');
|
||||
});
|
||||
|
||||
it('calls toast.custom with new URL after successful domain update', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
),
|
||||
rest.put(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({})),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
render(<CustomDomainSettings />);
|
||||
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /edit workspace link/i }),
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'myteam');
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }));
|
||||
|
||||
// Verify toast.custom was called
|
||||
await waitFor(() => {
|
||||
expect(mockToastCustom).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Render the toast element to verify its content
|
||||
const toastRenderer = mockToastCustom.mock.calls[0][0] as (
|
||||
id: string,
|
||||
) => JSX.Element;
|
||||
const { container } = render(toastRenderer('test-id'));
|
||||
expect(container).toHaveTextContent(/myteam\.test\.cloud/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
.general-settings-page {
|
||||
max-width: 768px;
|
||||
margin: 0 auto;
|
||||
padding: var(--padding-8) 0 var(--padding-16);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.general-settings-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.general-settings-title {
|
||||
font-size: var(--paragraph-medium-500-font-size);
|
||||
font-weight: var(--paragraph-medium-500-font-weight);
|
||||
line-height: 32px;
|
||||
letter-spacing: -0.08px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.general-settings-subtitle {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.retention-controls-container {
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.retention-controls-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--padding-4);
|
||||
height: 50px;
|
||||
background: var(--l2-background);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.retention-controls-header-label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-24);
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.retention-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--padding-4);
|
||||
height: 52px;
|
||||
background: var(--l2-background);
|
||||
|
||||
& + & {
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
.retention-row-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-20);
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
svg {
|
||||
color: var(--l2-foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.retention-row-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.retention-input-group {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
// todo: https://github.com/SigNoz/components/issues/116
|
||||
input[type='number'] {
|
||||
display: flex;
|
||||
width: 120px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
border-radius: 2px 0 0 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 16px;
|
||||
box-shadow: none;
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select {
|
||||
.ant-select-selector {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
padding: 6px 6px 6px 8px;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
width: 80px;
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.retention-error-text {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--accent-amber);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.retention-modal-description {
|
||||
margin: 0;
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 22px;
|
||||
}
|
||||
@@ -3,16 +3,20 @@ import { useTranslation } from 'react-i18next';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { useInterval } from 'react-use';
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Button, Card, Col, Divider, Modal, Row, Spin, Typography } from 'antd';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Compass, ScrollText } from '@signozhq/icons';
|
||||
import { Modal, Spin } from 'antd';
|
||||
import setRetentionApi from 'api/settings/setRetention';
|
||||
import setRetentionApiV2 from 'api/settings/setRetentionV2';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import CustomDomainSettings from 'container/CustomDomainSettings';
|
||||
import GeneralSettingsCloud from 'container/GeneralSettingsCloud';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import find from 'lodash-es/find';
|
||||
import { BarChart2 } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import {
|
||||
ErrorResponse,
|
||||
@@ -31,13 +35,17 @@ import {
|
||||
PayloadPropsMetrics as GetRetentionPeriodMetricsPayload,
|
||||
PayloadPropsTraces as GetRetentionPeriodTracesPayload,
|
||||
} from 'types/api/settings/getRetention';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import Retention from './Retention';
|
||||
import StatusMessage from './StatusMessage';
|
||||
import { ActionItemsContainer, ErrorText, ErrorTextContainer } from './styles';
|
||||
|
||||
import './GeneralSettings.styles.scss';
|
||||
|
||||
type NumberOrNull = number | null;
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function GeneralSettings({
|
||||
metricsTtlValuesPayload,
|
||||
tracesTtlValuesPayload,
|
||||
@@ -455,12 +463,20 @@ function GeneralSettings({
|
||||
onModalToggleHandler(type);
|
||||
};
|
||||
|
||||
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
|
||||
const {
|
||||
isCloudUser: isCloudUserVal,
|
||||
isEnterpriseSelfHostedUser,
|
||||
} = useGetTenantLicense();
|
||||
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
const showCustomDomainSettings =
|
||||
(isCloudUserVal || isEnterpriseSelfHostedUser) && isAdmin;
|
||||
|
||||
const renderConfig = [
|
||||
{
|
||||
name: 'Metrics',
|
||||
type: 'metrics',
|
||||
icon: <BarChart2 size={14} />,
|
||||
retentionFields: [
|
||||
{
|
||||
name: t('total_retention_period'),
|
||||
@@ -503,6 +519,7 @@ function GeneralSettings({
|
||||
{
|
||||
name: 'Traces',
|
||||
type: 'traces',
|
||||
icon: <Compass size={14} />,
|
||||
retentionFields: [
|
||||
{
|
||||
name: t('total_retention_period'),
|
||||
@@ -543,6 +560,7 @@ function GeneralSettings({
|
||||
{
|
||||
name: 'Logs',
|
||||
type: 'logs',
|
||||
icon: <ScrollText size={14} />,
|
||||
retentionFields: [
|
||||
{
|
||||
name: t('total_retention_period'),
|
||||
@@ -587,69 +605,66 @@ function GeneralSettings({
|
||||
) {
|
||||
return (
|
||||
<Fragment key={category.name}>
|
||||
<Col xs={22} xl={11} key={category.name} style={{ margin: '0.5rem' }}>
|
||||
<Card style={{ height: '100%' }}>
|
||||
<Typography.Title style={{ margin: 0 }} level={3}>
|
||||
{category.name}
|
||||
</Typography.Title>
|
||||
<Divider
|
||||
style={{
|
||||
margin: '0.5rem 0',
|
||||
padding: 0,
|
||||
opacity: 0.5,
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
/>
|
||||
{category.retentionFields.map((retentionField) => (
|
||||
<div className="retention-row">
|
||||
<span className="retention-row-label">
|
||||
{category.icon}
|
||||
{category.name}
|
||||
</span>
|
||||
<div className="retention-row-controls">
|
||||
{category.retentionFields.map((field) => (
|
||||
<Retention
|
||||
key={field.name}
|
||||
type={category.type as TTTLType}
|
||||
key={retentionField.name}
|
||||
text={retentionField.name}
|
||||
retentionValue={retentionField.value}
|
||||
setRetentionValue={retentionField.setValue}
|
||||
hide={!!retentionField.hide}
|
||||
isS3Field={'isS3Field' in retentionField && retentionField.isS3Field}
|
||||
text={field.name}
|
||||
retentionValue={field.value}
|
||||
setRetentionValue={field.setValue}
|
||||
hide={!!field.hide}
|
||||
isS3Field={'isS3Field' in field && !!field.isS3Field}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
|
||||
{!isCloudUserVal && (
|
||||
<>
|
||||
<ActionItemsContainer>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={category.save.modalOpen}
|
||||
disabled={category.save.isDisabled}
|
||||
>
|
||||
{category.save.saveButtonText}
|
||||
</Button>
|
||||
{category.statusComponent}
|
||||
</ActionItemsContainer>
|
||||
<Modal
|
||||
title={t('retention_confirmation')}
|
||||
focusTriggerAfterClose
|
||||
forceRender
|
||||
destroyOnClose
|
||||
closable
|
||||
onCancel={(): void =>
|
||||
onModalToggleHandler(category.name.toLowerCase() as TTTLType)
|
||||
}
|
||||
onOk={(): Promise<void> =>
|
||||
onOkHandler(category.name.toLowerCase() as TTTLType)
|
||||
}
|
||||
centered
|
||||
open={category.save.modal}
|
||||
confirmLoading={category.save.apiLoading}
|
||||
>
|
||||
<Typography>
|
||||
{t('retention_confirmation_description', {
|
||||
name: category.name.toLowerCase(),
|
||||
})}
|
||||
</Typography>
|
||||
</Modal>
|
||||
</>
|
||||
<Button
|
||||
variant="solid"
|
||||
size="sm"
|
||||
color="primary"
|
||||
onClick={category.save.modalOpen}
|
||||
disabled={category.save.isDisabled}
|
||||
>
|
||||
{category.save.saveButtonText}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isCloudUserVal && (
|
||||
<ActionItemsContainer>{category.statusComponent}</ActionItemsContainer>
|
||||
)}
|
||||
|
||||
{!isCloudUserVal && (
|
||||
<Modal
|
||||
title={t('retention_confirmation')}
|
||||
focusTriggerAfterClose
|
||||
forceRender
|
||||
destroyOnClose
|
||||
closable
|
||||
onCancel={(): void =>
|
||||
onModalToggleHandler(category.name.toLowerCase() as TTTLType)
|
||||
}
|
||||
onOk={(): Promise<void> =>
|
||||
onOkHandler(category.name.toLowerCase() as TTTLType)
|
||||
}
|
||||
centered
|
||||
open={category.save.modal}
|
||||
confirmLoading={category.save.apiLoading}
|
||||
>
|
||||
<p className="retention-modal-description">
|
||||
{t('retention_confirmation_description', {
|
||||
name: category.name.toLowerCase(),
|
||||
})}
|
||||
</p>
|
||||
</Modal>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -657,9 +672,24 @@ function GeneralSettings({
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{Element}
|
||||
<Col xs={24} md={22} xl={20} xxl={18} style={{ margin: 'auto' }}>
|
||||
<div className="general-settings-page">
|
||||
<div className="general-settings-header">
|
||||
<span className="general-settings-title">Workspace</span>
|
||||
<span className="general-settings-subtitle">
|
||||
Manage your workspace settings.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showCustomDomainSettings && <CustomDomainSettings />}
|
||||
|
||||
<div className="retention-controls-container">
|
||||
<div className="retention-controls-header">
|
||||
<span className="retention-controls-header-label">Retention Controls</span>
|
||||
</div>
|
||||
{renderConfig}
|
||||
</div>
|
||||
|
||||
{(!isCloudUserVal || errorText) && (
|
||||
<ErrorTextContainer>
|
||||
{!isCloudUserVal && (
|
||||
<TextToolTip
|
||||
@@ -671,12 +701,10 @@ function GeneralSettings({
|
||||
)}
|
||||
{errorText && <ErrorText>{errorText}</ErrorText>}
|
||||
</ErrorTextContainer>
|
||||
)}
|
||||
|
||||
<Row justify="start">{renderConfig}</Row>
|
||||
|
||||
{isCloudUserVal && <GeneralSettingsCloud />}
|
||||
</Col>
|
||||
</>
|
||||
{isCloudUserVal && <GeneralSettingsCloud />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Input as SignozInput } from '@signozhq/input';
|
||||
import { Col, Row, Select } from 'antd';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { find } from 'lodash-es';
|
||||
@@ -34,6 +35,7 @@ function Retention({
|
||||
text,
|
||||
hide,
|
||||
isS3Field = false,
|
||||
compact = false,
|
||||
}: RetentionProps): JSX.Element | null {
|
||||
// Filter available units based on type and field
|
||||
const availableUnits = useMemo(
|
||||
@@ -126,6 +128,27 @@ function Retention({
|
||||
return null;
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="retention-input-group">
|
||||
<SignozInput
|
||||
type="number"
|
||||
min={0}
|
||||
value={selectedValue && selectedValue >= 0 ? selectedValue : ''}
|
||||
disabled={isCloudUserVal}
|
||||
onChange={(e): void => onChangeHandler(e, setSelectedValue)}
|
||||
/>
|
||||
<Select
|
||||
value={selectedTimeUnit}
|
||||
onChange={currentSelectedOption}
|
||||
disabled={isCloudUserVal}
|
||||
>
|
||||
{menuItems}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RetentionContainer>
|
||||
<Row justify="space-between">
|
||||
@@ -162,9 +185,11 @@ interface RetentionProps {
|
||||
setRetentionValue: Dispatch<SetStateAction<number | null>>;
|
||||
hide: boolean;
|
||||
isS3Field?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
Retention.defaultProps = {
|
||||
isS3Field: false,
|
||||
compact: false,
|
||||
};
|
||||
export default Retention;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import setRetentionApiV2 from 'api/settings/setRetentionV2';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -35,14 +36,20 @@ jest.mock('hooks/useComponentPermission', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: (): { isCloudUser: boolean } => ({
|
||||
useGetTenantLicense: jest.fn(() => ({
|
||||
isCloudUser: false,
|
||||
}),
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('container/GeneralSettingsCloud', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
default: (): JSX.Element => <div data-testid="general-settings-cloud" />,
|
||||
}));
|
||||
|
||||
jest.mock('container/CustomDomainSettings', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="custom-domain-settings" />,
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
@@ -93,10 +100,12 @@ const mockDisksWithoutS3: IDiskType[] = [
|
||||
},
|
||||
];
|
||||
|
||||
describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
const BUTTON_SELECTOR = 'button[type="button"]';
|
||||
const PRIMARY_BUTTON_CLASS = 'ant-btn-primary';
|
||||
const getLogsRow = (): HTMLElement => {
|
||||
const logsLabel = screen.getByText('Logs');
|
||||
return logsLabel.closest('.retention-row') as HTMLElement;
|
||||
};
|
||||
|
||||
describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(setRetentionApiV2 as jest.Mock).mockResolvedValue({
|
||||
@@ -121,21 +130,20 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the Logs card
|
||||
const logsCard = screen.getByText('Logs').closest('.ant-card');
|
||||
expect(logsCard).toBeInTheDocument();
|
||||
const logsRow = getLogsRow();
|
||||
expect(logsRow).toBeInTheDocument();
|
||||
|
||||
// Find all inputs in the Logs card - there should be 2 (total retention + S3)
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
// Find all inputs in the Logs row - there should be 2 (total retention + S3)
|
||||
const inputs = logsRow.querySelectorAll('input[type="number"]');
|
||||
expect(inputs).toHaveLength(2);
|
||||
|
||||
// The second input is the S3 retention field
|
||||
const s3Input = inputs?.[1] as HTMLInputElement;
|
||||
const s3Input = inputs[1] as HTMLInputElement;
|
||||
|
||||
// Find the S3 dropdown (next sibling of the S3 input)
|
||||
const s3Dropdown = s3Input?.nextElementSibling?.querySelector(
|
||||
'.ant-select-selector',
|
||||
) as HTMLElement;
|
||||
const s3Dropdown = s3Input
|
||||
?.closest('.retention-row-controls')
|
||||
?.querySelectorAll('.ant-select-selector')[1] as HTMLElement;
|
||||
expect(s3Dropdown).toBeInTheDocument();
|
||||
|
||||
// Click the S3 dropdown to open it
|
||||
@@ -155,16 +163,13 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
await user.clear(s3Input);
|
||||
await user.type(s3Input, '5');
|
||||
|
||||
// Find the save button in the Logs card
|
||||
const buttons = logsCard?.querySelectorAll(BUTTON_SELECTOR);
|
||||
// The primary button should be the save button
|
||||
const saveButton = Array.from(buttons || []).find((btn) =>
|
||||
btn.className.includes(PRIMARY_BUTTON_CLASS),
|
||||
// Find the save button in the Logs row
|
||||
const saveButton = logsRow.querySelector(
|
||||
'button:not([disabled])',
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
|
||||
// Wait for button to be enabled (it should enable after value changes)
|
||||
// Wait for button to be enabled
|
||||
await waitFor(() => {
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
@@ -205,8 +210,8 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
);
|
||||
|
||||
// Verify S3 field is visible
|
||||
const logsCard = screen.getByText('Logs').closest('.ant-card');
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
const logsRow = getLogsRow();
|
||||
const inputs = logsRow.querySelectorAll('input[type="number"]');
|
||||
expect(inputs).toHaveLength(2); // Total + S3
|
||||
});
|
||||
});
|
||||
@@ -227,19 +232,18 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the Logs card
|
||||
const logsCard = screen.getByText('Logs').closest('.ant-card');
|
||||
expect(logsCard).toBeInTheDocument();
|
||||
const logsRow = getLogsRow();
|
||||
expect(logsRow).toBeInTheDocument();
|
||||
|
||||
// Only 1 input should be visible (total retention, no S3)
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
const inputs = logsRow.querySelectorAll('input[type="number"]');
|
||||
expect(inputs).toHaveLength(1);
|
||||
|
||||
// Change total retention value
|
||||
const totalInput = inputs?.[0] as HTMLInputElement;
|
||||
const totalInput = inputs[0] as HTMLInputElement;
|
||||
|
||||
// First, change the dropdown to Days (it defaults to Months)
|
||||
const totalDropdown = totalInput?.nextElementSibling?.querySelector(
|
||||
const totalDropdown = logsRow.querySelector(
|
||||
'.ant-select-selector',
|
||||
) as HTMLElement;
|
||||
await user.click(totalDropdown);
|
||||
@@ -263,14 +267,12 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
await user.type(totalInput, '60');
|
||||
|
||||
// Find the save button
|
||||
const buttons = logsCard?.querySelectorAll(BUTTON_SELECTOR);
|
||||
const saveButton = Array.from(buttons || []).find((btn) =>
|
||||
btn.className.includes(PRIMARY_BUTTON_CLASS),
|
||||
const saveButton = logsRow.querySelector(
|
||||
'button:not([disabled])',
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
|
||||
// Wait for button to be enabled (ensures all state updates have settled)
|
||||
// Wait for button to be enabled
|
||||
await waitFor(() => {
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
@@ -312,22 +314,21 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the Logs card
|
||||
const logsCard = screen.getByText('Logs').closest('.ant-card');
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
const logsRow = getLogsRow();
|
||||
const inputs = logsRow.querySelectorAll('input[type="number"]');
|
||||
|
||||
// Total retention: 720 hours = 30 days = 1 month (displays as 1 Month)
|
||||
const totalInput = inputs?.[0] as HTMLInputElement;
|
||||
// Total retention: 30 days = 1 month (displays as 1 Month)
|
||||
const totalInput = inputs[0] as HTMLInputElement;
|
||||
expect(totalInput.value).toBe('1');
|
||||
|
||||
// S3 retention: 24 day
|
||||
const s3Input = inputs?.[1] as HTMLInputElement;
|
||||
// S3 retention: 24 days
|
||||
const s3Input = inputs[1] as HTMLInputElement;
|
||||
expect(s3Input.value).toBe('24');
|
||||
|
||||
// Verify dropdowns: total shows Months, S3 shows Days
|
||||
const dropdowns = logsCard?.querySelectorAll('.ant-select-selection-item');
|
||||
expect(dropdowns?.[0]).toHaveTextContent('Months');
|
||||
expect(dropdowns?.[1]).toHaveTextContent('Days');
|
||||
const dropdowns = logsRow.querySelectorAll('.ant-select-selection-item');
|
||||
expect(dropdowns[0]).toHaveTextContent('Months');
|
||||
expect(dropdowns[1]).toHaveTextContent('Days');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -347,24 +348,22 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the Logs card
|
||||
const logsCard = screen.getByText('Logs').closest('.ant-card');
|
||||
expect(logsCard).toBeInTheDocument();
|
||||
const logsRow = getLogsRow();
|
||||
expect(logsRow).toBeInTheDocument();
|
||||
|
||||
// Find the save button
|
||||
const buttons = logsCard?.querySelectorAll(BUTTON_SELECTOR);
|
||||
const saveButton = Array.from(buttons || []).find((btn) =>
|
||||
btn.className.includes(PRIMARY_BUTTON_CLASS),
|
||||
// Find the save button by accessible name within the Logs row
|
||||
const allSaveButtons = screen.getAllByRole('button', { name: /save/i });
|
||||
const saveButton = allSaveButtons.find((btn) =>
|
||||
logsRow.contains(btn),
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
|
||||
// Verify save button is disabled on initial load (no changes, S3 disabled with -1)
|
||||
// Verify save button is disabled on initial load
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
// Find the total retention input
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
const totalInput = inputs?.[0] as HTMLInputElement;
|
||||
const inputs = logsRow.querySelectorAll('input[type="number"]');
|
||||
const totalInput = inputs[0] as HTMLInputElement;
|
||||
|
||||
// Change total retention value to trigger button enable
|
||||
await user.clear(totalInput);
|
||||
@@ -385,4 +384,62 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloud User Rendering', () => {
|
||||
beforeEach(() => {
|
||||
(useGetTenantLicense as jest.Mock).mockReturnValue({
|
||||
isCloudUser: true,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render CustomDomainSettings and GeneralSettingsCloud for cloud admin', () => {
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
tracesTtlValuesPayload={mockTracesRetention}
|
||||
logsTtlValuesPayload={mockLogsRetentionWithS3}
|
||||
getAvailableDiskPayload={mockDisksWithS3}
|
||||
metricsTtlValuesRefetch={jest.fn()}
|
||||
tracesTtlValuesRefetch={jest.fn()}
|
||||
logsTtlValuesRefetch={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom-domain-settings')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('general-settings-cloud')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enterprise Self-Hosted User Rendering', () => {
|
||||
beforeEach(() => {
|
||||
(useGetTenantLicense as jest.Mock).mockReturnValue({
|
||||
isCloudUser: false,
|
||||
isEnterpriseSelfHostedUser: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should render CustomDomainSettings but not GeneralSettingsCloud', () => {
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
tracesTtlValuesPayload={mockTracesRetention}
|
||||
logsTtlValuesPayload={mockLogsRetentionWithS3}
|
||||
getAvailableDiskPayload={mockDisksWithS3}
|
||||
metricsTtlValuesRefetch={jest.fn()}
|
||||
tracesTtlValuesRefetch={jest.fn()}
|
||||
logsTtlValuesRefetch={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('custom-domain-settings')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('general-settings-cloud'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Save buttons should be visible for self-hosted
|
||||
const saveButtons = screen.getAllByRole('button', { name: /save/i });
|
||||
expect(saveButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
.general-settings-container {
|
||||
margin: 16px 8px;
|
||||
margin: var(--spacing-8) 0px;
|
||||
|
||||
.ant-card-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: var(--spacing-8);
|
||||
padding: 8px;
|
||||
margin: 16px 0;
|
||||
margin: var(--spacing-8) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Skeleton, Tag, Typography } from 'antd';
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Skeleton } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { useGetHosts } from 'api/generated/services/zeus';
|
||||
import ROUTES from 'constants/routes';
|
||||
@@ -29,77 +30,60 @@ function DataSourceInfo({
|
||||
query: { enabled: isEnabled || false },
|
||||
});
|
||||
|
||||
const [url, setUrl] = useState<string>('');
|
||||
const activeHost = useMemo(
|
||||
() =>
|
||||
hostsData?.data?.hosts?.find((h) => !h.is_default) ??
|
||||
hostsData?.data?.hosts?.find((h) => h.is_default),
|
||||
[hostsData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (hostsData) {
|
||||
const defaultHost = hostsData?.data.hosts?.find((h) => h.is_default);
|
||||
if (defaultHost?.url) {
|
||||
const url = defaultHost?.url?.split('://')[1] ?? '';
|
||||
setUrl(url);
|
||||
}
|
||||
const url = useMemo(() => activeHost?.url?.split('://')[1] ?? '', [
|
||||
activeHost,
|
||||
]);
|
||||
|
||||
const handleConnect = (): void => {
|
||||
logEvent('Homepage: Connect dataSource clicked', {});
|
||||
|
||||
if (activeLicense && activeLicense.platform === LicensePlatform.CLOUD) {
|
||||
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
} else {
|
||||
window?.open(DOCS_LINKS.ADD_DATA_SOURCE, '_blank', 'noopener noreferrer');
|
||||
}
|
||||
}, [hostsData]);
|
||||
};
|
||||
|
||||
const renderNotSendingData = (): JSX.Element => (
|
||||
<>
|
||||
<Typography className="welcome-title">
|
||||
<h2 className="welcome-title">
|
||||
Hello there, Welcome to your SigNoz workspace
|
||||
</Typography>
|
||||
</h2>
|
||||
|
||||
<Typography className="welcome-description">
|
||||
<p className="welcome-description">
|
||||
You’re not sending any data yet. <br />
|
||||
SigNoz is so much better with your data ⎯ start by sending your telemetry
|
||||
data to SigNoz.
|
||||
</Typography>
|
||||
</p>
|
||||
|
||||
<Card className="welcome-card">
|
||||
<Card.Content>
|
||||
<div className="workspace-ready-container">
|
||||
<div className="workspace-ready-header">
|
||||
<Typography className="workspace-ready-title">
|
||||
<span className="workspace-ready-title">
|
||||
<img src="/Icons/hurray.svg" alt="hurray" />
|
||||
Your workspace is ready
|
||||
</Typography>
|
||||
</span>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
className="periscope-btn primary"
|
||||
icon={<img src="/Icons/container-plus.svg" alt="plus" />}
|
||||
prefixIcon={<img src="/Icons/container-plus.svg" alt="plus" />}
|
||||
onClick={handleConnect}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(): void => {
|
||||
logEvent('Homepage: Connect dataSource clicked', {});
|
||||
|
||||
if (
|
||||
activeLicense &&
|
||||
activeLicense.platform === LicensePlatform.CLOUD
|
||||
) {
|
||||
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
} else {
|
||||
window?.open(
|
||||
DOCS_LINKS.ADD_DATA_SOURCE,
|
||||
'_blank',
|
||||
'noopener noreferrer',
|
||||
);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter') {
|
||||
logEvent('Homepage: Connect dataSource clicked', {});
|
||||
|
||||
if (
|
||||
activeLicense &&
|
||||
activeLicense.platform === LicensePlatform.CLOUD
|
||||
) {
|
||||
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
|
||||
} else {
|
||||
window?.open(
|
||||
DOCS_LINKS.ADD_DATA_SOURCE,
|
||||
'_blank',
|
||||
'noopener noreferrer',
|
||||
);
|
||||
}
|
||||
handleConnect();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -112,13 +96,7 @@ function DataSourceInfo({
|
||||
<div className="workspace-url">
|
||||
<Link2 size={12} />
|
||||
|
||||
<Typography className="workspace-url-text">
|
||||
{url}
|
||||
|
||||
<Tag color="default" className="workspace-url-tag">
|
||||
default
|
||||
</Tag>
|
||||
</Typography>
|
||||
<span className="workspace-url-text">{url}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -130,9 +108,9 @@ function DataSourceInfo({
|
||||
|
||||
const renderDataReceived = (): JSX.Element => (
|
||||
<>
|
||||
<Typography className="welcome-title">
|
||||
<h2 className="welcome-title">
|
||||
Hello there, Welcome to your SigNoz workspace
|
||||
</Typography>
|
||||
</h2>
|
||||
|
||||
{!isError && hostsData && (
|
||||
<Card className="welcome-card">
|
||||
@@ -142,13 +120,7 @@ function DataSourceInfo({
|
||||
<div className="workspace-url">
|
||||
<Link2 size={12} />
|
||||
|
||||
<Typography className="workspace-url-text">
|
||||
{url}
|
||||
|
||||
<Tag color="default" className="workspace-url-tag">
|
||||
default
|
||||
</Tag>
|
||||
</Typography>
|
||||
<span className="workspace-url-text">{url}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ const mockHostsResponse: GetHosts200 = {
|
||||
describe('DataSourceInfo', () => {
|
||||
afterEach(() => server.resetHandlers());
|
||||
|
||||
it('renders the default workspace URL with protocol stripped', async () => {
|
||||
it('renders the active workspace URL with protocol stripped', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
@@ -39,7 +39,7 @@ describe('DataSourceInfo', () => {
|
||||
|
||||
render(<DataSourceInfo dataSentToSigNoz={false} isLoading={false} />);
|
||||
|
||||
await screen.findByText(/accepted-starfish\.test\.cloud/i);
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
});
|
||||
|
||||
it('does not render workspace URL when GET /zeus/hosts fails', async () => {
|
||||
@@ -55,7 +55,7 @@ describe('DataSourceInfo', () => {
|
||||
expect(screen.queryByText(/signoz\.cloud/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders workspace URL in the data-received view when telemetry is flowing', async () => {
|
||||
it('renders active workspace URL in the data-received view when telemetry is flowing', async () => {
|
||||
server.use(
|
||||
rest.get(ZEUS_HOSTS_ENDPOINT, (_, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockHostsResponse)),
|
||||
@@ -64,6 +64,6 @@ describe('DataSourceInfo', () => {
|
||||
|
||||
render(<DataSourceInfo dataSentToSigNoz={true} isLoading={false} />);
|
||||
|
||||
await screen.findByText(/accepted-starfish\.test\.cloud/i);
|
||||
await screen.findByText(/custom-host\.test\.cloud/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,19 +251,11 @@
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.workspace-url-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: 0.12px;
|
||||
|
||||
border-radius: 3px;
|
||||
|
||||
border: 1px solid var(--Slate-400, #1d212d);
|
||||
background: var(--Ink-400, #121317);
|
||||
color: var(--Vanilla-400, #c0c1c3);
|
||||
}
|
||||
font-size: var(--paragraph-small-400-font-size);
|
||||
font-weight: var(--paragraph-small-400-font-weight);
|
||||
line-height: var(--paragraph-small-400-line-height);
|
||||
letter-spacing: 0.12px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ function MySettings(): JSX.Element {
|
||||
<div className="my-settings-container">
|
||||
<div className="user-info-section">
|
||||
<div className="user-info-section-header">
|
||||
<div className="user-info-section-title">General </div>
|
||||
<div className="user-info-section-title">Account </div>
|
||||
|
||||
<div className="user-info-section-subtitle">
|
||||
Manage your account settings.
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
.permission-side-panel-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.permission-side-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 101;
|
||||
width: 720px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--l2-background);
|
||||
border-left: 1px solid var(--l2-border);
|
||||
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
height: 48px;
|
||||
padding: 0 16px;
|
||||
background: var(--l2-background);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
&__close {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
color: var(--foreground);
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-base-white);
|
||||
}
|
||||
}
|
||||
|
||||
&__header-divider {
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--l2-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
&__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 15px;
|
||||
}
|
||||
|
||||
&__resource-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
height: 56px;
|
||||
padding: 0 16px;
|
||||
gap: 12px;
|
||||
background: var(--l2-background);
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
&__unsaved {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
&__unsaved-dot {
|
||||
display: block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--primary);
|
||||
box-shadow: 0px 0px 6px 0px rgba(78, 116, 248, 0.4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__unsaved-text {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
&__footer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.psp-resource {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&--expanded {
|
||||
background: rgba(171, 189, 255, 0.04);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(171, 189, 255, 0.03);
|
||||
}
|
||||
}
|
||||
|
||||
&__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
&__chevron {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--foreground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--text-base-white);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 0 8px 44px;
|
||||
background: rgba(171, 189, 255, 0.04);
|
||||
}
|
||||
|
||||
&__radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
&__radio-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
|
||||
label {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--text-base-white);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&__select-wrapper {
|
||||
padding: 6px 16px 4px 24px;
|
||||
}
|
||||
|
||||
&__select {
|
||||
width: 100%;
|
||||
|
||||
// todo: https://github.com/SigNoz/components/issues/116
|
||||
.ant-select-selector {
|
||||
background: var(--l2-background) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: 2px !important;
|
||||
padding: 4px 6px !important;
|
||||
min-height: 32px !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
border-color: var(--input) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-selection-placeholder {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--foreground);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
background: var(--input) !important;
|
||||
border: none !important;
|
||||
border-radius: 2px !important;
|
||||
padding: 0 6px !important;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
color: var(--text-base-white) !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.ant-select-selection-item-remove {
|
||||
color: var(--foreground) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ant-select-arrow {
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
&__select-popup {
|
||||
.ant-select-item {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: var(--foreground);
|
||||
background: var(--l2-background);
|
||||
|
||||
&-option-selected {
|
||||
background: var(--border) !important;
|
||||
color: var(--text-base-white) !important;
|
||||
}
|
||||
|
||||
&-option-active {
|
||||
background: var(--l2-background-hover) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-dropdown {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.permission-side-panel {
|
||||
&__close {
|
||||
&:hover {
|
||||
color: var(--text-base-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.psp-resource {
|
||||
&__label {
|
||||
color: var(--text-base-black);
|
||||
}
|
||||
|
||||
&__radio-item label {
|
||||
color: var(--text-base-black);
|
||||
}
|
||||
|
||||
&__select .ant-select-selection-item {
|
||||
color: var(--text-base-black) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.psp-resource__select-popup {
|
||||
.ant-select-item {
|
||||
&-option-selected {
|
||||
color: var(--text-base-black) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { ChevronDown, ChevronRight, X } from '@signozhq/icons';
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
RadioGroupLabel,
|
||||
} from '@signozhq/radio-group';
|
||||
import { Select, Skeleton } from 'antd';
|
||||
|
||||
import {
|
||||
buildConfig,
|
||||
configsEqual,
|
||||
DEFAULT_RESOURCE_CONFIG,
|
||||
isResourceConfigEqual,
|
||||
} from '../utils';
|
||||
import type {
|
||||
PermissionConfig,
|
||||
PermissionSidePanelProps,
|
||||
ResourceConfig,
|
||||
ResourceDefinition,
|
||||
ScopeType,
|
||||
} from './PermissionSidePanel.types';
|
||||
import { PermissionScope } from './PermissionSidePanel.types';
|
||||
|
||||
import './PermissionSidePanel.styles.scss';
|
||||
|
||||
interface ResourceRowProps {
|
||||
resource: ResourceDefinition;
|
||||
config: ResourceConfig;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: (id: string) => void;
|
||||
onScopeChange: (id: string, scope: ScopeType) => void;
|
||||
onSelectedIdsChange: (id: string, ids: string[]) => void;
|
||||
}
|
||||
|
||||
function ResourceRow({
|
||||
resource,
|
||||
config,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onScopeChange,
|
||||
onSelectedIdsChange,
|
||||
}: ResourceRowProps): JSX.Element {
|
||||
return (
|
||||
<div className="psp-resource">
|
||||
<div
|
||||
className={`psp-resource__row${
|
||||
isExpanded ? ' psp-resource__row--expanded' : ''
|
||||
}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(): void => onToggleExpand(resource.id)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onToggleExpand(resource.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="psp-resource__left">
|
||||
<span className="psp-resource__chevron">
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
<span className="psp-resource__label">{resource.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="psp-resource__body">
|
||||
<RadioGroup
|
||||
value={config.scope}
|
||||
onValueChange={(val): void =>
|
||||
onScopeChange(resource.id, val as ScopeType)
|
||||
}
|
||||
className="psp-resource__radio-group"
|
||||
>
|
||||
<div className="psp-resource__radio-item">
|
||||
<RadioGroupItem
|
||||
value={PermissionScope.ALL}
|
||||
id={`${resource.id}-all`}
|
||||
color="robin"
|
||||
/>
|
||||
<RadioGroupLabel htmlFor={`${resource.id}-all`}>All</RadioGroupLabel>
|
||||
</div>
|
||||
|
||||
<div className="psp-resource__radio-item">
|
||||
<RadioGroupItem
|
||||
value={PermissionScope.ONLY_SELECTED}
|
||||
id={`${resource.id}-only-selected`}
|
||||
color="robin"
|
||||
/>
|
||||
<RadioGroupLabel htmlFor={`${resource.id}-only-selected`}>
|
||||
Only selected
|
||||
</RadioGroupLabel>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{config.scope === PermissionScope.ONLY_SELECTED && (
|
||||
<div className="psp-resource__select-wrapper">
|
||||
{/* TODO: right now made to only accept user input, we need to give it proper resource based value fetching from APIs */}
|
||||
<Select
|
||||
mode="tags"
|
||||
value={config.selectedIds}
|
||||
onChange={(vals: string[]): void =>
|
||||
onSelectedIdsChange(resource.id, vals)
|
||||
}
|
||||
options={resource.options ?? []}
|
||||
placeholder="Select resources..."
|
||||
className="psp-resource__select"
|
||||
popupClassName="psp-resource__select-popup"
|
||||
showSearch
|
||||
filterOption={(input, option): boolean =>
|
||||
String(option?.label ?? '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionSidePanel({
|
||||
open,
|
||||
onClose,
|
||||
permissionLabel,
|
||||
resources,
|
||||
initialConfig,
|
||||
isLoading = false,
|
||||
isSaving = false,
|
||||
onSave,
|
||||
}: PermissionSidePanelProps): JSX.Element | null {
|
||||
const [config, setConfig] = useState<PermissionConfig>(() =>
|
||||
buildConfig(resources, initialConfig),
|
||||
);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setConfig(buildConfig(resources, initialConfig));
|
||||
setExpandedIds(new Set());
|
||||
}
|
||||
}, [open, resources, initialConfig]);
|
||||
|
||||
const savedConfig = useMemo(() => buildConfig(resources, initialConfig), [
|
||||
resources,
|
||||
initialConfig,
|
||||
]);
|
||||
|
||||
const unsavedCount = useMemo(() => {
|
||||
if (configsEqual(config, savedConfig)) {
|
||||
return 0;
|
||||
}
|
||||
return Object.keys(config).filter(
|
||||
(id) => !isResourceConfigEqual(config[id], savedConfig[id]),
|
||||
).length;
|
||||
}, [config, savedConfig]);
|
||||
|
||||
const updateResource = useCallback(
|
||||
(id: string, patch: Partial<ResourceConfig>): void => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
[id]: { ...prev[id], ...patch },
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleToggleExpand = useCallback((id: string): void => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleScopeChange = useCallback(
|
||||
(id: string, scope: ScopeType): void => {
|
||||
updateResource(id, { scope, selectedIds: [] });
|
||||
},
|
||||
[updateResource],
|
||||
);
|
||||
|
||||
const handleSelectedIdsChange = useCallback(
|
||||
(id: string, ids: string[]): void => {
|
||||
updateResource(id, { selectedIds: ids });
|
||||
},
|
||||
[updateResource],
|
||||
);
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
onSave(config);
|
||||
}, [config, onSave]);
|
||||
|
||||
const handleDiscard = useCallback((): void => {
|
||||
setConfig(buildConfig(resources, initialConfig));
|
||||
setExpandedIds(new Set());
|
||||
}, [resources, initialConfig]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="permission-side-panel-backdrop"
|
||||
role="presentation"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<div className="permission-side-panel">
|
||||
<div className="permission-side-panel__header">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="permission-side-panel__close"
|
||||
onClick={onClose}
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
<span className="permission-side-panel__header-divider" />
|
||||
<span className="permission-side-panel__title">
|
||||
Edit {permissionLabel} Permissions
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="permission-side-panel__content">
|
||||
{isLoading ? (
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
) : (
|
||||
<div className="permission-side-panel__resource-list">
|
||||
{resources.map((resource) => (
|
||||
<ResourceRow
|
||||
key={resource.id}
|
||||
resource={resource}
|
||||
config={config[resource.id] ?? DEFAULT_RESOURCE_CONFIG}
|
||||
isExpanded={expandedIds.has(resource.id)}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onScopeChange={handleScopeChange}
|
||||
onSelectedIdsChange={handleSelectedIdsChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="permission-side-panel__footer">
|
||||
{unsavedCount > 0 && (
|
||||
<div className="permission-side-panel__unsaved">
|
||||
<span className="permission-side-panel__unsaved-dot" />
|
||||
<span className="permission-side-panel__unsaved-text">
|
||||
{unsavedCount} unsaved change{unsavedCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="permission-side-panel__footer-actions">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefixIcon={<X size={14} />}
|
||||
onClick={unsavedCount > 0 ? handleDiscard : onClose}
|
||||
size="sm"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{unsavedCount > 0 ? 'Discard' : 'Cancel'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
disabled={isLoading || unsavedCount === 0}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionSidePanel;
|
||||
@@ -0,0 +1,35 @@
|
||||
export interface ResourceOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ResourceDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
options?: ResourceOption[];
|
||||
}
|
||||
|
||||
export enum PermissionScope {
|
||||
ALL = 'all',
|
||||
ONLY_SELECTED = 'only_selected',
|
||||
}
|
||||
|
||||
export type ScopeType = PermissionScope;
|
||||
|
||||
export interface ResourceConfig {
|
||||
scope: ScopeType;
|
||||
selectedIds: string[];
|
||||
}
|
||||
|
||||
export type PermissionConfig = Record<string, ResourceConfig>;
|
||||
|
||||
export interface PermissionSidePanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
permissionLabel: string;
|
||||
resources: ResourceDefinition[];
|
||||
initialConfig?: PermissionConfig;
|
||||
isLoading?: boolean;
|
||||
isSaving?: boolean;
|
||||
onSave: (config: PermissionConfig) => void;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { default } from './PermissionSidePanel';
|
||||
export type {
|
||||
PermissionConfig,
|
||||
PermissionSidePanelProps,
|
||||
ResourceConfig,
|
||||
ResourceDefinition,
|
||||
ResourceOption,
|
||||
ScopeType,
|
||||
} from './PermissionSidePanel.types';
|
||||
export { PermissionScope } from './PermissionSidePanel.types';
|
||||
@@ -0,0 +1,417 @@
|
||||
.role-details-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
width: 100%;
|
||||
max-width: 60vw;
|
||||
margin: 0 auto;
|
||||
|
||||
.role-details-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.role-details-title {
|
||||
color: var(--text-base-white);
|
||||
font-family: Inter;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
line-height: 28px;
|
||||
letter-spacing: -0.09px;
|
||||
}
|
||||
|
||||
.role-details-permission-item--readonly {
|
||||
cursor: default !important;
|
||||
pointer-events: none;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.role-details-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.role-details-tab {
|
||||
gap: 4px;
|
||||
padding: 0 16px;
|
||||
height: 32px;
|
||||
border-radius: 0;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.06px;
|
||||
|
||||
&[data-state='on'] {
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.role-details-tab-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 50px;
|
||||
background: var(--secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
color: var(--foreground);
|
||||
letter-spacing: -0.06px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.role-details-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.role-details-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.role-details-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.role-details-section-label {
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: 0.48px;
|
||||
text-transform: uppercase;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.role-details-description-text {
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.role-details-info-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.role-details-info-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.role-details-info-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.role-details-info-name {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.role-details-permissions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.role-details-permissions-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.role-details-permissions-divider {
|
||||
flex: 1;
|
||||
border: none;
|
||||
border-top: 2px dotted var(--border);
|
||||
border-bottom: 2px dotted var(--border);
|
||||
height: 7px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.role-details-permission-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.role-details-permission-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
background: rgba(171, 189, 255, 0.08);
|
||||
border: 1px solid var(--secondary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(171, 189, 255, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.role-details-permission-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.role-details-permission-item-label {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--text-base-white);
|
||||
}
|
||||
|
||||
.role-details-members {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.role-details-members-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 6px 6px 6px 8px;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--secondary);
|
||||
border-radius: 2px;
|
||||
|
||||
.role-details-members-search-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.role-details-members-search-input {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--foreground);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--foreground);
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.role-details-members-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 420px;
|
||||
border: 1px dashed var(--secondary);
|
||||
border-radius: 3px;
|
||||
margin-top: -1px;
|
||||
}
|
||||
|
||||
.role-details-members-empty-state {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 48px 0;
|
||||
flex-grow: 1;
|
||||
|
||||
.role-details-members-empty-emoji {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.role-details-members-empty-text {
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
&--bold {
|
||||
font-weight: 500;
|
||||
color: var(--text-base-white);
|
||||
}
|
||||
|
||||
&--muted {
|
||||
font-weight: 400;
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.role-details-skeleton {
|
||||
padding: 16px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.role-details-delete-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
color: var(--destructive);
|
||||
opacity: 0.6;
|
||||
padding: 0;
|
||||
transition: background-color 0.2s, opacity 0.2s;
|
||||
box-shadow: none;
|
||||
|
||||
&:hover {
|
||||
background: rgba(229, 72, 77, 0.1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.role-details-delete-modal {
|
||||
width: calc(100% - 30px) !important;
|
||||
max-width: 384px;
|
||||
|
||||
.ant-modal-content {
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--secondary);
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0px -4px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
|
||||
.ant-modal-header {
|
||||
padding: 16px;
|
||||
background: var(--l2-background);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 0 16px 28px 16px;
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
|
||||
.cancel-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--text-base-white);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.065px;
|
||||
}
|
||||
|
||||
.delete-text {
|
||||
color: var(--foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
color: var(--text-base-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.role-details-delete-modal {
|
||||
.ant-modal-content {
|
||||
.ant-modal-header {
|
||||
.title {
|
||||
color: var(--text-base-black);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
.delete-text {
|
||||
strong {
|
||||
color: var(--text-base-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.role-details-page {
|
||||
.role-details-title {
|
||||
color: var(--text-base-black);
|
||||
}
|
||||
|
||||
.role-details-members-empty-state {
|
||||
.role-details-members-empty-text--bold {
|
||||
color: var(--text-base-black);
|
||||
}
|
||||
}
|
||||
|
||||
.role-details-permission-item {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.07);
|
||||
}
|
||||
}
|
||||
|
||||
.role-details-permission-item-label {
|
||||
color: var(--text-base-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Table2, Trash2, Users } from '@signozhq/icons';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@signozhq/toggle-group';
|
||||
import { Skeleton } from 'antd';
|
||||
import { useAuthzResources } from 'api/generated/services/authz';
|
||||
import {
|
||||
getGetObjectsQueryKey,
|
||||
useDeleteRole,
|
||||
useGetObjects,
|
||||
useGetRole,
|
||||
usePatchObjects,
|
||||
} from 'api/generated/services/role';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { RoleType } from 'types/roles';
|
||||
import { handleApiError, toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { IS_ROLE_DETAILS_AND_CRUD_ENABLED } from '../config';
|
||||
import type { PermissionConfig } from '../PermissionSidePanel';
|
||||
import PermissionSidePanel from '../PermissionSidePanel';
|
||||
import CreateRoleModal from '../RolesComponents/CreateRoleModal';
|
||||
import DeleteRoleModal from '../RolesComponents/DeleteRoleModal';
|
||||
import {
|
||||
buildPatchPayload,
|
||||
derivePermissionTypes,
|
||||
deriveResourcesForRelation,
|
||||
objectsToPermissionConfig,
|
||||
} from '../utils';
|
||||
import MembersTab from './components/MembersTab';
|
||||
import OverviewTab from './components/OverviewTab';
|
||||
import { ROLE_ID_REGEX } from './constants';
|
||||
|
||||
import './RoleDetailsPage.styles.scss';
|
||||
|
||||
type TabKey = 'overview' | 'members';
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function RoleDetailsPage(): JSX.Element {
|
||||
const { pathname } = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_ROLE_DETAILS_AND_CRUD_ENABLED) {
|
||||
history.push(ROUTES.ROLES_SETTINGS);
|
||||
}
|
||||
}, [history]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const { data: authzResourcesResponse } = useAuthzResources({
|
||||
query: { enabled: true },
|
||||
});
|
||||
const authzResources = authzResourcesResponse?.data ?? null;
|
||||
|
||||
// Extract channelId from URL pathname since useParams doesn't work in nested routing
|
||||
const roleIdMatch = pathname.match(ROLE_ID_REGEX);
|
||||
const roleId = roleIdMatch ? roleIdMatch[1] : '';
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('overview');
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [activePermission, setActivePermission] = useState<string | null>(null);
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetRole(
|
||||
{ id: roleId },
|
||||
{ query: { enabled: !!roleId } },
|
||||
);
|
||||
const role = data?.data;
|
||||
const isTransitioning = isFetching && role?.id !== roleId;
|
||||
const isManaged = role?.type === RoleType.MANAGED;
|
||||
|
||||
const permissionTypes = useMemo(
|
||||
() => derivePermissionTypes(authzResources?.relations ?? null),
|
||||
[authzResources],
|
||||
);
|
||||
|
||||
const resourcesForActivePermission = useMemo(
|
||||
() =>
|
||||
activePermission
|
||||
? deriveResourcesForRelation(authzResources ?? null, activePermission)
|
||||
: [],
|
||||
[authzResources, activePermission],
|
||||
);
|
||||
|
||||
const { data: objectsData, isLoading: isLoadingObjects } = useGetObjects(
|
||||
{ id: roleId, relation: activePermission ?? '' },
|
||||
{ query: { enabled: !!activePermission && !!roleId && !isManaged } },
|
||||
);
|
||||
|
||||
const initialConfig = useMemo(() => {
|
||||
if (!objectsData?.data || !activePermission) {
|
||||
return undefined;
|
||||
}
|
||||
return objectsToPermissionConfig(
|
||||
objectsData.data,
|
||||
resourcesForActivePermission,
|
||||
);
|
||||
}, [objectsData, activePermission, resourcesForActivePermission]);
|
||||
|
||||
const handleSaveSuccess = (): void => {
|
||||
toast.success('Permissions saved successfully');
|
||||
if (activePermission) {
|
||||
queryClient.removeQueries(
|
||||
getGetObjectsQueryKey({ id: roleId, relation: activePermission }),
|
||||
);
|
||||
}
|
||||
setActivePermission(null);
|
||||
};
|
||||
|
||||
const { mutate: patchObjects, isLoading: isSaving } = usePatchObjects({
|
||||
mutation: {
|
||||
onSuccess: handleSaveSuccess,
|
||||
onError: (err) => handleApiError(err, showErrorModal),
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: deleteRole, isLoading: isDeleting } = useDeleteRole({
|
||||
mutation: {
|
||||
onSuccess: (): void => {
|
||||
toast.success('Role deleted successfully');
|
||||
history.push(ROUTES.ROLES_SETTINGS);
|
||||
},
|
||||
onError: (err) => handleApiError(err, showErrorModal),
|
||||
},
|
||||
});
|
||||
|
||||
if (!IS_ROLE_DETAILS_AND_CRUD_ENABLED || isLoading || isTransitioning) {
|
||||
return (
|
||||
<div className="role-details-page">
|
||||
<Skeleton
|
||||
active
|
||||
paragraph={{ rows: 8 }}
|
||||
className="role-details-skeleton"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="role-details-page">
|
||||
<ErrorInPlace
|
||||
error={toAPIError(
|
||||
error,
|
||||
'An unexpected error occurred while fetching role details.',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!role) {
|
||||
return (
|
||||
<div className="role-details-page">
|
||||
<Skeleton
|
||||
active
|
||||
paragraph={{ rows: 8 }}
|
||||
className="role-details-skeleton"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSave = (config: PermissionConfig): void => {
|
||||
if (!activePermission || !authzResources) {
|
||||
return;
|
||||
}
|
||||
patchObjects({
|
||||
pathParams: { id: roleId, relation: activePermission },
|
||||
data: buildPatchPayload({
|
||||
newConfig: config,
|
||||
initialConfig: initialConfig ?? {},
|
||||
resources: resourcesForActivePermission,
|
||||
authzRes: authzResources,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="role-details-page">
|
||||
<div className="role-details-header">
|
||||
<h2 className="role-details-title">Role — {role.name}</h2>
|
||||
</div>
|
||||
|
||||
<div className="role-details-nav">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={activeTab}
|
||||
onValueChange={(val): void => {
|
||||
if (val) {
|
||||
setActiveTab(val as TabKey);
|
||||
}
|
||||
}}
|
||||
className="role-details-tabs"
|
||||
>
|
||||
<ToggleGroupItem value="overview" className="role-details-tab">
|
||||
<Table2 size={14} />
|
||||
Overview
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="members" className="role-details-tab">
|
||||
<Users size={14} />
|
||||
Members
|
||||
<span className="role-details-tab-count">0</span>
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
|
||||
{!isManaged && (
|
||||
<div className="role-details-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
className="role-details-delete-action-btn"
|
||||
onClick={(): void => setIsDeleteModalOpen(true)}
|
||||
aria-label="Delete role"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => setIsEditModalOpen(true)}
|
||||
>
|
||||
Edit Role Details
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' && (
|
||||
<OverviewTab
|
||||
role={role || null}
|
||||
isManaged={isManaged}
|
||||
permissionTypes={permissionTypes}
|
||||
onPermissionClick={(key): void => setActivePermission(key)}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'members' && <MembersTab />}
|
||||
|
||||
{!isManaged && (
|
||||
<>
|
||||
<PermissionSidePanel
|
||||
open={activePermission !== null}
|
||||
onClose={(): void => setActivePermission(null)}
|
||||
permissionLabel={activePermission ? capitalize(activePermission) : ''}
|
||||
resources={resourcesForActivePermission}
|
||||
initialConfig={initialConfig}
|
||||
isLoading={isLoadingObjects}
|
||||
isSaving={isSaving}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
<CreateRoleModal
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={(): void => setIsEditModalOpen(false)}
|
||||
initialData={{
|
||||
id: roleId,
|
||||
name: role.name || '',
|
||||
description: role.description || '',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DeleteRoleModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
roleName={role.name || ''}
|
||||
isDeleting={isDeleting}
|
||||
onCancel={(): void => setIsDeleteModalOpen(false)}
|
||||
onConfirm={(): void => deleteRole({ pathParams: { id: roleId } })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RoleDetailsPage;
|
||||
@@ -0,0 +1,438 @@
|
||||
// Ungate feature flag for all tests in this file
|
||||
jest.mock('../../config', () => ({ IS_ROLE_DETAILS_AND_CRUD_ENABLED: true }));
|
||||
|
||||
import {
|
||||
customRoleResponse,
|
||||
managedRoleResponse,
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
|
||||
|
||||
import RoleDetailsPage from '../RoleDetailsPage';
|
||||
|
||||
const CUSTOM_ROLE_ID = '019c24aa-3333-0001-aaaa-111111111111';
|
||||
const MANAGED_ROLE_ID = '019c24aa-2248-756f-9833-984f1ab63819';
|
||||
|
||||
const rolesApiBase = 'http://localhost/api/v1/roles';
|
||||
const authzResourcesUrl = 'http://localhost/api/v1/authz/resources';
|
||||
|
||||
const authzResourcesResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
relations: { create: ['dashboard'], read: ['dashboard'] },
|
||||
resources: [{ name: 'dashboard', type: 'dashboard' }],
|
||||
},
|
||||
};
|
||||
|
||||
const emptyObjectsResponse = { status: 'success', data: [] };
|
||||
|
||||
const allScopeObjectsResponse = {
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
resource: { name: 'dashboard', type: 'dashboard' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function setupDefaultHandlers(roleId = CUSTOM_ROLE_ID): void {
|
||||
const roleResponse =
|
||||
roleId === MANAGED_ROLE_ID ? managedRoleResponse : customRoleResponse;
|
||||
|
||||
server.use(
|
||||
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(roleResponse)),
|
||||
),
|
||||
rest.get(authzResourcesUrl, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(authzResourcesResponse)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('RoleDetailsPage', () => {
|
||||
it('renders custom role header, tabs, description, permissions, and action buttons', async () => {
|
||||
setupDefaultHandlers();
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Role — billing-manager')).toBeInTheDocument();
|
||||
|
||||
// Tab navigation
|
||||
expect(screen.getByText('Overview')).toBeInTheDocument();
|
||||
expect(screen.getByText('Members')).toBeInTheDocument();
|
||||
|
||||
// Role description (OverviewTab)
|
||||
expect(
|
||||
screen.getByText('Custom role for managing billing and invoices.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Permission items derived from mocked authz relations
|
||||
expect(screen.getByText('Create')).toBeInTheDocument();
|
||||
expect(screen.getByText('Read')).toBeInTheDocument();
|
||||
|
||||
// Action buttons present for custom role
|
||||
expect(
|
||||
screen.getByRole('button', { name: /edit role details/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /delete role/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows managed-role warning callout and hides edit/delete buttons', async () => {
|
||||
setupDefaultHandlers(MANAGED_ROLE_ID);
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${MANAGED_ROLE_ID}`,
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/Role — signoz-admin/)).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'This is a managed role. Permissions and settings are view-only and cannot be modified.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Action buttons absent for managed role
|
||||
expect(screen.queryByText('Edit Role Details')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /delete role/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('edit flow: modal opens pre-filled and calls PATCH on save and verify', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
let description = customRoleResponse.data.description;
|
||||
server.use(
|
||||
rest.get(`${rolesApiBase}/:id`, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
...customRoleResponse,
|
||||
data: { ...customRoleResponse.data, description },
|
||||
}),
|
||||
),
|
||||
),
|
||||
rest.patch(`${rolesApiBase}/:id`, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
patchSpy(body);
|
||||
description = body.description;
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
...customRoleResponse,
|
||||
data: { ...customRoleResponse.data, description },
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await screen.findByText('Role — billing-manager');
|
||||
|
||||
// Open the edit modal
|
||||
await user.click(screen.getByRole('button', { name: /edit role details/i }));
|
||||
expect(
|
||||
await screen.findByText('Edit Role Details', {
|
||||
selector: '.ant-modal-title',
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Name field is disabled in edit mode (role rename is not allowed)
|
||||
const nameInput = screen.getByPlaceholderText(
|
||||
'Enter role name e.g. : Service Owner',
|
||||
);
|
||||
expect(nameInput).toBeDisabled();
|
||||
|
||||
// Update description and save
|
||||
const descField = screen.getByPlaceholderText(
|
||||
'A helpful description of the role',
|
||||
);
|
||||
await user.clear(descField);
|
||||
await user.type(descField, 'Updated description');
|
||||
await user.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith({
|
||||
description: 'Updated description',
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText('Edit Role Details', {
|
||||
selector: '.ant-modal-title',
|
||||
}),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(await screen.findByText('Updated description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('delete flow: modal shows role name, DELETE called on confirm', async () => {
|
||||
const deleteSpy = jest.fn();
|
||||
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.delete(`${rolesApiBase}/:id`, (_req, res, ctx) => {
|
||||
deleteSpy();
|
||||
return res(ctx.status(200), ctx.json({ status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await screen.findByText('Role — billing-manager');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /delete role/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText(/Are you sure you want to delete the role/),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
await user.click(
|
||||
within(dialog).getByRole('button', { name: /delete role/i }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(deleteSpy).toHaveBeenCalled());
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText(/Are you sure you want to delete the role/),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
describe('permission side panel', () => {
|
||||
async function openCreatePanel(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
): Promise<void> {
|
||||
await screen.findByText('Role — billing-manager');
|
||||
await user.click(screen.getByText('Create'));
|
||||
await screen.findByText('Edit Create Permissions');
|
||||
await screen.findByRole('button', { name: /dashboard/i });
|
||||
}
|
||||
|
||||
it('Save Changes is disabled until a resource scope is changed', async () => {
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await openCreatePanel(user);
|
||||
|
||||
// No change yet — config matches initial, unsavedCount = 0
|
||||
expect(screen.getByRole('button', { name: /save changes/i })).toBeDisabled();
|
||||
|
||||
// Expand Dashboard and flip to All — now Save is enabled
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
await user.click(screen.getByText('All'));
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /save changes/i }),
|
||||
).not.toBeDisabled();
|
||||
|
||||
// check for what shown now - unsavedCount = 1
|
||||
expect(screen.getByText('1 unsaved change')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('set scope to All → patchObjects additions: ["*"], deletions: null', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
|
||||
),
|
||||
rest.patch(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
async (req, res, ctx) => {
|
||||
patchSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await openCreatePanel(user);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
await user.click(screen.getByText('All'));
|
||||
await user.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith({
|
||||
additions: [
|
||||
{
|
||||
resource: { name: 'dashboard', type: 'dashboard' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
],
|
||||
deletions: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('set scope to Only selected with IDs → patchObjects additions contain those IDs', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
|
||||
),
|
||||
rest.patch(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
async (req, res, ctx) => {
|
||||
patchSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await openCreatePanel(user);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
|
||||
const combobox = screen.getByRole('combobox');
|
||||
await user.click(combobox);
|
||||
await user.type(combobox, 'dash-1');
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith({
|
||||
additions: [
|
||||
{
|
||||
resource: { name: 'dashboard', type: 'dashboard' },
|
||||
selectors: ['dash-1'],
|
||||
},
|
||||
],
|
||||
deletions: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('existing All scope changed to Only selected (empty) → patchObjects deletions: ["*"], additions: null', async () => {
|
||||
const patchSpy = jest.fn();
|
||||
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(allScopeObjectsResponse)),
|
||||
),
|
||||
rest.patch(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
async (req, res, ctx) => {
|
||||
patchSpy(await req.json());
|
||||
return res(ctx.status(200), ctx.json({ status: 'success', data: null }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await openCreatePanel(user);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
|
||||
await user.click(screen.getByText('Only selected'));
|
||||
await user.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
// Should delete the '*' selector and add nothing
|
||||
await waitFor(() =>
|
||||
expect(patchSpy).toHaveBeenCalledWith({
|
||||
additions: null,
|
||||
deletions: [
|
||||
{
|
||||
resource: { name: 'dashboard', type: 'dashboard' },
|
||||
selectors: ['*'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('unsaved changes counter shown on scope change, Discard resets it', async () => {
|
||||
setupDefaultHandlers();
|
||||
server.use(
|
||||
rest.get(
|
||||
`${rolesApiBase}/:id/relation/:relation/objects`,
|
||||
(_req, res, ctx) => res(ctx.status(200), ctx.json(emptyObjectsResponse)),
|
||||
),
|
||||
);
|
||||
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(<RoleDetailsPage />, undefined, {
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
});
|
||||
|
||||
await openCreatePanel(user);
|
||||
|
||||
// No unsaved changes indicator yet
|
||||
expect(screen.queryByText(/unsaved change/)).not.toBeInTheDocument();
|
||||
|
||||
// Change dashboard scope to "All"
|
||||
await user.click(screen.getByRole('button', { name: /dashboard/i }));
|
||||
await user.click(screen.getByText('All'));
|
||||
|
||||
expect(screen.getByText('1 unsaved change')).toBeInTheDocument();
|
||||
|
||||
// Discard reverts to initial config — counter disappears, Save re-disabled
|
||||
await user.click(screen.getByRole('button', { name: /discard/i }));
|
||||
|
||||
expect(screen.queryByText(/unsaved change/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /save changes/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from 'react';
|
||||
import { Search } from '@signozhq/icons';
|
||||
|
||||
function MembersTab(): JSX.Element {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
return (
|
||||
<div className="role-details-members">
|
||||
<div className="role-details-members-search">
|
||||
<Search size={12} className="role-details-members-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="role-details-members-search-input"
|
||||
placeholder="Search and add members..."
|
||||
value={searchQuery}
|
||||
onChange={(e): void => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Todo: Right now we are only adding the empty state in this cut */}
|
||||
<div className="role-details-members-content">
|
||||
<div className="role-details-members-empty-state">
|
||||
<span
|
||||
className="role-details-members-empty-emoji"
|
||||
role="img"
|
||||
aria-label="monocle face"
|
||||
>
|
||||
🧐
|
||||
</span>
|
||||
<p className="role-details-members-empty-text">
|
||||
<span className="role-details-members-empty-text--bold">
|
||||
No members added.
|
||||
</span>{' '}
|
||||
<span className="role-details-members-empty-text--muted">
|
||||
Start adding members to this role.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MembersTab;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Callout } from '@signozhq/callout';
|
||||
|
||||
import { PermissionType, TimestampBadge } from '../../utils';
|
||||
import PermissionItem from './PermissionItem';
|
||||
|
||||
interface OverviewTabProps {
|
||||
role: {
|
||||
description?: string;
|
||||
createdAt?: Date | string;
|
||||
updatedAt?: Date | string;
|
||||
} | null;
|
||||
isManaged: boolean;
|
||||
permissionTypes: PermissionType[];
|
||||
onPermissionClick: (relationKey: string) => void;
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
role,
|
||||
isManaged,
|
||||
permissionTypes,
|
||||
onPermissionClick,
|
||||
}: OverviewTabProps): JSX.Element {
|
||||
return (
|
||||
<div className="role-details-overview">
|
||||
{isManaged && (
|
||||
<Callout
|
||||
type="warning"
|
||||
showIcon
|
||||
message="This is a managed role. Permissions and settings are view-only and cannot be modified."
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="role-details-meta">
|
||||
<div>
|
||||
<p className="role-details-section-label">Description</p>
|
||||
<p className="role-details-description-text">{role?.description || '—'}</p>
|
||||
</div>
|
||||
|
||||
<div className="role-details-info-row">
|
||||
<div className="role-details-info-col">
|
||||
<p className="role-details-section-label">Created At</p>
|
||||
<div className="role-details-info-value">
|
||||
<TimestampBadge date={role?.createdAt} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="role-details-info-col">
|
||||
<p className="role-details-section-label">Last Modified At</p>
|
||||
<div className="role-details-info-value">
|
||||
<TimestampBadge date={role?.updatedAt} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="role-details-permissions">
|
||||
<div className="role-details-permissions-header">
|
||||
<span className="role-details-section-label">Permissions</span>
|
||||
<hr className="role-details-permissions-divider" />
|
||||
</div>
|
||||
|
||||
<div className="role-details-permission-list">
|
||||
{permissionTypes.map((permissionType) => (
|
||||
<PermissionItem
|
||||
key={permissionType.key}
|
||||
permissionType={permissionType}
|
||||
isManaged={isManaged}
|
||||
onPermissionClick={onPermissionClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OverviewTab;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ChevronRight } from '@signozhq/icons';
|
||||
|
||||
import { PermissionType } from '../../utils';
|
||||
|
||||
interface PermissionItemProps {
|
||||
permissionType: PermissionType;
|
||||
isManaged: boolean;
|
||||
onPermissionClick: (key: string) => void;
|
||||
}
|
||||
|
||||
function PermissionItem({
|
||||
permissionType,
|
||||
isManaged,
|
||||
onPermissionClick,
|
||||
}: PermissionItemProps): JSX.Element {
|
||||
const { key, label, icon } = permissionType;
|
||||
|
||||
if (isManaged) {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="role-details-permission-item role-details-permission-item--readonly"
|
||||
>
|
||||
<div className="role-details-permission-item-left">
|
||||
{icon}
|
||||
<span className="role-details-permission-item-label">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="role-details-permission-item"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(): void => onPermissionClick(key)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onPermissionClick(key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="role-details-permission-item-left">
|
||||
{icon}
|
||||
<span className="role-details-permission-item-label">{label}</span>
|
||||
</div>
|
||||
<ChevronRight size={14} color="var(--foreground)" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PermissionItem;
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
BadgePlus,
|
||||
Eye,
|
||||
LayoutList,
|
||||
PencilRuler,
|
||||
Settings,
|
||||
Trash2,
|
||||
} from '@signozhq/icons';
|
||||
|
||||
export const ROLE_ID_REGEX = /\/settings\/roles\/([^/]+)/;
|
||||
|
||||
export type IconComponent = React.ComponentType<any>;
|
||||
|
||||
export const PERMISSION_ICON_MAP: Record<string, IconComponent> = {
|
||||
create: BadgePlus,
|
||||
list: LayoutList,
|
||||
read: Eye,
|
||||
update: PencilRuler,
|
||||
delete: Trash2,
|
||||
};
|
||||
|
||||
export const FALLBACK_PERMISSION_ICON: IconComponent = Settings;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './RoleDetailsPage';
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { generatePath, useHistory } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Input, inputVariants } from '@signozhq/input';
|
||||
import { toast } from '@signozhq/sonner';
|
||||
import { Form, Modal } from 'antd';
|
||||
import {
|
||||
invalidateGetRole,
|
||||
invalidateListRoles,
|
||||
useCreateRole,
|
||||
usePatchRole,
|
||||
} from 'api/generated/services/role';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
RoletypesPostableRoleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { handleApiError } from 'utils/errorUtils';
|
||||
|
||||
import '../RolesSettings.styles.scss';
|
||||
|
||||
export interface CreateRoleModalInitialData {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface CreateRoleModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialData?: CreateRoleModalInitialData;
|
||||
}
|
||||
|
||||
interface CreateRoleFormValues {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
function CreateRoleModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialData,
|
||||
}: CreateRoleModalProps): JSX.Element {
|
||||
const [form] = Form.useForm<CreateRoleFormValues>();
|
||||
const queryClient = useQueryClient();
|
||||
const history = useHistory();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const isEditMode = !!initialData?.id;
|
||||
const prevIsOpen = useRef(isOpen);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !prevIsOpen.current) {
|
||||
if (isEditMode && initialData) {
|
||||
form.setFieldsValue({
|
||||
name: initialData.name,
|
||||
description: initialData.description || '',
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}
|
||||
prevIsOpen.current = isOpen;
|
||||
}, [isOpen, isEditMode, initialData, form]);
|
||||
|
||||
const handleSuccess = async (
|
||||
message: string,
|
||||
redirectPath?: string,
|
||||
): Promise<void> => {
|
||||
await invalidateListRoles(queryClient);
|
||||
if (isEditMode && initialData?.id) {
|
||||
await invalidateGetRole(queryClient, { id: initialData.id });
|
||||
}
|
||||
toast.success(message);
|
||||
form.resetFields();
|
||||
onClose();
|
||||
if (redirectPath) {
|
||||
history.push(redirectPath);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (error: ErrorType<RenderErrorResponseDTO>): void => {
|
||||
handleApiError(error, showErrorModal);
|
||||
};
|
||||
|
||||
const { mutate: createRole, isLoading: isCreating } = useCreateRole({
|
||||
mutation: {
|
||||
onSuccess: (res) =>
|
||||
handleSuccess(
|
||||
'Role created successfully',
|
||||
generatePath(ROUTES.ROLE_DETAILS, { roleId: res.data.id }),
|
||||
),
|
||||
onError: handleError,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: patchRole, isLoading: isPatching } = usePatchRole({
|
||||
mutation: {
|
||||
onSuccess: () => handleSuccess('Role updated successfully'),
|
||||
onError: handleError,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (isEditMode && initialData?.id) {
|
||||
patchRole({
|
||||
pathParams: { id: initialData.id },
|
||||
data: { description: values.description || '' },
|
||||
});
|
||||
} else {
|
||||
const data: RoletypesPostableRoleDTO = {
|
||||
name: values.name,
|
||||
...(values.description ? { description: values.description } : {}),
|
||||
};
|
||||
createRole({ data });
|
||||
}
|
||||
} catch {
|
||||
// form validation failed; antd handles inline error display
|
||||
}
|
||||
}, [form, createRole, patchRole, isEditMode, initialData]);
|
||||
|
||||
const onCancel = useCallback((): void => {
|
||||
form.resetFields();
|
||||
onClose();
|
||||
}, [form, onClose]);
|
||||
|
||||
const isLoading = isCreating || isPatching;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onCancel={onCancel}
|
||||
title={isEditMode ? 'Edit Role Details' : 'Create a New Role'}
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
onClick={onCancel}
|
||||
size="sm"
|
||||
>
|
||||
<X size={14} />
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onSubmit}
|
||||
loading={isLoading}
|
||||
size="sm"
|
||||
>
|
||||
{isEditMode ? 'Save Changes' : 'Create Role'}
|
||||
</Button>,
|
||||
]}
|
||||
destroyOnClose
|
||||
className="create-role-modal"
|
||||
width={530}
|
||||
>
|
||||
<Form form={form} layout="vertical" className="create-role-form">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Name"
|
||||
rules={[{ required: true, message: 'Role name is required' }]}
|
||||
>
|
||||
<Input
|
||||
disabled={isEditMode}
|
||||
placeholder="Enter role name e.g. : Service Owner"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<textarea
|
||||
className={inputVariants()}
|
||||
placeholder="A helpful description of the role"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateRoleModal;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Trash2, X } from '@signozhq/icons';
|
||||
import { Modal } from 'antd';
|
||||
|
||||
interface DeleteRoleModalProps {
|
||||
isOpen: boolean;
|
||||
roleName: string;
|
||||
isDeleting: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
function DeleteRoleModal({
|
||||
isOpen,
|
||||
roleName,
|
||||
isDeleting,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DeleteRoleModalProps): JSX.Element {
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
onCancel={onCancel}
|
||||
title={<span className="title">Delete Role</span>}
|
||||
closable
|
||||
footer={[
|
||||
<Button
|
||||
key="cancel"
|
||||
className="cancel-btn"
|
||||
prefixIcon={<X size={16} />}
|
||||
onClick={onCancel}
|
||||
size="sm"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key="delete"
|
||||
className="delete-btn"
|
||||
prefixIcon={<Trash2 size={16} />}
|
||||
onClick={onConfirm}
|
||||
loading={isDeleting}
|
||||
size="sm"
|
||||
variant="solid"
|
||||
color="destructive"
|
||||
>
|
||||
Delete Role
|
||||
</Button>,
|
||||
]}
|
||||
destroyOnClose
|
||||
className="role-details-delete-modal"
|
||||
>
|
||||
<p className="delete-text">
|
||||
Are you sure you want to delete the role <strong>{roleName}</strong>? This
|
||||
action cannot be undone.
|
||||
</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default DeleteRoleModal;
|
||||
@@ -5,11 +5,15 @@ import { useListRoles } from 'api/generated/services/role';
|
||||
import { RoletypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { RoleType } from 'types/roles';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
import { IS_ROLE_DETAILS_AND_CRUD_ENABLED } from '../config';
|
||||
|
||||
import '../RolesSettings.styles.scss';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -68,11 +72,15 @@ function RolesListingTable({
|
||||
}, [roles, searchQuery]);
|
||||
|
||||
const managedRoles = useMemo(
|
||||
() => filteredRoles.filter((role) => role.type?.toLowerCase() === 'managed'),
|
||||
() =>
|
||||
filteredRoles.filter(
|
||||
(role) => role.type?.toLowerCase() === RoleType.MANAGED,
|
||||
),
|
||||
[filteredRoles],
|
||||
);
|
||||
const customRoles = useMemo(
|
||||
() => filteredRoles.filter((role) => role.type?.toLowerCase() === 'custom'),
|
||||
() =>
|
||||
filteredRoles.filter((role) => role.type?.toLowerCase() === RoleType.CUSTOM),
|
||||
[filteredRoles],
|
||||
);
|
||||
|
||||
@@ -174,9 +182,34 @@ function RolesListingTable({
|
||||
);
|
||||
}
|
||||
|
||||
const navigateToRole = (roleId: string): void => {
|
||||
history.push(ROUTES.ROLE_DETAILS.replace(':roleId', roleId));
|
||||
};
|
||||
|
||||
// todo: use table from periscope when its available for consumption
|
||||
const renderRow = (role: RoletypesRoleDTO): JSX.Element => (
|
||||
<div key={role.id} className="roles-table-row">
|
||||
<div
|
||||
key={role.id}
|
||||
className={`roles-table-row ${
|
||||
IS_ROLE_DETAILS_AND_CRUD_ENABLED ? 'roles-table-row--clickable' : ''
|
||||
}`}
|
||||
role="button"
|
||||
tabIndex={IS_ROLE_DETAILS_AND_CRUD_ENABLED ? 0 : -1}
|
||||
onClick={(): void => {
|
||||
if (IS_ROLE_DETAILS_AND_CRUD_ENABLED && role.id) {
|
||||
navigateToRole(role.id);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (
|
||||
IS_ROLE_DETAILS_AND_CRUD_ENABLED &&
|
||||
(e.key === 'Enter' || e.key === ' ') &&
|
||||
role.id
|
||||
) {
|
||||
navigateToRole(role.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="roles-table-cell roles-table-cell--name">
|
||||
{role.name ?? '—'}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
.roles-settings-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
@@ -31,8 +32,28 @@
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.roles-settings-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.role-settings-toolbar-button {
|
||||
display: flex;
|
||||
width: 156px;
|
||||
height: 32px;
|
||||
padding: 6px 12px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// todo: https://github.com/SigNoz/components/issues/116
|
||||
.roles-search-wrapper {
|
||||
flex: 1;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
background: var(--l3-background);
|
||||
@@ -153,6 +174,14 @@
|
||||
background: rgba(171, 189, 255, 0.02);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
gap: 24px;
|
||||
|
||||
&--clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: rgba(171, 189, 255, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.roles-table-cell {
|
||||
@@ -235,4 +264,136 @@
|
||||
.roles-table-row {
|
||||
background: rgba(0, 0, 0, 0.01);
|
||||
}
|
||||
|
||||
.create-role-modal {
|
||||
.ant-modal-title {
|
||||
color: var(--text-base-black);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.create-role-modal {
|
||||
.ant-modal-content {
|
||||
padding: 0;
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--secondary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ant-modal-header {
|
||||
background: var(--l2-background);
|
||||
border-bottom: 1px solid var(--secondary);
|
||||
padding: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-modal-close {
|
||||
top: 14px;
|
||||
inset-inline-end: 16px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: var(--foreground);
|
||||
|
||||
.ant-modal-close-x {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-title {
|
||||
color: var(--text-base-white);
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.065px;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.create-role-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-form-item-label {
|
||||
padding-bottom: 8px;
|
||||
|
||||
label {
|
||||
color: var(--foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// todo: https://github.com/SigNoz/components/issues/116
|
||||
input,
|
||||
textarea {
|
||||
width: 100%;
|
||||
background: var(--l3-background);
|
||||
border: 1px solid var(--l3-border);
|
||||
border-radius: 2px;
|
||||
padding: 6px 8px;
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: -0.07px;
|
||||
color: var(--l1-foreground);
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
border-color: var(--input);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
input {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l3-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
height: 56px;
|
||||
border-top: 1px solid var(--secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/button';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/input';
|
||||
|
||||
import { IS_ROLE_DETAILS_AND_CRUD_ENABLED } from './config';
|
||||
import CreateRoleModal from './RolesComponents/CreateRoleModal';
|
||||
import RolesListingTable from './RolesComponents/RolesListingTable';
|
||||
|
||||
import './RolesSettings.styles.scss';
|
||||
|
||||
function RolesSettings(): JSX.Element {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="roles-settings" data-testid="roles-settings">
|
||||
@@ -17,16 +22,33 @@ function RolesSettings(): JSX.Element {
|
||||
</p>
|
||||
</div>
|
||||
<div className="roles-settings-content">
|
||||
<div className="roles-search-wrapper">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search for roles..."
|
||||
value={searchQuery}
|
||||
onChange={(e): void => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<div className="roles-settings-toolbar">
|
||||
<div className="roles-search-wrapper">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search for roles..."
|
||||
value={searchQuery}
|
||||
onChange={(e): void => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{IS_ROLE_DETAILS_AND_CRUD_ENABLED && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className="role-settings-toolbar-button"
|
||||
onClick={(): void => setIsCreateModalOpen(true)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Custom role
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<RolesListingTable searchQuery={searchQuery} />
|
||||
</div>
|
||||
<CreateRoleModal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={(): void => setIsCreateModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
384
frontend/src/container/RolesSettings/__tests__/utils.test.ts
Normal file
384
frontend/src/container/RolesSettings/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import type {
|
||||
AuthtypesGettableObjectsDTO,
|
||||
AuthtypesGettableResourcesDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type {
|
||||
PermissionConfig,
|
||||
ResourceDefinition,
|
||||
} from '../PermissionSidePanel/PermissionSidePanel.types';
|
||||
import { PermissionScope } from '../PermissionSidePanel/PermissionSidePanel.types';
|
||||
import {
|
||||
buildConfig,
|
||||
buildPatchPayload,
|
||||
configsEqual,
|
||||
DEFAULT_RESOURCE_CONFIG,
|
||||
derivePermissionTypes,
|
||||
deriveResourcesForRelation,
|
||||
objectsToPermissionConfig,
|
||||
} from '../utils';
|
||||
|
||||
jest.mock('../RoleDetails/constants', () => {
|
||||
const MockIcon = (): null => null;
|
||||
return {
|
||||
PERMISSION_ICON_MAP: {
|
||||
create: MockIcon,
|
||||
list: MockIcon,
|
||||
read: MockIcon,
|
||||
update: MockIcon,
|
||||
delete: MockIcon,
|
||||
},
|
||||
FALLBACK_PERMISSION_ICON: MockIcon,
|
||||
ROLE_ID_REGEX: /\/settings\/roles\/([^/]+)/,
|
||||
};
|
||||
});
|
||||
|
||||
const dashboardResource: AuthtypesGettableResourcesDTO['resources'][number] = {
|
||||
name: 'dashboard',
|
||||
type: 'metaresource',
|
||||
};
|
||||
|
||||
const alertResource: AuthtypesGettableResourcesDTO['resources'][number] = {
|
||||
name: 'alert',
|
||||
type: 'metaresource',
|
||||
};
|
||||
|
||||
const baseAuthzResources: AuthtypesGettableResourcesDTO = {
|
||||
resources: [dashboardResource, alertResource],
|
||||
relations: {
|
||||
create: ['metaresource'],
|
||||
read: ['metaresource'],
|
||||
},
|
||||
};
|
||||
|
||||
const resourceDefs: ResourceDefinition[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard' },
|
||||
{ id: 'alert', label: 'Alert' },
|
||||
];
|
||||
|
||||
const ID_A = 'aaaaaaaa-0000-0000-0000-000000000001';
|
||||
const ID_B = 'bbbbbbbb-0000-0000-0000-000000000002';
|
||||
const ID_C = 'cccccccc-0000-0000-0000-000000000003';
|
||||
|
||||
describe('buildPatchPayload', () => {
|
||||
it('sends only the added selector as an addition', () => {
|
||||
const initial: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_A] },
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
},
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
newConfig,
|
||||
initialConfig: initial,
|
||||
resources: resourceDefs,
|
||||
authzRes: baseAuthzResources,
|
||||
});
|
||||
|
||||
expect(result.additions).toEqual([
|
||||
{ resource: dashboardResource, selectors: [ID_B] },
|
||||
]);
|
||||
expect(result.deletions).toBeNull();
|
||||
});
|
||||
|
||||
it('sends only the removed selector as a deletion', () => {
|
||||
const initial: PermissionConfig = {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B, ID_C],
|
||||
},
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_C],
|
||||
},
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
newConfig,
|
||||
initialConfig: initial,
|
||||
resources: resourceDefs,
|
||||
authzRes: baseAuthzResources,
|
||||
});
|
||||
|
||||
expect(result.deletions).toEqual([
|
||||
{ resource: dashboardResource, selectors: [ID_B] },
|
||||
]);
|
||||
expect(result.additions).toBeNull();
|
||||
});
|
||||
|
||||
it('treats selector order as irrelevant — produces no payload when IDs are identical', () => {
|
||||
const initial: PermissionConfig = {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
},
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_B, ID_A],
|
||||
},
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
newConfig,
|
||||
initialConfig: initial,
|
||||
resources: resourceDefs,
|
||||
authzRes: baseAuthzResources,
|
||||
});
|
||||
|
||||
expect(result.additions).toBeNull();
|
||||
expect(result.deletions).toBeNull();
|
||||
});
|
||||
|
||||
it('replaces wildcard with specific IDs when switching all → only_selected', () => {
|
||||
const initial: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
},
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
newConfig,
|
||||
initialConfig: initial,
|
||||
resources: resourceDefs,
|
||||
authzRes: baseAuthzResources,
|
||||
});
|
||||
|
||||
expect(result.deletions).toEqual([
|
||||
{ resource: dashboardResource, selectors: ['*'] },
|
||||
]);
|
||||
expect(result.additions).toEqual([
|
||||
{ resource: dashboardResource, selectors: [ID_A, ID_B] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('only deletes wildcard when switching all → only_selected with empty selector list', () => {
|
||||
const initial: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
newConfig,
|
||||
initialConfig: initial,
|
||||
resources: resourceDefs,
|
||||
authzRes: baseAuthzResources,
|
||||
});
|
||||
|
||||
expect(result.deletions).toEqual([
|
||||
{ resource: dashboardResource, selectors: ['*'] },
|
||||
]);
|
||||
expect(result.additions).toBeNull();
|
||||
});
|
||||
|
||||
it('only includes resources that actually changed', () => {
|
||||
const initial: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_A] },
|
||||
};
|
||||
const newConfig: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] }, // unchanged
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_A, ID_B] }, // added ID_B
|
||||
};
|
||||
|
||||
const result = buildPatchPayload({
|
||||
newConfig,
|
||||
initialConfig: initial,
|
||||
resources: resourceDefs,
|
||||
authzRes: baseAuthzResources,
|
||||
});
|
||||
|
||||
expect(result.additions).toEqual([
|
||||
{ resource: alertResource, selectors: [ID_B] },
|
||||
]);
|
||||
expect(result.deletions).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('objectsToPermissionConfig', () => {
|
||||
it('maps a wildcard selector to ALL scope', () => {
|
||||
const objects: AuthtypesGettableObjectsDTO[] = [
|
||||
{ resource: dashboardResource, selectors: ['*'] },
|
||||
];
|
||||
|
||||
const result = objectsToPermissionConfig(objects, resourceDefs);
|
||||
|
||||
expect(result.dashboard).toEqual({
|
||||
scope: PermissionScope.ALL,
|
||||
selectedIds: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('maps specific selectors to ONLY_SELECTED scope with the IDs', () => {
|
||||
const objects: AuthtypesGettableObjectsDTO[] = [
|
||||
{ resource: dashboardResource, selectors: [ID_A, ID_B] },
|
||||
];
|
||||
|
||||
const result = objectsToPermissionConfig(objects, resourceDefs);
|
||||
|
||||
expect(result.dashboard).toEqual({
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to ONLY_SELECTED with empty selectedIds when resource is absent from API response', () => {
|
||||
const result = objectsToPermissionConfig([], resourceDefs);
|
||||
|
||||
expect(result.dashboard).toEqual({
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
});
|
||||
expect(result.alert).toEqual({
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('configsEqual', () => {
|
||||
it('returns true for identical configs', () => {
|
||||
const config: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
alert: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [ID_A] },
|
||||
};
|
||||
|
||||
expect(configsEqual(config, { ...config })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when configs differ', () => {
|
||||
const a: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
};
|
||||
const b: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ONLY_SELECTED, selectedIds: [] },
|
||||
};
|
||||
|
||||
expect(configsEqual(a, b)).toBe(false);
|
||||
|
||||
const c: PermissionConfig = {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_C, ID_B],
|
||||
},
|
||||
};
|
||||
const d: PermissionConfig = {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
},
|
||||
};
|
||||
|
||||
expect(configsEqual(c, d)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when selectedIds are the same but in different order', () => {
|
||||
const a: PermissionConfig = {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_A, ID_B],
|
||||
},
|
||||
};
|
||||
const b: PermissionConfig = {
|
||||
dashboard: {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [ID_B, ID_A],
|
||||
},
|
||||
};
|
||||
|
||||
expect(configsEqual(a, b)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildConfig', () => {
|
||||
it('uses initial values when provided and defaults for resources not in initial', () => {
|
||||
const initial: PermissionConfig = {
|
||||
dashboard: { scope: PermissionScope.ALL, selectedIds: [] },
|
||||
};
|
||||
|
||||
const result = buildConfig(resourceDefs, initial);
|
||||
|
||||
expect(result.dashboard).toEqual({
|
||||
scope: PermissionScope.ALL,
|
||||
selectedIds: [],
|
||||
});
|
||||
expect(result.alert).toEqual(DEFAULT_RESOURCE_CONFIG);
|
||||
});
|
||||
|
||||
it('applies DEFAULT_RESOURCE_CONFIG to all resources when no initial is provided', () => {
|
||||
const result = buildConfig(resourceDefs);
|
||||
|
||||
expect(result.dashboard).toEqual(DEFAULT_RESOURCE_CONFIG);
|
||||
expect(result.alert).toEqual(DEFAULT_RESOURCE_CONFIG);
|
||||
});
|
||||
});
|
||||
|
||||
describe('derivePermissionTypes', () => {
|
||||
it('derives one PermissionType per relation key with correct key and capitalised label', () => {
|
||||
const relations: AuthtypesGettableResourcesDTO['relations'] = {
|
||||
create: ['metaresource'],
|
||||
read: ['metaresource'],
|
||||
delete: ['metaresource'],
|
||||
};
|
||||
|
||||
const result = derivePermissionTypes(relations);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((p) => p.key)).toEqual(['create', 'read', 'delete']);
|
||||
expect(result[0].label).toBe('Create');
|
||||
});
|
||||
|
||||
it('falls back to the default set of permission types when relations is null', () => {
|
||||
const result = derivePermissionTypes(null);
|
||||
|
||||
expect(result.map((p) => p.key)).toEqual([
|
||||
'create',
|
||||
'list',
|
||||
'read',
|
||||
'update',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveResourcesForRelation', () => {
|
||||
it('returns resources whose type matches the relation', () => {
|
||||
const result = deriveResourcesForRelation(baseAuthzResources, 'create');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((r) => r.id)).toEqual(['dashboard', 'alert']);
|
||||
});
|
||||
|
||||
it('returns an empty array when authzResources is null', () => {
|
||||
expect(deriveResourcesForRelation(null, 'create')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns an empty array when the relation is not defined in the map', () => {
|
||||
expect(
|
||||
deriveResourcesForRelation(baseAuthzResources, 'nonexistent'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
1
frontend/src/container/RolesSettings/config.ts
Normal file
1
frontend/src/container/RolesSettings/config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const IS_ROLE_DETAILS_AND_CRUD_ENABLED = false;
|
||||
226
frontend/src/container/RolesSettings/utils.tsx
Normal file
226
frontend/src/container/RolesSettings/utils.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import React from 'react';
|
||||
import { Badge } from '@signozhq/badge';
|
||||
import type {
|
||||
AuthtypesGettableObjectsDTO,
|
||||
AuthtypesGettableResourcesDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import type {
|
||||
PermissionConfig,
|
||||
ResourceConfig,
|
||||
ResourceDefinition,
|
||||
} from './PermissionSidePanel/PermissionSidePanel.types';
|
||||
import { PermissionScope } from './PermissionSidePanel/PermissionSidePanel.types';
|
||||
import {
|
||||
FALLBACK_PERMISSION_ICON,
|
||||
PERMISSION_ICON_MAP,
|
||||
} from './RoleDetails/constants';
|
||||
|
||||
export interface PermissionType {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: JSX.Element;
|
||||
}
|
||||
|
||||
export interface PatchPayloadOptions {
|
||||
newConfig: PermissionConfig;
|
||||
initialConfig: PermissionConfig;
|
||||
resources: ResourceDefinition[];
|
||||
authzRes: AuthtypesGettableResourcesDTO;
|
||||
}
|
||||
|
||||
export function derivePermissionTypes(
|
||||
relations: AuthtypesGettableResourcesDTO['relations'] | null,
|
||||
): PermissionType[] {
|
||||
const iconSize = { size: 14 };
|
||||
|
||||
if (!relations) {
|
||||
return Object.entries(PERMISSION_ICON_MAP).map(([key, IconComp]) => ({
|
||||
key,
|
||||
label: capitalize(key),
|
||||
icon: React.createElement(IconComp, iconSize),
|
||||
}));
|
||||
}
|
||||
return Object.keys(relations).map((key) => {
|
||||
const IconComp = PERMISSION_ICON_MAP[key] ?? FALLBACK_PERMISSION_ICON;
|
||||
return {
|
||||
key,
|
||||
label: capitalize(key),
|
||||
icon: React.createElement(IconComp, iconSize),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function deriveResourcesForRelation(
|
||||
authzResources: AuthtypesGettableResourcesDTO | null,
|
||||
relation: string,
|
||||
): ResourceDefinition[] {
|
||||
if (!authzResources?.relations) {
|
||||
return [];
|
||||
}
|
||||
const supportedTypes = authzResources.relations[relation] ?? [];
|
||||
return authzResources.resources
|
||||
.filter((r) => supportedTypes.includes(r.type))
|
||||
.map((r) => ({
|
||||
id: r.name,
|
||||
label: capitalize(r.name).replace(/_/g, ' '),
|
||||
options: [],
|
||||
}));
|
||||
}
|
||||
|
||||
export function objectsToPermissionConfig(
|
||||
objects: AuthtypesGettableObjectsDTO[],
|
||||
resources: ResourceDefinition[],
|
||||
): PermissionConfig {
|
||||
const config: PermissionConfig = {};
|
||||
for (const res of resources) {
|
||||
const obj = objects.find((o) => o.resource.name === res.id);
|
||||
if (!obj) {
|
||||
config[res.id] = {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
};
|
||||
} else {
|
||||
const isAll = obj.selectors.includes('*');
|
||||
config[res.id] = {
|
||||
scope: isAll ? PermissionScope.ALL : PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: isAll ? [] : obj.selectors,
|
||||
};
|
||||
}
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
export function buildPatchPayload({
|
||||
newConfig,
|
||||
initialConfig,
|
||||
resources,
|
||||
authzRes,
|
||||
}: PatchPayloadOptions): {
|
||||
additions: AuthtypesGettableObjectsDTO[] | null;
|
||||
deletions: AuthtypesGettableObjectsDTO[] | null;
|
||||
} {
|
||||
if (!authzRes) {
|
||||
return { additions: null, deletions: null };
|
||||
}
|
||||
const additions: AuthtypesGettableObjectsDTO[] = [];
|
||||
const deletions: AuthtypesGettableObjectsDTO[] = [];
|
||||
|
||||
for (const res of resources) {
|
||||
const initial = initialConfig[res.id];
|
||||
const current = newConfig[res.id];
|
||||
const resourceDef = authzRes.resources.find((r) => r.name === res.id);
|
||||
if (!resourceDef) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const initialScope = initial?.scope ?? PermissionScope.ONLY_SELECTED;
|
||||
const currentScope = current?.scope ?? PermissionScope.ONLY_SELECTED;
|
||||
|
||||
if (initialScope === currentScope) {
|
||||
// Same scope — only diff individual selectors when both are ONLY_SELECTED
|
||||
if (initialScope === PermissionScope.ONLY_SELECTED) {
|
||||
const initialIds = new Set(initial?.selectedIds ?? []);
|
||||
const currentIds = new Set(current?.selectedIds ?? []);
|
||||
const removed = [...initialIds].filter((id) => !currentIds.has(id));
|
||||
const added = [...currentIds].filter((id) => !initialIds.has(id));
|
||||
if (removed.length > 0) {
|
||||
deletions.push({ resource: resourceDef, selectors: removed });
|
||||
}
|
||||
if (added.length > 0) {
|
||||
additions.push({ resource: resourceDef, selectors: added });
|
||||
}
|
||||
}
|
||||
// Both ALL → no change, skip
|
||||
} else {
|
||||
// Scope changed (ALL ↔ ONLY_SELECTED) — replace old with new
|
||||
const initialSelectors =
|
||||
initialScope === PermissionScope.ALL ? ['*'] : initial?.selectedIds ?? [];
|
||||
if (initialSelectors.length > 0) {
|
||||
deletions.push({ resource: resourceDef, selectors: initialSelectors });
|
||||
}
|
||||
const currentSelectors =
|
||||
currentScope === PermissionScope.ALL ? ['*'] : current?.selectedIds ?? [];
|
||||
if (currentSelectors.length > 0) {
|
||||
additions.push({ resource: resourceDef, selectors: currentSelectors });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
additions: additions.length > 0 ? additions : null,
|
||||
deletions: deletions.length > 0 ? deletions : null,
|
||||
};
|
||||
}
|
||||
|
||||
interface TimestampBadgeProps {
|
||||
date?: Date | string;
|
||||
}
|
||||
|
||||
export function TimestampBadge({ date }: TimestampBadgeProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
if (!date) {
|
||||
return <Badge color="vanilla">—</Badge>;
|
||||
}
|
||||
|
||||
const d = new Date(date);
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
return <Badge color="vanilla">—</Badge>;
|
||||
}
|
||||
|
||||
const formatted = formatTimezoneAdjustedTimestamp(
|
||||
date,
|
||||
DATE_TIME_FORMATS.DASH_DATETIME,
|
||||
);
|
||||
|
||||
return <Badge color="vanilla">{formatted}</Badge>;
|
||||
}
|
||||
|
||||
export const DEFAULT_RESOURCE_CONFIG: ResourceConfig = {
|
||||
scope: PermissionScope.ONLY_SELECTED,
|
||||
selectedIds: [],
|
||||
};
|
||||
|
||||
export function buildConfig(
|
||||
resources: ResourceDefinition[],
|
||||
initial?: PermissionConfig,
|
||||
): PermissionConfig {
|
||||
const config: PermissionConfig = {};
|
||||
resources.forEach((r) => {
|
||||
config[r.id] = initial?.[r.id] ?? { ...DEFAULT_RESOURCE_CONFIG };
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
export function isResourceConfigEqual(
|
||||
ac: ResourceConfig,
|
||||
bc?: ResourceConfig,
|
||||
): boolean {
|
||||
if (!bc) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
ac.scope === bc.scope &&
|
||||
JSON.stringify([...ac.selectedIds].sort()) ===
|
||||
JSON.stringify([...bc.selectedIds].sort())
|
||||
);
|
||||
}
|
||||
|
||||
export function configsEqual(
|
||||
a: PermissionConfig,
|
||||
b: PermissionConfig,
|
||||
): boolean {
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return keysA.every((id) => isResourceConfigEqual(a[id], b[id]));
|
||||
}
|
||||
@@ -1057,21 +1057,20 @@
|
||||
gap: 8px;
|
||||
|
||||
.user-settings-dropdown-label-text {
|
||||
color: var(--bg-slate-50, #62687c);
|
||||
color: var(--l3-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 10px;
|
||||
font-family: Inter;
|
||||
font-weight: 600;
|
||||
font-size: var(--uppercase-small-500-font-size);
|
||||
font-weight: var(--uppercase-small-500-font-weight);
|
||||
font-style: normal;
|
||||
line-height: 18px; /* 163.636% */
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.user-settings-dropdown-label-email {
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-style: normal;
|
||||
line-height: normal;
|
||||
letter-spacing: 0.14px;
|
||||
@@ -1079,7 +1078,7 @@
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item-divider {
|
||||
background-color: var(--bg-slate-500, #161922) !important;
|
||||
background-color: var(--secondary) !important;
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item-disabled {
|
||||
@@ -1095,6 +1094,14 @@
|
||||
.help-support-dropdown {
|
||||
.ant-dropdown-menu-item {
|
||||
min-height: 32px;
|
||||
|
||||
.ant-dropdown-menu-title-content {
|
||||
color: var(--l1-foreground) !important;
|
||||
}
|
||||
|
||||
.user-settings-dropdown-logout-section {
|
||||
color: var(--danger-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1271,7 +1278,7 @@
|
||||
}
|
||||
|
||||
.help-support-dropdown li.ant-dropdown-menu-item-divider {
|
||||
background-color: var(--bg-slate-500, #161922) !important;
|
||||
background-color: var(--secondary) !important;
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
@@ -1431,22 +1438,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.settings-dropdown {
|
||||
.user-settings-dropdown-logged-in-section {
|
||||
.user-settings-dropdown-label-text {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
.user-settings-dropdown-label-email {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
}
|
||||
|
||||
.ant-dropdown-menu-item-divider {
|
||||
background-color: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.reorder-shortcut-nav-items-modal {
|
||||
.ant-modal-content {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
@@ -1503,10 +1494,6 @@
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
.help-support-dropdown li.ant-dropdown-menu-item-divider {
|
||||
background-color: var(--bg-vanilla-300) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.version-tooltip-overlay {
|
||||
|
||||
@@ -69,6 +69,7 @@ import { routeConfig } from './config';
|
||||
import { getQueryString } from './helper';
|
||||
import {
|
||||
defaultMoreMenuItems,
|
||||
getUserSettingsDropdownMenuItems,
|
||||
helpSupportDropdownMenuItems as DefaultHelpSupportDropdownMenuItems,
|
||||
helpSupportMenuItem,
|
||||
primaryMenuItems,
|
||||
@@ -485,48 +486,12 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
|
||||
const userSettingsDropdownMenuItems: MenuProps['items'] = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
key: 'label',
|
||||
label: (
|
||||
<div className="user-settings-dropdown-logged-in-section">
|
||||
<span className="user-settings-dropdown-label-text">LOGGED IN AS</span>
|
||||
<span className="user-settings-dropdown-label-email">{user.email}</span>
|
||||
</div>
|
||||
),
|
||||
disabled: true,
|
||||
dataTestId: 'logged-in-as-nav-item',
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'account',
|
||||
label: 'Account Settings',
|
||||
dataTestId: 'account-settings-nav-item',
|
||||
},
|
||||
{
|
||||
key: 'workspace',
|
||||
label: 'Workspace Settings',
|
||||
disabled: isWorkspaceBlocked,
|
||||
dataTestId: 'workspace-settings-nav-item',
|
||||
},
|
||||
...(isEnterpriseSelfHostedUser || isCommunityEnterpriseUser
|
||||
? [
|
||||
{
|
||||
key: 'license',
|
||||
label: 'Manage License',
|
||||
dataTestId: 'manage-license-nav-item',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'logout',
|
||||
label: (
|
||||
<span className="user-settings-dropdown-logout-section">Sign out</span>
|
||||
),
|
||||
dataTestId: 'logout-nav-item',
|
||||
},
|
||||
].filter(Boolean),
|
||||
getUserSettingsDropdownMenuItems({
|
||||
userEmail: user.email,
|
||||
isWorkspaceBlocked,
|
||||
isEnterpriseSelfHostedUser,
|
||||
isCommunityEnterpriseUser,
|
||||
}),
|
||||
[
|
||||
isEnterpriseSelfHostedUser,
|
||||
isCommunityEnterpriseUser,
|
||||
@@ -856,9 +821,6 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
});
|
||||
|
||||
switch (item.key) {
|
||||
case ROUTES.SHORTCUTS:
|
||||
history.push(ROUTES.SHORTCUTS);
|
||||
break;
|
||||
case 'invite-collaborators':
|
||||
history.push(`${ROUTES.ORG_SETTINGS}#invite-team-members`);
|
||||
break;
|
||||
@@ -878,7 +840,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
};
|
||||
|
||||
const handleSettingsMenuItemClick = (info: SidebarItem): void => {
|
||||
const item = userSettingsDropdownMenuItems.find(
|
||||
const item = (userSettingsDropdownMenuItems ?? []).find(
|
||||
(item) => item?.key === info.key,
|
||||
);
|
||||
let menuLabel = '';
|
||||
@@ -904,6 +866,9 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
|
||||
case 'license':
|
||||
history.push(ROUTES.LIST_LICENSES);
|
||||
break;
|
||||
case 'keyboard-shortcuts':
|
||||
history.push(ROUTES.SHORTCUTS);
|
||||
break;
|
||||
case 'logout':
|
||||
Logout();
|
||||
break;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { RocketOutlined } from '@ant-design/icons';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { MenuProps } from 'antd';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
@@ -8,12 +10,12 @@ import {
|
||||
Book,
|
||||
Boxes,
|
||||
BugIcon,
|
||||
Building2,
|
||||
ChartArea,
|
||||
Cloudy,
|
||||
DraftingCompass,
|
||||
FileKey2,
|
||||
Github,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Home,
|
||||
Key,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
Layers2,
|
||||
LayoutGrid,
|
||||
ListMinus,
|
||||
LogOut,
|
||||
MessageSquareText,
|
||||
Plus,
|
||||
Receipt,
|
||||
@@ -35,7 +38,11 @@ import {
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { SecondaryMenuItemKey, SidebarItem } from './sideNav.types';
|
||||
import {
|
||||
SecondaryMenuItemKey,
|
||||
SettingsNavSection,
|
||||
SidebarItem,
|
||||
} from './sideNav.types';
|
||||
|
||||
export const getStartedMenuItem = {
|
||||
key: ROUTES.GET_STARTED,
|
||||
@@ -297,83 +304,87 @@ export const defaultMoreMenuItems: SidebarItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const settingsMenuItems: SidebarItem[] = [
|
||||
export const settingsNavSections: SettingsNavSection[] = [
|
||||
{
|
||||
key: ROUTES.SETTINGS,
|
||||
label: 'General',
|
||||
icon: <Settings size={16} />,
|
||||
isEnabled: true,
|
||||
itemKey: 'general',
|
||||
key: 'general',
|
||||
items: [
|
||||
{
|
||||
key: ROUTES.SETTINGS,
|
||||
label: 'Workspace',
|
||||
icon: <Settings size={16} />,
|
||||
isEnabled: true,
|
||||
itemKey: 'workspace',
|
||||
},
|
||||
{
|
||||
key: ROUTES.MY_SETTINGS,
|
||||
label: 'Account',
|
||||
icon: <User size={16} />,
|
||||
isEnabled: true,
|
||||
itemKey: 'account',
|
||||
},
|
||||
{
|
||||
key: ROUTES.ALL_CHANNELS,
|
||||
label: 'Notification Channels',
|
||||
icon: <FileKey2 size={16} />,
|
||||
isEnabled: true,
|
||||
itemKey: 'notification-channels',
|
||||
},
|
||||
{
|
||||
key: ROUTES.BILLING,
|
||||
label: 'Billing',
|
||||
icon: <Receipt size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'billing',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
key: 'identity-access',
|
||||
title: 'IDENTITY & ACCESS',
|
||||
items: [
|
||||
{
|
||||
key: ROUTES.ROLES_SETTINGS,
|
||||
label: 'Roles',
|
||||
icon: <Shield size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'roles',
|
||||
},
|
||||
{
|
||||
key: ROUTES.INTEGRATIONS,
|
||||
label: 'Integrations',
|
||||
icon: <Unplug size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'integrations',
|
||||
},
|
||||
{
|
||||
key: ROUTES.API_KEYS,
|
||||
label: 'API Keys',
|
||||
icon: <Key size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'api-keys',
|
||||
},
|
||||
{
|
||||
key: ROUTES.INGESTION_SETTINGS,
|
||||
label: 'Ingestion',
|
||||
icon: <RocketOutlined rotate={45} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'ingestion',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: ROUTES.BILLING,
|
||||
label: 'Billing',
|
||||
icon: <Receipt size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'billing',
|
||||
},
|
||||
{
|
||||
key: ROUTES.ROLES_SETTINGS,
|
||||
label: 'Roles',
|
||||
icon: <Shield size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'roles',
|
||||
},
|
||||
{
|
||||
key: ROUTES.ORG_SETTINGS,
|
||||
label: 'Members & SSO',
|
||||
icon: <User size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'members-sso',
|
||||
},
|
||||
{
|
||||
key: ROUTES.CUSTOM_DOMAIN_SETTINGS,
|
||||
label: 'Custom Domain',
|
||||
icon: <Globe size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'custom-domain',
|
||||
},
|
||||
{
|
||||
key: ROUTES.INTEGRATIONS,
|
||||
label: 'Integrations',
|
||||
icon: <Unplug size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'integrations',
|
||||
},
|
||||
{
|
||||
key: ROUTES.ALL_CHANNELS,
|
||||
label: 'Notification Channels',
|
||||
icon: <FileKey2 size={16} />,
|
||||
isEnabled: true,
|
||||
itemKey: 'notification-channels',
|
||||
},
|
||||
{
|
||||
key: ROUTES.API_KEYS,
|
||||
label: 'API Keys',
|
||||
icon: <Key size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'api-keys',
|
||||
},
|
||||
{
|
||||
key: ROUTES.INGESTION_SETTINGS,
|
||||
label: 'Ingestion',
|
||||
icon: <RocketOutlined rotate={45} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'ingestion',
|
||||
},
|
||||
{
|
||||
key: ROUTES.MY_SETTINGS,
|
||||
label: 'Account Settings',
|
||||
icon: <User size={16} />,
|
||||
isEnabled: true,
|
||||
itemKey: 'account-settings',
|
||||
},
|
||||
{
|
||||
key: ROUTES.SHORTCUTS,
|
||||
label: 'Keyboard Shortcuts',
|
||||
icon: <Layers2 size={16} />,
|
||||
isEnabled: true,
|
||||
itemKey: 'keyboard-shortcuts',
|
||||
key: 'authentication',
|
||||
title: 'Authentication',
|
||||
items: [
|
||||
{
|
||||
key: ROUTES.ORG_SETTINGS,
|
||||
label: 'Members & SSO',
|
||||
icon: <User size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'members-sso',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -424,12 +435,6 @@ export const helpSupportDropdownMenuItems: SidebarItem[] = [
|
||||
icon: <MessageSquareText size={14} />,
|
||||
itemKey: 'chat-support',
|
||||
},
|
||||
{
|
||||
key: ROUTES.SHORTCUTS,
|
||||
label: 'Keyboard Shortcuts',
|
||||
icon: <Keyboard size={14} />,
|
||||
itemKey: 'keyboard-shortcuts',
|
||||
},
|
||||
{
|
||||
key: 'invite-collaborators',
|
||||
label: 'Invite a Team Member',
|
||||
@@ -438,6 +443,78 @@ export const helpSupportDropdownMenuItems: SidebarItem[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export interface UserSettingsMenuItemsParams {
|
||||
userEmail: string;
|
||||
isWorkspaceBlocked: boolean;
|
||||
isEnterpriseSelfHostedUser: boolean;
|
||||
isCommunityEnterpriseUser: boolean;
|
||||
}
|
||||
|
||||
export const getUserSettingsDropdownMenuItems = ({
|
||||
userEmail,
|
||||
isWorkspaceBlocked,
|
||||
isEnterpriseSelfHostedUser,
|
||||
isCommunityEnterpriseUser,
|
||||
}: UserSettingsMenuItemsParams): MenuProps['items'] =>
|
||||
[
|
||||
{
|
||||
key: 'label',
|
||||
label: (
|
||||
<div className="user-settings-dropdown-logged-in-section">
|
||||
<span className="user-settings-dropdown-label-text">LOGGED IN AS</span>
|
||||
<span className="user-settings-dropdown-label-email">{userEmail}</span>
|
||||
</div>
|
||||
),
|
||||
disabled: true,
|
||||
dataTestId: 'logged-in-as-nav-item',
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'workspace',
|
||||
label: 'Workspace Settings',
|
||||
icon: <Building2 size={14} color={Style.L1_FOREGROUND} />,
|
||||
disabled: isWorkspaceBlocked,
|
||||
dataTestId: 'workspace-settings-nav-item',
|
||||
},
|
||||
{
|
||||
key: 'account',
|
||||
label: 'Account Settings',
|
||||
icon: <User size={14} color={Style.L1_FOREGROUND} />,
|
||||
dataTestId: 'account-settings-nav-item',
|
||||
},
|
||||
...(isEnterpriseSelfHostedUser || isCommunityEnterpriseUser
|
||||
? [
|
||||
{
|
||||
key: 'license',
|
||||
label: 'Manage License',
|
||||
icon: <Shield size={14} color={Style.L1_FOREGROUND} />,
|
||||
dataTestId: 'manage-license-nav-item',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'keyboard-shortcuts',
|
||||
label: 'Keyboard Shortcuts',
|
||||
icon: <Keyboard size={14} color={Style.L1_FOREGROUND} />,
|
||||
dataTestId: 'keyboard-shortcuts-nav-item',
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
{
|
||||
key: 'logout',
|
||||
label: (
|
||||
<span className="user-settings-dropdown-logout-section">Sign out</span>
|
||||
),
|
||||
icon: (
|
||||
<LogOut
|
||||
size={14}
|
||||
className="user-settings-dropdown-logout-section"
|
||||
color={Style.DANGER_BACKGROUND}
|
||||
/>
|
||||
),
|
||||
dataTestId: 'logout-nav-item',
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
/** Mapping of some newly added routes and their corresponding active sidebar menu key */
|
||||
export const NEW_ROUTES_MENU_ITEM_KEY_MAP: Record<string, string> = {
|
||||
[ROUTES.TRACE]: ROUTES.TRACES_EXPLORER,
|
||||
|
||||
@@ -24,6 +24,12 @@ export interface SidebarItem {
|
||||
|
||||
export const CHANGELOG_LABEL = 'Full Changelog';
|
||||
|
||||
export interface SettingsNavSection {
|
||||
title?: string;
|
||||
items: SidebarItem[];
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface DropdownSeparator {
|
||||
type: 'divider' | 'group';
|
||||
label?: ReactNode;
|
||||
|
||||
@@ -19,7 +19,6 @@ const breadcrumbNameMap: Record<string, string> = {
|
||||
[ROUTES.ORG_SETTINGS]: 'Organization Settings',
|
||||
[ROUTES.INGESTION_SETTINGS]: 'Ingestion Settings',
|
||||
[ROUTES.MY_SETTINGS]: 'My Settings',
|
||||
[ROUTES.CUSTOM_DOMAIN_SETTINGS]: 'Custom Domain Settings',
|
||||
[ROUTES.ERROR_DETAIL]: 'Exceptions',
|
||||
[ROUTES.LIST_ALL_ALERT]: 'Alerts',
|
||||
[ROUTES.ALL_DASHBOARD]: 'Dashboard',
|
||||
|
||||
@@ -154,12 +154,12 @@ export const routesToSkip = [
|
||||
ROUTES.ALL_DASHBOARD,
|
||||
ROUTES.ORG_SETTINGS,
|
||||
ROUTES.INGESTION_SETTINGS,
|
||||
ROUTES.CUSTOM_DOMAIN_SETTINGS,
|
||||
ROUTES.API_KEYS,
|
||||
ROUTES.ERROR_DETAIL,
|
||||
ROUTES.LOGS_PIPELINES,
|
||||
ROUTES.BILLING,
|
||||
ROUTES.ROLES_SETTINGS,
|
||||
ROUTES.ROLE_DETAILS,
|
||||
ROUTES.SUPPORT,
|
||||
ROUTES.WORKSPACE_LOCKED,
|
||||
ROUTES.WORKSPACE_SUSPENDED,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/* eslint-disable no-restricted-imports */
|
||||
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { QueryKeyValueSuggestionsResponseProps } from 'types/api/querySuggestions/types';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
export const useGetQueryKeyValueSuggestions = ({
|
||||
key,
|
||||
@@ -9,6 +13,7 @@ export const useGetQueryKeyValueSuggestions = ({
|
||||
searchText,
|
||||
signalSource,
|
||||
metricName,
|
||||
existingQuery,
|
||||
options,
|
||||
}: {
|
||||
key: string;
|
||||
@@ -20,11 +25,24 @@ export const useGetQueryKeyValueSuggestions = ({
|
||||
AxiosError
|
||||
>;
|
||||
metricName?: string;
|
||||
existingQuery?: string;
|
||||
}): UseQueryResult<
|
||||
AxiosResponse<QueryKeyValueSuggestionsResponseProps>,
|
||||
AxiosError
|
||||
> =>
|
||||
useQuery<AxiosResponse<QueryKeyValueSuggestionsResponseProps>, AxiosError>({
|
||||
> => {
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
|
||||
const timeRangeKey =
|
||||
minTime != null && maxTime != null
|
||||
? `${Math.floor(minTime / 1e9)}-${Math.floor(maxTime / 1e9)}`
|
||||
: null;
|
||||
|
||||
return useQuery<
|
||||
AxiosResponse<QueryKeyValueSuggestionsResponseProps>,
|
||||
AxiosError
|
||||
>({
|
||||
queryKey: [
|
||||
'queryKeyValueSuggestions',
|
||||
key,
|
||||
@@ -32,6 +50,7 @@ export const useGetQueryKeyValueSuggestions = ({
|
||||
searchText,
|
||||
signalSource,
|
||||
metricName,
|
||||
timeRangeKey,
|
||||
],
|
||||
queryFn: () =>
|
||||
getValueSuggestions({
|
||||
@@ -40,6 +59,8 @@ export const useGetQueryKeyValueSuggestions = ({
|
||||
searchText: searchText || '',
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
metricName: metricName || '',
|
||||
existingQuery,
|
||||
}),
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -118,13 +118,13 @@ export const otherFiltersResponse = {
|
||||
export const quickFiltersAttributeValuesResponse = {
|
||||
status: 'success',
|
||||
data: {
|
||||
stringAttributeValues: [
|
||||
'mq-kafka',
|
||||
'otel-demo',
|
||||
'otlp-python',
|
||||
'sample-flask',
|
||||
],
|
||||
numberAttributeValues: null,
|
||||
boolAttributeValues: null,
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: ['mq-kafka', 'otel-demo', 'otlp-python', 'sample-flask'],
|
||||
stringValues: ['mq-kafka', 'otel-demo', 'otlp-python', 'sample-flask'],
|
||||
numberValues: [],
|
||||
},
|
||||
complete: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -62,3 +62,6 @@ export const listRolesSuccessResponse = {
|
||||
status: 'success',
|
||||
data: allRoles,
|
||||
};
|
||||
|
||||
export const customRoleResponse = { status: 'success', data: customRoles[0] };
|
||||
export const managedRoleResponse = { status: 'success', data: managedRoles[0] };
|
||||
|
||||
@@ -31,9 +31,28 @@
|
||||
.settings-page-sidenav {
|
||||
width: 240px;
|
||||
height: calc(100vh - 48px);
|
||||
border-right: 1px solid var(--Slate-500, #161922);
|
||||
background: var(--Ink-500, #0b0c0e);
|
||||
margin-top: 4px;
|
||||
border-right: 1px solid var(--secondary);
|
||||
background: var(--sidebar-primary-foreground);
|
||||
padding-top: var(--padding-1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
overflow-y: auto;
|
||||
|
||||
.settings-nav-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-nav-section-title {
|
||||
font-size: var(--uppercase-small-600-font-size);
|
||||
font-weight: var(--uppercase-small-600-font-weight);
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
color: var(--l3-foreground);
|
||||
margin-bottom: var(--margin-2);
|
||||
padding: var(--padding-1) var(--padding-3);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
.nav-item-data {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { FeatureKeys } from 'constants/features';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { routeConfig } from 'container/SideNav/config';
|
||||
import { getQueryString } from 'container/SideNav/helper';
|
||||
import { settingsMenuItems as defaultSettingsMenuItems } from 'container/SideNav/menuItems';
|
||||
import { settingsNavSections } from 'container/SideNav/menuItems';
|
||||
import NavItem from 'container/SideNav/NavItem/NavItem';
|
||||
import { SidebarItem } from 'container/SideNav/sideNav.types';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
@@ -33,7 +33,7 @@ function SettingsPage(): JSX.Element {
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
|
||||
const [settingsMenuItems, setSettingsMenuItems] = useState<SidebarItem[]>(
|
||||
defaultSettingsMenuItems,
|
||||
settingsNavSections.flatMap((section) => section.items),
|
||||
);
|
||||
|
||||
const isAdmin = user.role === USER_ROLES.ADMIN;
|
||||
@@ -78,8 +78,8 @@ function SettingsPage(): JSX.Element {
|
||||
isEnabled:
|
||||
item.key === ROUTES.BILLING ||
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.ROLE_DETAILS ||
|
||||
item.key === ROUTES.INTEGRATIONS ||
|
||||
item.key === ROUTES.CUSTOM_DOMAIN_SETTINGS ||
|
||||
item.key === ROUTES.API_KEYS ||
|
||||
item.key === ROUTES.INGESTION_SETTINGS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
@@ -109,6 +109,7 @@ function SettingsPage(): JSX.Element {
|
||||
isEnabled:
|
||||
item.key === ROUTES.BILLING ||
|
||||
item.key === ROUTES.ROLES_SETTINGS ||
|
||||
item.key === ROUTES.ROLE_DETAILS ||
|
||||
item.key === ROUTES.INTEGRATIONS ||
|
||||
item.key === ROUTES.API_KEYS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
@@ -135,9 +136,7 @@ function SettingsPage(): JSX.Element {
|
||||
updatedItems = updatedItems.map((item) => ({
|
||||
...item,
|
||||
isEnabled:
|
||||
item.key === ROUTES.API_KEYS ||
|
||||
item.key === ROUTES.ORG_SETTINGS ||
|
||||
item.key === ROUTES.ROLES_SETTINGS
|
||||
item.key === ROUTES.API_KEYS || item.key === ROUTES.ORG_SETTINGS
|
||||
? true
|
||||
: item.isEnabled,
|
||||
}));
|
||||
@@ -229,6 +228,13 @@ function SettingsPage(): JSX.Element {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
pathname.startsWith(ROUTES.ROLES_SETTINGS) &&
|
||||
key === ROUTES.ROLES_SETTINGS
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return pathname === key;
|
||||
};
|
||||
|
||||
@@ -246,25 +252,40 @@ function SettingsPage(): JSX.Element {
|
||||
|
||||
<div className="settings-page-content-container">
|
||||
<div className="settings-page-sidenav" data-testid="settings-page-sidenav">
|
||||
{settingsMenuItems
|
||||
.filter((item) => item.isEnabled)
|
||||
.map((item) => (
|
||||
<NavItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
isActive={isActiveNavItem(item.key as string)}
|
||||
isDisabled={false}
|
||||
showIcon={false}
|
||||
onClick={(event): void => {
|
||||
logEvent('Settings V2: Menu clicked', {
|
||||
menuLabel: item.label,
|
||||
menuRoute: item.key,
|
||||
});
|
||||
handleMenuItemClick((event as unknown) as MouseEvent, item);
|
||||
}}
|
||||
dataTestId={item.itemKey}
|
||||
/>
|
||||
))}
|
||||
{settingsNavSections.map((section) => {
|
||||
const enabledItems = section.items.filter((sectionItem) =>
|
||||
settingsMenuItems.some(
|
||||
(item) => item.key === sectionItem.key && item.isEnabled,
|
||||
),
|
||||
);
|
||||
if (enabledItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={section.key} className="settings-nav-section">
|
||||
{section.title && (
|
||||
<div className="settings-nav-section-title">{section.title}</div>
|
||||
)}
|
||||
{enabledItems.map((item) => (
|
||||
<NavItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
isActive={isActiveNavItem(item.key as string)}
|
||||
isDisabled={false}
|
||||
showIcon={false}
|
||||
onClick={(event): void => {
|
||||
logEvent('Settings V2: Menu clicked', {
|
||||
menuLabel: item.label,
|
||||
menuRoute: item.key,
|
||||
});
|
||||
handleMenuItemClick((event as unknown) as MouseEvent, item);
|
||||
}}
|
||||
dataTestId={item.itemKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="settings-page-content">
|
||||
|
||||
@@ -5,7 +5,6 @@ import APIKeys from 'container/APIKeys/APIKeys';
|
||||
import BillingContainer from 'container/BillingContainer/BillingContainer';
|
||||
import CreateAlertChannels from 'container/CreateAlertChannels';
|
||||
import { ChannelType } from 'container/CreateAlertChannels/config';
|
||||
import CustomDomainSettings from 'container/CustomDomainSettings';
|
||||
import GeneralSettings from 'container/GeneralSettings';
|
||||
import GeneralSettingsCloud from 'container/GeneralSettingsCloud';
|
||||
import IngestionSettings from 'container/IngestionSettings/IngestionSettings';
|
||||
@@ -13,6 +12,7 @@ import MultiIngestionSettings from 'container/IngestionSettings/MultiIngestionSe
|
||||
import MySettings from 'container/MySettings';
|
||||
import OrganizationSettings from 'container/OrganizationSettings';
|
||||
import RolesSettings from 'container/RolesSettings';
|
||||
import RoleDetailsPage from 'container/RolesSettings/RoleDetails';
|
||||
import { TFunction } from 'i18next';
|
||||
import {
|
||||
Backpack,
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
Building,
|
||||
Cpu,
|
||||
CreditCard,
|
||||
Globe,
|
||||
Keyboard,
|
||||
KeySquare,
|
||||
Pencil,
|
||||
@@ -124,19 +123,6 @@ export const apiKeys = (t: TFunction): RouteTabProps['routes'] => [
|
||||
},
|
||||
];
|
||||
|
||||
export const customDomainSettings = (t: TFunction): RouteTabProps['routes'] => [
|
||||
{
|
||||
Component: CustomDomainSettings,
|
||||
name: (
|
||||
<div className="periscope-tab">
|
||||
<Globe size={16} /> {t('routes:custom_domain_settings').toString()}
|
||||
</div>
|
||||
),
|
||||
route: ROUTES.CUSTOM_DOMAIN_SETTINGS,
|
||||
key: ROUTES.CUSTOM_DOMAIN_SETTINGS,
|
||||
},
|
||||
];
|
||||
|
||||
export const billingSettings = (t: TFunction): RouteTabProps['routes'] => [
|
||||
{
|
||||
Component: BillingContainer,
|
||||
@@ -163,6 +149,19 @@ export const rolesSettings = (t: TFunction): RouteTabProps['routes'] => [
|
||||
},
|
||||
];
|
||||
|
||||
export const roleDetails = (t: TFunction): RouteTabProps['routes'] => [
|
||||
{
|
||||
Component: RoleDetailsPage,
|
||||
name: (
|
||||
<div className="periscope-tab">
|
||||
<Shield size={16} /> {t('routes:role_details').toString()}
|
||||
</div>
|
||||
),
|
||||
route: ROUTES.ROLE_DETAILS,
|
||||
key: ROUTES.ROLE_DETAILS,
|
||||
},
|
||||
];
|
||||
|
||||
export const keyboardShortcuts = (t: TFunction): RouteTabProps['routes'] => [
|
||||
{
|
||||
Component: Shortcuts,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
apiKeys,
|
||||
billingSettings,
|
||||
createAlertChannels,
|
||||
customDomainSettings,
|
||||
editAlertChannels,
|
||||
generalSettings,
|
||||
ingestionSettings,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
multiIngestionSettings,
|
||||
mySettings,
|
||||
organizationSettings,
|
||||
roleDetails,
|
||||
rolesSettings,
|
||||
} from './config';
|
||||
|
||||
@@ -63,12 +63,9 @@ export const getRoutes = (
|
||||
settings.push(...apiKeys(t));
|
||||
}
|
||||
|
||||
// todo: Sagar - check the condition for role list and details page, to whom we want to serve
|
||||
if ((isCloudUser || isEnterpriseSelfHostedUser) && isAdmin) {
|
||||
settings.push(...customDomainSettings(t), ...billingSettings(t));
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
settings.push(...rolesSettings(t));
|
||||
settings.push(...billingSettings(t), ...rolesSettings(t), ...roleDetails(t));
|
||||
}
|
||||
|
||||
settings.push(
|
||||
|
||||
@@ -592,6 +592,39 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.ant-btn-text:hover,
|
||||
.ant-btn-text:focus-visible {
|
||||
background-color: var(--bg-vanilla-200) !important;
|
||||
}
|
||||
|
||||
.ant-btn-link:hover,
|
||||
.ant-btn-link:focus-visible {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.ant-btn-default:hover,
|
||||
.ant-btn-default:focus-visible,
|
||||
.ant-btn-text:not(.ant-btn-primary):hover,
|
||||
.ant-btn-text:not(.ant-btn-primary):focus-visible,
|
||||
.ant-btn:not(.ant-btn-primary):not(.ant-btn-dangerous):hover,
|
||||
.ant-btn:not(.ant-btn-primary):not(.ant-btn-dangerous):focus-visible {
|
||||
background-color: var(--bg-vanilla-200) !important;
|
||||
}
|
||||
|
||||
.ant-typography:hover,
|
||||
.ant-typography:focus-visible {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.ant-tooltip {
|
||||
--antd-arrow-background-color: var(--bg-vanilla-300);
|
||||
|
||||
.ant-tooltip-inner {
|
||||
background-color: var(--bg-vanilla-200);
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced legend light mode styles
|
||||
.u-legend-enhanced {
|
||||
// Light mode scrollbar styling
|
||||
|
||||
@@ -245,6 +245,7 @@ export function getAppContextMock(
|
||||
ee: 'Y',
|
||||
setupCompleted: true,
|
||||
},
|
||||
|
||||
...appContextOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface QueryKeyValueRequestProps {
|
||||
searchText: string;
|
||||
signalSource?: 'meter' | '';
|
||||
metricName?: string;
|
||||
existingQuery?: string;
|
||||
}
|
||||
|
||||
export type SignalType = 'traces' | 'logs' | 'metrics';
|
||||
|
||||
@@ -11,3 +11,8 @@ export const USER_ROLES = {
|
||||
EDITOR: 'EDITOR',
|
||||
AUTHOR: 'AUTHOR',
|
||||
};
|
||||
|
||||
export enum RoleType {
|
||||
MANAGED = 'managed',
|
||||
CUSTOM = 'custom',
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp } from 'types/api';
|
||||
import { ErrorResponseHandlerForGeneratedAPIs } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
/**
|
||||
@@ -35,11 +35,11 @@ export const isRetryableError = (error: any): boolean => {
|
||||
};
|
||||
|
||||
export function toAPIError(
|
||||
error: unknown,
|
||||
error: ErrorType<RenderErrorResponseDTO>,
|
||||
defaultMessage = 'An unexpected error occurred.',
|
||||
): APIError {
|
||||
try {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
ErrorResponseHandlerForGeneratedAPIs(error);
|
||||
} catch (apiError) {
|
||||
if (apiError instanceof APIError) {
|
||||
return apiError;
|
||||
@@ -55,3 +55,14 @@ export function toAPIError(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function handleApiError(
|
||||
err: ErrorType<RenderErrorResponseDTO>,
|
||||
showErrorFunction: (error: APIError) => void,
|
||||
): void {
|
||||
try {
|
||||
ErrorResponseHandlerForGeneratedAPIs(err);
|
||||
} catch (apiError) {
|
||||
showErrorFunction(apiError as APIError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ROLES_SETTINGS: ['ADMIN'],
|
||||
ROLE_DETAILS: ['ADMIN'],
|
||||
BILLING: ['ADMIN'],
|
||||
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
@@ -106,7 +107,6 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
TRACES_FUNNELS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
TRACES_FUNNELS_DETAIL: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
API_KEYS: ['ADMIN'],
|
||||
CUSTOM_DOMAIN_SETTINGS: ['ADMIN'],
|
||||
LOGS_BASE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
OLD_LOGS_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SHORTCUTS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
|
||||
@@ -4804,6 +4804,28 @@
|
||||
"@radix-ui/react-roving-focus" "1.0.4"
|
||||
"@radix-ui/react-use-controllable-state" "1.0.1"
|
||||
|
||||
"@radix-ui/react-toggle-group@^1.1.7":
|
||||
version "1.1.11"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz#e513d6ffdb07509b400ab5b26f2523747c0d51c1"
|
||||
integrity sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.3"
|
||||
"@radix-ui/react-context" "1.1.2"
|
||||
"@radix-ui/react-direction" "1.1.1"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-roving-focus" "1.1.11"
|
||||
"@radix-ui/react-toggle" "1.1.10"
|
||||
"@radix-ui/react-use-controllable-state" "1.2.2"
|
||||
|
||||
"@radix-ui/react-toggle@1.1.10", "@radix-ui/react-toggle@^1.1.6":
|
||||
version "1.1.10"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz#b04ba0f9609599df666fce5b2f38109a197f08cf"
|
||||
integrity sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==
|
||||
dependencies:
|
||||
"@radix-ui/primitive" "1.1.3"
|
||||
"@radix-ui/react-primitive" "2.1.3"
|
||||
"@radix-ui/react-use-controllable-state" "1.2.2"
|
||||
|
||||
"@radix-ui/react-tooltip@1.0.7":
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.0.7.tgz#8f55070f852e7e7450cc1d9210b793d2e5a7686e"
|
||||
@@ -5483,6 +5505,20 @@
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/design-tokens/-/design-tokens-2.1.1.tgz#9c36d433fd264410713cc0c5ebdd75ce0ebecba3"
|
||||
integrity sha512-SdziCHg5Lwj+6oY6IRUPplaKZ+kTHjbrlhNj//UoAJ8aQLnRdR2F/miPzfSi4vrYw88LtXxNA9J9iJyacCp37A==
|
||||
|
||||
"@signozhq/dialog@^0.0.2":
|
||||
version "0.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/dialog/-/dialog-0.0.2.tgz#55bd8e693f76325fda9aabe3197350e0adc163c4"
|
||||
integrity sha512-YT5t3oZpGkAuWptTqhCgVtLjxsRQrEIrQHFoXpP9elM1+O4TS9WHr+07BLQutOVg6u9n9pCvW3OYf0SCETkDVQ==
|
||||
dependencies:
|
||||
"@radix-ui/react-dialog" "^1.1.11"
|
||||
"@radix-ui/react-icons" "^1.3.0"
|
||||
"@radix-ui/react-slot" "^1.1.0"
|
||||
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/icons@0.1.0", "@signozhq/icons@^0.1.0":
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/icons/-/icons-0.1.0.tgz#00dfb430dbac423bfff715876f91a7b8a72509e4"
|
||||
@@ -5595,6 +5631,21 @@
|
||||
tailwind-merge "^2.5.2"
|
||||
tailwindcss-animate "^1.0.7"
|
||||
|
||||
"@signozhq/toggle-group@^0.0.1":
|
||||
version "0.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/toggle-group/-/toggle-group-0.0.1.tgz#c82ff1da34e77b24da53c2d595ad6b4a0d1b1de4"
|
||||
integrity sha512-871bQayL5MaqsuNOFHKexidu9W2Hlg1y4xmH8C5mGmlfZ4bd0ovJ9OweQrM6Puys3jeMwi69xmJuesYCfKQc1g==
|
||||
dependencies:
|
||||
"@radix-ui/react-icons" "^1.3.0"
|
||||
"@radix-ui/react-slot" "^1.1.0"
|
||||
"@radix-ui/react-toggle" "^1.1.6"
|
||||
"@radix-ui/react-toggle-group" "^1.1.7"
|
||||
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/tooltip@0.0.2":
|
||||
version "0.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@signozhq/tooltip/-/tooltip-0.0.2.tgz#bb4e2681868fa2e06db78eff5872ffb2a78b93b6"
|
||||
|
||||
Reference in New Issue
Block a user