mirror of
https://github.com/SigNoz/signoz.git
synced 2026-03-05 13:22:00 +00:00
Compare commits
6 Commits
SIG_3522
...
SIG-1783-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32112ef8d2 | ||
|
|
04563833d2 | ||
|
|
1528c6cfe1 | ||
|
|
c140043f6e | ||
|
|
a08170bb09 | ||
|
|
4ff2d44188 |
@@ -190,7 +190,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.114.0
|
||||
image: signoz/signoz:v0.113.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
# - "6060:6060" # pprof port
|
||||
@@ -213,7 +213,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.2
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
command:
|
||||
@@ -241,7 +241,7 @@ services:
|
||||
replicas: 3
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.2
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.114.0
|
||||
image: signoz/signoz:v0.113.0
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
volumes:
|
||||
@@ -139,7 +139,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.2
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
command:
|
||||
@@ -167,7 +167,7 @@ services:
|
||||
replicas: 3
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.144.2
|
||||
image: signoz/signoz-otel-collector:v0.144.1
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster
|
||||
|
||||
@@ -181,7 +181,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.114.0}
|
||||
image: signoz/signoz:${VERSION:-v0.113.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
@@ -204,7 +204,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.2}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
container_name: signoz-otel-collector
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
@@ -229,7 +229,7 @@ services:
|
||||
- "4318:4318" # OTLP HTTP receiver
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.2}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
container_name: signoz-telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.114.0}
|
||||
image: signoz/signoz:${VERSION:-v0.113.0}
|
||||
container_name: signoz
|
||||
ports:
|
||||
- "8080:8080" # signoz port
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.2}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
container_name: signoz-otel-collector
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
@@ -157,7 +157,7 @@ services:
|
||||
- "4318:4318" # OTLP HTTP receiver
|
||||
signoz-telemetrystore-migrator:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.2}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.1}
|
||||
container_name: signoz-telemetrystore-migrator
|
||||
environment:
|
||||
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import dayjs from 'dayjs';
|
||||
import * as timeUtils from 'utils/timeUtils';
|
||||
|
||||
import CustomTimePicker from './CustomTimePicker';
|
||||
|
||||
jest.mock('react-router-dom', () => {
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useLocation: jest.fn().mockReturnValue({
|
||||
pathname: '/test-path',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('providers/Timezone', () => {
|
||||
const actual = jest.requireActual('providers/Timezone');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useTimezone: jest.fn().mockReturnValue({
|
||||
timezone: {
|
||||
value: 'UTC',
|
||||
offset: '+00:00',
|
||||
name: 'UTC',
|
||||
},
|
||||
browserTimezone: {
|
||||
value: 'UTC',
|
||||
offset: '+00:00',
|
||||
name: 'UTC',
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
interface WrapperProps {
|
||||
initialValue?: string;
|
||||
showLiveLogs?: boolean;
|
||||
onValidCustomDateChange?: () => void;
|
||||
onError?: () => void;
|
||||
onSelect?: (value: string) => void;
|
||||
onCustomDateHandler?: () => void;
|
||||
onCustomTimeStatusUpdate?: () => void;
|
||||
}
|
||||
|
||||
function Wrapper({
|
||||
initialValue = '2024-01-01 00:00:00 - 2024-01-01 01:00:00',
|
||||
showLiveLogs = false,
|
||||
onValidCustomDateChange = (): void => {},
|
||||
onError = (): void => {},
|
||||
onSelect = (): void => {},
|
||||
onCustomDateHandler = (): void => {},
|
||||
onCustomTimeStatusUpdate = (): void => {},
|
||||
}: WrapperProps): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedTime, setSelectedTime] = useState('custom');
|
||||
const [selectedValue, setSelectedValue] = useState(initialValue);
|
||||
|
||||
const handleSelect = (value: string): void => {
|
||||
setSelectedTime(value);
|
||||
onSelect(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomTimePicker
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
onSelect={handleSelect}
|
||||
onError={onError}
|
||||
selectedTime={selectedTime}
|
||||
selectedValue={selectedValue}
|
||||
onValidCustomDateChange={({ timeStr }): void => {
|
||||
setSelectedValue(timeStr);
|
||||
onValidCustomDateChange();
|
||||
}}
|
||||
onCustomDateHandler={(): void => {
|
||||
onCustomDateHandler();
|
||||
}}
|
||||
onCustomTimeStatusUpdate={(): void => {
|
||||
onCustomTimeStatusUpdate();
|
||||
}}
|
||||
items={[
|
||||
{ label: 'Last 5 minutes', value: '5m' },
|
||||
{ label: 'Custom', value: 'custom' },
|
||||
]}
|
||||
minTime={dayjs('2024-01-01 00:00:00').valueOf() * 1000_000}
|
||||
maxTime={dayjs('2024-01-01 01:00:00').valueOf() * 1000_000}
|
||||
showLiveLogs={showLiveLogs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('CustomTimePicker', () => {
|
||||
it('does not close or reset when clicking input while open', () => {
|
||||
render(<Wrapper />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
// Open popover
|
||||
fireEvent.focus(input);
|
||||
|
||||
// Type some text
|
||||
fireEvent.change(input, { target: { value: '5m' } });
|
||||
|
||||
// Click the input again while open
|
||||
fireEvent.mouseDown(input);
|
||||
fireEvent.click(input);
|
||||
|
||||
// Value should remain as typed
|
||||
expect((input as HTMLInputElement).value).toBe('5m');
|
||||
});
|
||||
|
||||
it('applies valid shorthand on Enter', () => {
|
||||
const onValid = jest.fn();
|
||||
const onError = jest.fn();
|
||||
|
||||
render(<Wrapper onValidCustomDateChange={onValid} onError={onError} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: '5m' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(onValid).toHaveBeenCalledTimes(1);
|
||||
// onError(false) may be called by internal reset logic; we only assert that
|
||||
// it was never called with a truthy error state
|
||||
expect(onError).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('sets error and updates custom time status for invalid shorthand exceeding max allowed window', () => {
|
||||
const onValid = jest.fn();
|
||||
const onError = jest.fn();
|
||||
const onCustomTimeStatusUpdate = jest.fn();
|
||||
|
||||
render(
|
||||
<Wrapper
|
||||
onValidCustomDateChange={onValid}
|
||||
onError={onError}
|
||||
onCustomTimeStatusUpdate={onCustomTimeStatusUpdate}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.focus(input);
|
||||
// large number of days to ensure it exceeds the 15 months allowed window
|
||||
fireEvent.change(input, { target: { value: '9999d' } });
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(true);
|
||||
expect(onCustomTimeStatusUpdate).toHaveBeenCalledWith();
|
||||
expect(onValid).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats close after change like pressing Enter (blur + chevron)', () => {
|
||||
const onValid = jest.fn();
|
||||
const onError = jest.fn();
|
||||
|
||||
render(<Wrapper onValidCustomDateChange={onValid} onError={onError} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
// Open and change value so "changed since open" is true
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, { target: { value: '5m' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
// Click the chevron (which triggers handleClose)
|
||||
const chevron = document.querySelector(
|
||||
'.time-input-suffix-icon-badge',
|
||||
) as HTMLElement;
|
||||
|
||||
fireEvent.click(chevron);
|
||||
|
||||
// Should have applied the value (same as Enter)
|
||||
expect(onValid).toHaveBeenCalledTimes(1);
|
||||
expect(onError).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('applies epoch start/end range on Enter via onCustomDateHandler', () => {
|
||||
const onCustomDateHandler = jest.fn();
|
||||
const onError = jest.fn();
|
||||
|
||||
render(
|
||||
<Wrapper onCustomDateHandler={onCustomDateHandler} onError={onError} />,
|
||||
);
|
||||
|
||||
const now = dayjs().valueOf();
|
||||
const later = dayjs().add(1, 'hour').valueOf();
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, {
|
||||
target: { value: `${now} - ${later}` },
|
||||
});
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(onCustomDateHandler).toHaveBeenCalledTimes(1);
|
||||
expect(onError).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('uses validateTimeRange result for generic formatted ranges (valid case)', () => {
|
||||
const validateTimeRangeSpy = jest.spyOn(timeUtils, 'validateTimeRange');
|
||||
const onCustomDateHandler = jest.fn();
|
||||
const onError = jest.fn();
|
||||
|
||||
validateTimeRangeSpy.mockReturnValue({
|
||||
isValid: true,
|
||||
errorDetails: undefined,
|
||||
startTimeMs: dayjs('2024-01-01 00:00:00').valueOf(),
|
||||
endTimeMs: dayjs('2024-01-01 01:00:00').valueOf(),
|
||||
});
|
||||
|
||||
render(
|
||||
<Wrapper onCustomDateHandler={onCustomDateHandler} onError={onError} />,
|
||||
);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'foo - bar' },
|
||||
});
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(validateTimeRangeSpy).toHaveBeenCalled();
|
||||
expect(onCustomDateHandler).toHaveBeenCalledTimes(1);
|
||||
expect(onError).not.toHaveBeenCalledWith(true);
|
||||
|
||||
validateTimeRangeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('uses validateTimeRange result for generic formatted ranges (invalid case)', () => {
|
||||
const validateTimeRangeSpy = jest.spyOn(timeUtils, 'validateTimeRange');
|
||||
const onValid = jest.fn();
|
||||
const onError = jest.fn();
|
||||
|
||||
validateTimeRangeSpy.mockReturnValue({
|
||||
isValid: false,
|
||||
errorDetails: {
|
||||
message: 'Invalid range',
|
||||
code: 'INVALID_RANGE',
|
||||
description: 'Start must be before end',
|
||||
},
|
||||
startTimeMs: 0,
|
||||
endTimeMs: 0,
|
||||
});
|
||||
|
||||
render(<Wrapper onValidCustomDateChange={onValid} onError={onError} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, {
|
||||
target: { value: 'foo - bar' },
|
||||
});
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(validateTimeRangeSpy).toHaveBeenCalled();
|
||||
expect(onError).toHaveBeenCalledWith(true);
|
||||
expect(onValid).not.toHaveBeenCalled();
|
||||
|
||||
validateTimeRangeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('opens live mode with correct label', () => {
|
||||
render(<Wrapper showLiveLogs />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.focus(input);
|
||||
|
||||
expect((input as HTMLInputElement).value).toBe('Live');
|
||||
});
|
||||
});
|
||||
@@ -104,10 +104,6 @@ function CustomTimePicker({
|
||||
const location = useLocation();
|
||||
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const initialInputValueOnOpenRef = useRef<string>('');
|
||||
const hasChangedSinceOpenRef = useRef<boolean>(false);
|
||||
// Tracks if the last pointer down was on the input so we don't close the popover when user clicks the input again
|
||||
const isClickFromInputRef = useRef(false);
|
||||
|
||||
const [activeView, setActiveView] = useState<ViewType>(DEFAULT_VIEW);
|
||||
|
||||
@@ -242,21 +238,6 @@ function CustomTimePicker({
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean): void => {
|
||||
// Don't close when the user clicked the input (trigger); Ant Design treats trigger as "outside" overlay
|
||||
if (!newOpen && isClickFromInputRef.current) {
|
||||
isClickFromInputRef.current = false;
|
||||
return;
|
||||
}
|
||||
isClickFromInputRef.current = false;
|
||||
|
||||
// If the popover is trying to close and the value changed since opening,
|
||||
// treat it as if the user pressed Enter (attempt to apply the value)
|
||||
if (!newOpen && hasChangedSinceOpenRef.current) {
|
||||
hasChangedSinceOpenRef.current = false;
|
||||
handleInputPressEnter();
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(newOpen);
|
||||
|
||||
if (!newOpen) {
|
||||
@@ -425,18 +406,10 @@ function CustomTimePicker({
|
||||
const handleOpen = (e?: React.SyntheticEvent): void => {
|
||||
e?.stopPropagation?.();
|
||||
|
||||
// If the popover is already open, avoid resetting the input value
|
||||
// so that any in-progress edits are preserved.
|
||||
if (open) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (showLiveLogs) {
|
||||
setOpen(true);
|
||||
setSelectedTimePlaceholderValue('Live');
|
||||
setInputValue('Live');
|
||||
initialInputValueOnOpenRef.current = 'Live';
|
||||
hasChangedSinceOpenRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -451,21 +424,11 @@ function CustomTimePicker({
|
||||
.tz(timezone.value)
|
||||
.format(DATE_TIME_FORMATS.UK_DATETIME_SECONDS);
|
||||
|
||||
const nextValue = `${startTime} - ${endTime}`;
|
||||
setInputValue(nextValue);
|
||||
initialInputValueOnOpenRef.current = nextValue;
|
||||
hasChangedSinceOpenRef.current = false;
|
||||
setInputValue(`${startTime} - ${endTime}`);
|
||||
};
|
||||
|
||||
const handleClose = (e: React.MouseEvent): void => {
|
||||
e.stopPropagation();
|
||||
// If the value changed since opening, treat this like pressing Enter
|
||||
if (hasChangedSinceOpenRef.current) {
|
||||
hasChangedSinceOpenRef.current = false;
|
||||
handleInputPressEnter();
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
setCustomDTPickerVisible?.(false);
|
||||
|
||||
@@ -487,9 +450,6 @@ function CustomTimePicker({
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleInputBlur = (): void => {
|
||||
// Track whether the value was changed since the input was opened for editing
|
||||
hasChangedSinceOpenRef.current =
|
||||
inputValue !== initialInputValueOnOpenRef.current;
|
||||
resetErrorStatus();
|
||||
};
|
||||
|
||||
@@ -592,12 +552,6 @@ function CustomTimePicker({
|
||||
readOnly={!open || showLiveLogs}
|
||||
placeholder={selectedTimePlaceholderValue}
|
||||
value={inputValue}
|
||||
onMouseDown={(e): void => {
|
||||
// Only treat as "click from input" when the actual input element is clicked (not suffix/chevron)
|
||||
if (e.target === inputRef.current?.input) {
|
||||
isClickFromInputRef.current = true;
|
||||
}
|
||||
}}
|
||||
onFocus={handleOpen}
|
||||
onClick={handleOpen}
|
||||
onChange={handleInputChange}
|
||||
|
||||
@@ -674,7 +674,7 @@ function GeneralSettings({
|
||||
return (
|
||||
<div className="general-settings-page">
|
||||
<div className="general-settings-header">
|
||||
<span className="general-settings-title">General</span>
|
||||
<span className="general-settings-title">Workspace</span>
|
||||
<span className="general-settings-subtitle">
|
||||
Manage your workspace settings.
|
||||
</span>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
74
frontend/src/container/SideNav/__tests__/menuItems.test.tsx
Normal file
74
frontend/src/container/SideNav/__tests__/menuItems.test.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { getUserSettingsDropdownMenuItems } from 'container/SideNav/menuItems';
|
||||
|
||||
const BASE_PARAMS = {
|
||||
userEmail: 'test@signoz.io',
|
||||
isWorkspaceBlocked: false,
|
||||
isEnterpriseSelfHostedUser: false,
|
||||
isCommunityEnterpriseUser: false,
|
||||
};
|
||||
|
||||
describe('getUserSettingsDropdownMenuItems', () => {
|
||||
it('always includes logged-in-as label, workspace, account, keyboard shortcuts, and sign out', () => {
|
||||
const items = getUserSettingsDropdownMenuItems(BASE_PARAMS);
|
||||
const keys = items?.map((item) => item?.key);
|
||||
|
||||
expect(keys).toContain('label');
|
||||
expect(keys).toContain('workspace');
|
||||
expect(keys).toContain('account');
|
||||
expect(keys).toContain('keyboard-shortcuts');
|
||||
expect(keys).toContain('logout');
|
||||
|
||||
// workspace item is enabled when workspace is not blocked
|
||||
const workspaceItem = items?.find(
|
||||
(item: any) => item.key === 'workspace',
|
||||
) as any;
|
||||
|
||||
expect(workspaceItem?.disabled).toBe(false);
|
||||
|
||||
// does not include license item for regular cloud user
|
||||
expect(keys).not.toContain('license');
|
||||
});
|
||||
|
||||
it('includes manage license item for enterprise self-hosted users', () => {
|
||||
const items = getUserSettingsDropdownMenuItems({
|
||||
...BASE_PARAMS,
|
||||
isEnterpriseSelfHostedUser: true,
|
||||
});
|
||||
const keys = items?.map((item) => item?.key);
|
||||
|
||||
expect(keys).toContain('license');
|
||||
});
|
||||
|
||||
it('includes manage license item for community enterprise users', () => {
|
||||
const items = getUserSettingsDropdownMenuItems({
|
||||
...BASE_PARAMS,
|
||||
isCommunityEnterpriseUser: true,
|
||||
});
|
||||
const keys = items?.map((item) => item?.key);
|
||||
|
||||
expect(keys).toContain('license');
|
||||
});
|
||||
|
||||
it('workspace item is disabled when workspace is blocked', () => {
|
||||
const items = getUserSettingsDropdownMenuItems({
|
||||
...BASE_PARAMS,
|
||||
isWorkspaceBlocked: true,
|
||||
});
|
||||
const workspaceItem = items?.find(
|
||||
(item: any) => item.key === 'workspace',
|
||||
) as any;
|
||||
|
||||
expect(workspaceItem?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('returns items in correct order: label, divider, workspace, account, ..., shortcuts, divider, logout', () => {
|
||||
const items = getUserSettingsDropdownMenuItems(BASE_PARAMS) ?? [];
|
||||
const keys = items.map((item: any) => item.key ?? item.type);
|
||||
|
||||
expect(keys[0]).toBe('label');
|
||||
expect(keys[1]).toBe('divider');
|
||||
expect(keys[2]).toBe('workspace');
|
||||
expect(keys[3]).toBe('account');
|
||||
expect(keys[keys.length - 1]).toBe('logout');
|
||||
});
|
||||
});
|
||||
@@ -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,6 +10,7 @@ import {
|
||||
Book,
|
||||
Boxes,
|
||||
BugIcon,
|
||||
Building2,
|
||||
ChartArea,
|
||||
Cloudy,
|
||||
DraftingCompass,
|
||||
@@ -20,6 +23,7 @@ import {
|
||||
Layers2,
|
||||
LayoutGrid,
|
||||
ListMinus,
|
||||
LogOut,
|
||||
MessageSquareText,
|
||||
Plus,
|
||||
Receipt,
|
||||
@@ -34,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,
|
||||
@@ -296,77 +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: 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: '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: ROUTES.INTEGRATIONS,
|
||||
label: 'Integrations',
|
||||
icon: <Unplug size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'integrations',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
key: ROUTES.INTEGRATIONS,
|
||||
label: 'Integrations',
|
||||
icon: <Unplug size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'integrations',
|
||||
key: 'identity-access',
|
||||
title: 'Identity & Access',
|
||||
items: [
|
||||
{
|
||||
key: ROUTES.ROLES_SETTINGS,
|
||||
label: 'Roles',
|
||||
icon: <Shield size={16} />,
|
||||
isEnabled: false,
|
||||
itemKey: 'roles',
|
||||
},
|
||||
{
|
||||
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.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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -417,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',
|
||||
@@ -431,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;
|
||||
|
||||
@@ -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;
|
||||
@@ -252,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">
|
||||
|
||||
137
frontend/src/pages/Settings/__tests__/Settings.test.tsx
Normal file
137
frontend/src/pages/Settings/__tests__/Settings.test.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import SettingsPage from 'pages/Settings/Settings';
|
||||
import { render, screen, within } from 'tests/test-utils';
|
||||
import { LicensePlatform } from 'types/api/licensesV3/getActive';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
jest.mock('components/MarkdownRenderer/MarkdownRenderer', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children }: { children: React.ReactNode }): React.ReactNode =>
|
||||
children,
|
||||
}));
|
||||
|
||||
jest.mock('api/common/logEvent', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('lib/history', () => ({
|
||||
push: jest.fn(),
|
||||
listen: jest.fn(() => jest.fn()),
|
||||
location: { pathname: '/settings', search: '' },
|
||||
}));
|
||||
|
||||
const getCloudAdminOverrides = (): any => ({
|
||||
activeLicense: {
|
||||
key: 'test-key',
|
||||
platform: LicensePlatform.CLOUD,
|
||||
},
|
||||
});
|
||||
|
||||
const getSelfHostedAdminOverrides = (): any => ({
|
||||
activeLicense: {
|
||||
key: 'test-key',
|
||||
platform: LicensePlatform.SELF_HOSTED,
|
||||
},
|
||||
});
|
||||
|
||||
describe('SettingsPage nav sections', () => {
|
||||
describe('Cloud Admin', () => {
|
||||
beforeEach(() => {
|
||||
render(<SettingsPage />, undefined, {
|
||||
role: USER_ROLES.ADMIN,
|
||||
appContextOverrides: getCloudAdminOverrides(),
|
||||
initialRoute: '/settings',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
'settings-page-sidenav',
|
||||
'workspace',
|
||||
'account',
|
||||
'notification-channels',
|
||||
'billing',
|
||||
'roles',
|
||||
'api-keys',
|
||||
'members-sso',
|
||||
'integrations',
|
||||
'ingestion',
|
||||
])('renders "%s" element', (id) => {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['Identity & Access', 'Authentication'])(
|
||||
'renders "%s" section title',
|
||||
(text) => {
|
||||
expect(screen.getByText(text)).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Cloud Viewer', () => {
|
||||
beforeEach(() => {
|
||||
render(<SettingsPage />, undefined, {
|
||||
role: USER_ROLES.VIEWER,
|
||||
appContextOverrides: getCloudAdminOverrides(),
|
||||
initialRoute: '/settings',
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['workspace', 'account'])('renders "%s" element', (id) => {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['billing', 'roles', 'api-keys'])(
|
||||
'does not render "%s" element',
|
||||
(id) => {
|
||||
expect(screen.queryByTestId(id)).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('Self-hosted Admin', () => {
|
||||
beforeEach(() => {
|
||||
render(<SettingsPage />, undefined, {
|
||||
role: USER_ROLES.ADMIN,
|
||||
appContextOverrides: getSelfHostedAdminOverrides(),
|
||||
initialRoute: '/settings',
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['roles', 'api-keys', 'integrations', 'members-sso', 'ingestion'])(
|
||||
'renders "%s" element',
|
||||
(id) => {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('section structure', () => {
|
||||
it('renders items in correct sections for cloud admin', () => {
|
||||
const { container } = render(<SettingsPage />, undefined, {
|
||||
role: USER_ROLES.ADMIN,
|
||||
appContextOverrides: getCloudAdminOverrides(),
|
||||
initialRoute: '/settings',
|
||||
});
|
||||
|
||||
const sidenav = within(container).getByTestId('settings-page-sidenav');
|
||||
const sections = sidenav.querySelectorAll('.settings-nav-section');
|
||||
|
||||
// Should have at least 2 sections (general + identity-access) for cloud admin
|
||||
expect(sections.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('hides section entirely when all items in it are disabled', () => {
|
||||
// Community user has very limited access — identity section should be hidden
|
||||
render(<SettingsPage />, undefined, {
|
||||
role: USER_ROLES.VIEWER,
|
||||
appContextOverrides: {
|
||||
activeLicense: null,
|
||||
},
|
||||
initialRoute: '/settings',
|
||||
});
|
||||
|
||||
expect(screen.queryByText('IDENTITY & ACCESS')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -237,21 +237,11 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
|
||||
return nil, errors.WrapInternalf(promErr, errors.CodeInternal, "error getting matrix from promql query %q", query)
|
||||
}
|
||||
|
||||
excludeLabel := func(labelName string) bool {
|
||||
if labelName == "__name__" {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(labelName, "__") || labelName == "fingerprint"
|
||||
}
|
||||
|
||||
var series []*qbv5.TimeSeries
|
||||
for _, v := range matrix {
|
||||
var s qbv5.TimeSeries
|
||||
lbls := make([]*qbv5.Label, 0, len(v.Metric))
|
||||
for name, value := range v.Metric.Copy().Map() {
|
||||
if excludeLabel(name) {
|
||||
continue
|
||||
}
|
||||
lbls = append(lbls, &qbv5.Label{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: name},
|
||||
Value: value,
|
||||
|
||||
Reference in New Issue
Block a user