Compare commits

..

7 Commits

Author SHA1 Message Date
Abhi kumar
518aefb42c Merge branch 'main' into fix/tooltip-height 2026-02-19 19:04:48 +05:30
Abhi Kumar
0602dbc6e5 Merge branch 'fix/tooltip-height' of https://github.com/SigNoz/signoz into fix/tooltip-height 2026-02-19 18:33:01 +05:30
Abhi Kumar
050be66b96 chore: added test for tooltip util 2026-02-19 18:32:41 +05:30
Abhi kumar
74f89d215e Merge branch 'main' into fix/tooltip-height 2026-02-19 18:26:25 +05:30
Abhi Kumar
797a7ba071 chore: refactored tooltip compute code + added test for tooltip 2026-02-19 18:26:00 +05:30
Abhi Kumar
e3acc65f85 fix: minor changes 2026-02-19 02:53:54 +05:30
Abhi Kumar
6d09ee6d68 fix: added a fix for tooltip height when legend is too big 2026-02-19 02:25:55 +05:30
26 changed files with 574 additions and 924 deletions

View File

@@ -12,6 +12,5 @@
"pipeline": "Pipeline",
"pipelines": "Pipelines",
"archives": "Archives",
"logs_to_metrics": "Logs To Metrics",
"roles": "Roles"
"logs_to_metrics": "Logs To Metrics"
}

View File

@@ -12,6 +12,5 @@
"pipeline": "Pipeline",
"pipelines": "Pipelines",
"archives": "Archives",
"logs_to_metrics": "Logs To Metrics",
"roles": "Roles"
"logs_to_metrics": "Logs To Metrics"
}

View File

@@ -73,6 +73,5 @@
"API_MONITORING": "SigNoz | External APIs",
"METER_EXPLORER": "SigNoz | Meter Explorer",
"METER_EXPLORER_VIEWS": "SigNoz | Meter Explorer Views",
"METER": "SigNoz | Meter",
"ROLES_SETTINGS": "SigNoz | Roles"
"METER": "SigNoz | Meter"
}

View File

@@ -42,7 +42,6 @@
display: flex;
flex-direction: column;
padding: 16px;
padding-bottom: 0;
}
.title {
@@ -191,7 +190,7 @@
padding: 0px;
}
.log-detail-drawer__footer-hint {
position: sticky;
position: absolute;
bottom: 0;
left: 0;
right: 0;
@@ -367,7 +366,7 @@
}
.log-detail-drawer__footer-hint {
position: sticky;
position: absolute;
bottom: 0;
left: 0;
right: 0;

View File

@@ -55,7 +55,6 @@ const ROUTES = {
LOGS_INDEX_FIELDS: '/logs-explorer/index-fields',
TRACE_EXPLORER: '/trace-explorer',
BILLING: '/settings/billing',
ROLES_SETTINGS: '/settings/roles',
SUPPORT: '/support',
LOGS_SAVE_VIEWS: '/logs/saved-views',
TRACES_SAVE_VIEWS: '/traces/saved-views',

View File

@@ -1,7 +1,6 @@
import { useCallback } from 'react';
import ChartWrapper from 'container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper';
import HistogramTooltip from 'lib/uPlotV2/components/Tooltip/HistogramTooltip';
import { buildTooltipContent } from 'lib/uPlotV2/components/Tooltip/utils';
import {
HistogramTooltipProps,
TooltipRenderArgs,
@@ -22,21 +21,11 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
if (customRenderTooltip) {
return customRenderTooltip(props);
}
const content = buildTooltipContent({
data: props.uPlotInstance.data,
series: props.uPlotInstance.series,
dataIndexes: props.dataIndexes,
activeSeriesIndex: props.seriesIndex,
uPlotInstance: props.uPlotInstance,
yAxisUnit: rest.yAxisUnit ?? '',
decimalPrecision: rest.decimalPrecision,
});
const tooltipProps: HistogramTooltipProps = {
...props,
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
content,
};
return <HistogramTooltip {...tooltipProps} />;
},

View File

@@ -1,7 +1,6 @@
import { useCallback } from 'react';
import ChartWrapper from 'container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper';
import TimeSeriesTooltip from 'lib/uPlotV2/components/Tooltip/TimeSeriesTooltip';
import { buildTooltipContent } from 'lib/uPlotV2/components/Tooltip/utils';
import {
TimeSeriesTooltipProps,
TooltipRenderArgs,
@@ -17,21 +16,11 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
if (customRenderTooltip) {
return customRenderTooltip(props);
}
const content = buildTooltipContent({
data: props.uPlotInstance.data,
series: props.uPlotInstance.series,
dataIndexes: props.dataIndexes,
activeSeriesIndex: props.seriesIndex,
uPlotInstance: props.uPlotInstance,
yAxisUnit: rest.yAxisUnit ?? '',
decimalPrecision: rest.decimalPrecision,
});
const tooltipProps: TimeSeriesTooltipProps = {
...props,
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
content,
};
return <TimeSeriesTooltip {...tooltipProps} />;
},

View File

@@ -1,246 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useHistory } from 'react-router-dom';
import { Pagination, Skeleton } from 'antd';
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 useUrlQuery from 'hooks/useUrlQuery';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { useTimezone } from 'providers/Timezone';
import { toAPIError } from 'utils/errorUtils';
import '../RolesSettings.styles.scss';
const PAGE_SIZE = 20;
type DisplayItem =
| { type: 'section'; label: string; count?: number }
| { type: 'role'; role: RoletypesRoleDTO };
interface RolesListingTableProps {
searchQuery: string;
}
function RolesListingTable({
searchQuery,
}: RolesListingTableProps): JSX.Element {
const { data, isLoading, isError, error } = useListRoles();
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const history = useHistory();
const urlQuery = useUrlQuery();
const pageParam = parseInt(urlQuery.get('page') ?? '1', 10);
const currentPage = Number.isNaN(pageParam) || pageParam < 1 ? 1 : pageParam;
const setCurrentPage = useCallback(
(page: number): void => {
urlQuery.set('page', String(page));
history.replace({ search: urlQuery.toString() });
},
[history, urlQuery],
);
const roles = useMemo(() => data?.data?.data ?? [], [data]);
const formatTimestamp = (date?: Date | string): string => {
if (!date) {
return '—';
}
const d = new Date(date);
if (Number.isNaN(d.getTime())) {
return '—';
}
return formatTimezoneAdjustedTimestamp(date, DATE_TIME_FORMATS.DASH_DATETIME);
};
const filteredRoles = useMemo(() => {
if (!searchQuery.trim()) {
return roles;
}
const query = searchQuery.toLowerCase();
return roles.filter(
(role) =>
role.name?.toLowerCase().includes(query) ||
role.description?.toLowerCase().includes(query),
);
}, [roles, searchQuery]);
const managedRoles = useMemo(
() => filteredRoles.filter((role) => role.type?.toLowerCase() === 'managed'),
[filteredRoles],
);
const customRoles = useMemo(
() => filteredRoles.filter((role) => role.type?.toLowerCase() === 'custom'),
[filteredRoles],
);
// Combine managed + custom into a flat display list for pagination
const displayList = useMemo((): DisplayItem[] => {
const result: DisplayItem[] = [];
if (managedRoles.length > 0) {
result.push({ type: 'section', label: 'Managed roles' });
managedRoles.forEach((role) => result.push({ type: 'role', role }));
}
if (customRoles.length > 0) {
result.push({
type: 'section',
label: 'Custom roles',
count: customRoles.length,
});
customRoles.forEach((role) => result.push({ type: 'role', role }));
}
return result;
}, [managedRoles, customRoles]);
const totalRoleCount = managedRoles.length + customRoles.length;
// Ensure current page is valid; if out of bounds, redirect to last available page
useEffect(() => {
if (isLoading || totalRoleCount === 0) {
return;
}
const maxPage = Math.ceil(totalRoleCount / PAGE_SIZE);
if (currentPage > maxPage) {
setCurrentPage(maxPage);
}
}, [isLoading, totalRoleCount, currentPage, setCurrentPage]);
// Paginate: count only role items, but include section headers contextually
const paginatedItems = useMemo((): DisplayItem[] => {
const startRole = (currentPage - 1) * PAGE_SIZE;
const endRole = startRole + PAGE_SIZE;
let roleIndex = 0;
let lastSection: DisplayItem | null = null;
const result: DisplayItem[] = [];
for (const item of displayList) {
if (item.type === 'section') {
lastSection = item;
} else {
if (roleIndex >= startRole && roleIndex < endRole) {
// Insert section header before first role in that section on this page
if (lastSection) {
result.push(lastSection);
lastSection = null;
}
result.push(item);
}
roleIndex++;
}
}
return result;
}, [displayList, currentPage]);
const showPaginationItem = (total: number, range: number[]): JSX.Element => (
<>
<span className="numbers">
{range[0]} &#8212; {range[1]}
</span>
<span className="total"> of {total}</span>
</>
);
if (isLoading) {
return (
<div className="roles-listing-table">
<Skeleton active paragraph={{ rows: 5 }} />
</div>
);
}
if (isError) {
return (
<div className="roles-listing-table">
<ErrorInPlace
error={toAPIError(
error,
'An unexpected error occurred while fetching roles.',
)}
/>
</div>
);
}
if (filteredRoles.length === 0) {
return (
<div className="roles-listing-table">
<div className="roles-table-empty">
{searchQuery ? 'No roles match your search.' : 'No roles found.'}
</div>
</div>
);
}
// 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 className="roles-table-cell roles-table-cell--name">
{role.name ?? '—'}
</div>
<div className="roles-table-cell roles-table-cell--description">
<LineClampedText
text={role.description ?? '—'}
tooltipProps={{ overlayClassName: 'roles-description-tooltip' }}
/>
</div>
<div className="roles-table-cell roles-table-cell--updated-at">
{formatTimestamp(role.updatedAt)}
</div>
<div className="roles-table-cell roles-table-cell--created-at">
{formatTimestamp(role.createdAt)}
</div>
</div>
);
return (
<div className="roles-listing-table">
<div className="roles-table-scroll-container">
<div className="roles-table-inner">
<div className="roles-table-header">
<div className="roles-table-header-cell roles-table-header-cell--name">
Name
</div>
<div className="roles-table-header-cell roles-table-header-cell--description">
Description
</div>
<div className="roles-table-header-cell roles-table-header-cell--updated-at">
Updated At
</div>
<div className="roles-table-header-cell roles-table-header-cell--created-at">
Created At
</div>
</div>
{paginatedItems.map((item) =>
item.type === 'section' ? (
<h3 key={`section-${item.label}`} className="roles-table-section-header">
{item.label}
{item.count !== undefined && (
<span className="roles-table-section-header__count">{item.count}</span>
)}
</h3>
) : (
renderRow(item.role)
),
)}
</div>
</div>
<Pagination
current={currentPage}
pageSize={PAGE_SIZE}
total={totalRoleCount}
showTotal={showPaginationItem}
showSizeChanger={false}
hideOnSinglePage
onChange={(page): void => setCurrentPage(page)}
className="roles-table-pagination"
/>
</div>
);
}
export default RolesListingTable;

View File

@@ -1,238 +0,0 @@
.roles-settings {
.roles-settings-header {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
padding: 16px;
.roles-settings-header-title {
color: var(--text-base-white);
font-family: Inter;
font-style: normal;
font-size: 18px;
font-weight: 500;
line-height: 28px;
letter-spacing: -0.09px;
}
.roles-settings-header-description {
color: var(--foreground);
font-family: Inter;
font-style: normal;
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
}
.roles-settings-content {
padding: 0 16px;
}
// todo: https://github.com/SigNoz/components/issues/116
.roles-search-wrapper {
input {
width: 100%;
background: var(--l3-background);
border: 1px solid var(--l3-border);
border-radius: 2px;
padding: 6px 6px 6px 8px;
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
color: var(--l1-foreground);
outline: none;
height: 32px;
&::placeholder {
color: var(--l3-foreground);
}
&:focus {
border-color: var(--input);
}
}
}
}
.roles-description-tooltip {
max-height: none;
overflow-y: visible;
}
.roles-listing-table {
margin-top: 12px;
border-radius: 4px;
overflow: hidden;
}
.roles-table-scroll-container {
overflow-x: auto;
}
.roles-table-inner {
min-width: 850px;
}
.roles-table-header {
display: flex;
align-items: center;
padding: 8px 16px;
gap: 24px;
}
.roles-table-header-cell {
font-family: Inter;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.44px;
color: var(--foreground);
line-height: 16px;
&--name {
flex: 0 0 180px;
}
&--description {
flex: 1;
min-width: 0;
}
&--created-at {
flex: 0 0 180px;
text-align: right;
}
&--updated-at {
flex: 0 0 180px;
text-align: right;
}
}
.roles-table-section-header {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 16px;
background: rgba(171, 189, 255, 0.04);
border-top: 1px solid var(--secondary);
border-bottom: 1px solid var(--secondary);
font-family: Inter;
font-size: 12px;
font-weight: 400;
color: var(--foreground);
line-height: 16px;
margin: 0;
&__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 18px;
padding: 0 6px;
border-radius: 9px;
background: var(--l3-background);
font-size: 12px;
font-weight: 500;
color: var(--foreground);
line-height: 1;
}
}
.roles-table-row {
display: flex;
align-items: center;
padding: 8px 16px;
background: rgba(171, 189, 255, 0.02);
border-bottom: 1px solid var(--secondary);
gap: 24px;
}
.roles-table-cell {
font-family: Inter;
font-size: 14px;
font-weight: 400;
color: var(--foreground);
line-height: 20px;
&--name {
flex: 0 0 180px;
font-weight: 500;
}
&--description {
flex: 1;
min-width: 0;
overflow: hidden;
}
&--created-at {
flex: 0 0 180px;
text-align: right;
white-space: nowrap;
}
&--updated-at {
flex: 0 0 180px;
text-align: right;
white-space: nowrap;
}
}
.roles-table-empty {
padding: 24px 16px;
text-align: center;
color: var(--foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
}
.roles-table-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 8px 16px;
.ant-pagination-total-text {
margin-right: auto;
.numbers {
font-family: Inter;
font-size: 12px;
color: var(--foreground);
}
.total {
font-family: Inter;
font-size: 12px;
color: var(--foreground);
opacity: 0.5;
}
}
}
.lightMode {
.roles-settings {
.roles-settings-header {
.roles-settings-header-title {
color: var(--text-base-black);
}
}
}
.roles-table-section-header {
background: rgba(0, 0, 0, 0.03);
}
.roles-table-row {
background: rgba(0, 0, 0, 0.01);
}
}

View File

@@ -1,34 +0,0 @@
import { useState } from 'react';
import { Input } from '@signozhq/input';
import RolesListingTable from './RolesComponents/RolesListingTable';
import './RolesSettings.styles.scss';
function RolesSettings(): JSX.Element {
const [searchQuery, setSearchQuery] = useState('');
return (
<div className="roles-settings" data-testid="roles-settings">
<div className="roles-settings-header">
<h3 className="roles-settings-header-title">Roles</h3>
<p className="roles-settings-header-description">
Create and manage custom roles for your team.
</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>
<RolesListingTable searchQuery={searchQuery} />
</div>
</div>
);
}
export default RolesSettings;

View File

@@ -1,232 +0,0 @@
import {
allRoles,
listRolesSuccessResponse,
} from 'mocks-server/__mockdata__/roles';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, userEvent } from 'tests/test-utils';
import RolesSettings from '../RolesSettings';
const rolesApiURL = 'http://localhost/api/v1/roles';
describe('RolesSettings', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('renders the header and search input', () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(screen.getByText('Roles')).toBeInTheDocument();
expect(
screen.getByText('Create and manage custom roles for your team.'),
).toBeInTheDocument();
expect(
screen.getByPlaceholderText('Search for roles...'),
).toBeInTheDocument();
});
it('displays roles grouped by managed and custom sections', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(await screen.findByText('signoz-admin')).toBeInTheDocument();
// Section headers
expect(screen.getByText('Managed roles')).toBeInTheDocument();
expect(screen.getByText('Custom roles')).toBeInTheDocument();
// Managed roles
expect(screen.getByText('signoz-admin')).toBeInTheDocument();
expect(screen.getByText('signoz-editor')).toBeInTheDocument();
expect(screen.getByText('signoz-viewer')).toBeInTheDocument();
// Custom roles
expect(screen.getByText('billing-manager')).toBeInTheDocument();
expect(screen.getByText('dashboard-creator')).toBeInTheDocument();
// Custom roles count badge
expect(screen.getByText('2')).toBeInTheDocument();
// Column headers
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
expect(screen.getByText('Updated At')).toBeInTheDocument();
expect(screen.getByText('Created At')).toBeInTheDocument();
});
it('filters roles by search query on name', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(await screen.findByText('signoz-admin')).toBeInTheDocument();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.type(searchInput, 'billing');
expect(await screen.findByText('billing-manager')).toBeInTheDocument();
expect(screen.queryByText('signoz-admin')).not.toBeInTheDocument();
expect(screen.queryByText('signoz-editor')).not.toBeInTheDocument();
expect(screen.queryByText('dashboard-creator')).not.toBeInTheDocument();
});
it('filters roles by search query on description', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(await screen.findByText('signoz-admin')).toBeInTheDocument();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.type(searchInput, 'read-only');
expect(await screen.findByText('signoz-viewer')).toBeInTheDocument();
expect(screen.queryByText('signoz-admin')).not.toBeInTheDocument();
expect(screen.queryByText('billing-manager')).not.toBeInTheDocument();
});
it('shows empty state when search matches nothing', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(await screen.findByText('signoz-admin')).toBeInTheDocument();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const searchInput = screen.getByPlaceholderText('Search for roles...');
await user.type(searchInput, 'nonexistentrole');
expect(
await screen.findByText('No roles match your search.'),
).toBeInTheDocument();
});
it('shows loading skeleton while fetching', () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.delay(200), ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('shows error state when API fails', async () => {
const errorMessage = 'Failed to fetch roles';
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(
ctx.status(500),
ctx.json({
error: {
code: 'INTERNAL_ERROR',
message: errorMessage,
url: '',
errors: [],
},
}),
),
),
);
render(<RolesSettings />);
expect(await screen.findByText(errorMessage)).toBeInTheDocument();
});
it('shows empty state when API returns no roles', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: [] })),
),
);
render(<RolesSettings />);
expect(await screen.findByText('No roles found.')).toBeInTheDocument();
});
it('renders descriptions for all roles', async () => {
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
);
render(<RolesSettings />);
expect(await screen.findByText('signoz-admin')).toBeInTheDocument();
for (const role of allRoles) {
if (role.description) {
expect(screen.getByText(role.description)).toBeInTheDocument();
}
}
});
it('handles invalid dates gracefully by showing fallback', async () => {
const invalidRole = {
id: 'edge-0009',
createdAt: ('invalid-date' as unknown) as Date,
updatedAt: ('not-a-date' as unknown) as Date,
name: 'invalid-date-role',
description: 'Tests date parsing fallback.',
type: 'custom',
orgId: 'org-001',
};
server.use(
rest.get(rolesApiURL, (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: [invalidRole],
}),
),
),
);
render(<RolesSettings />);
expect(await screen.findByText('invalid-date-role')).toBeInTheDocument();
// Verify the "—" (em-dash) fallback is shown for both cells
const dashFallback = screen.getAllByText('—');
// In renderRow: name, description, updatedAt, createdAt.
// Total dashes expected: 2 (for both dates)
expect(dashFallback.length).toBeGreaterThanOrEqual(2);
});
});

View File

@@ -1 +0,0 @@
export { default } from './RolesSettings';

View File

@@ -28,7 +28,6 @@ import {
ScrollText,
Search,
Settings,
Shield,
Slack,
Unplug,
User,
@@ -313,13 +312,6 @@ export const settingsMenuItems: SidebarItem[] = [
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',

View File

@@ -159,7 +159,6 @@ export const routesToSkip = [
ROUTES.ERROR_DETAIL,
ROUTES.LOGS_PIPELINES,
ROUTES.BILLING,
ROUTES.ROLES_SETTINGS,
ROUTES.SUPPORT,
ROUTES.WORKSPACE_LOCKED,
ROUTES.WORKSPACE_SUSPENDED,

View File

@@ -1,8 +1,31 @@
import { HistogramTooltipProps } from '../types';
import { useMemo } from 'react';
import { HistogramTooltipProps, TooltipContentItem } from '../types';
import Tooltip from './Tooltip';
import { buildTooltipContent } from './utils';
export default function HistogramTooltip(
props: HistogramTooltipProps,
): JSX.Element {
return <Tooltip {...props} showTooltipHeader={false} />;
const content = useMemo(
(): TooltipContentItem[] =>
buildTooltipContent({
data: props.uPlotInstance.data,
series: props.uPlotInstance.series,
dataIndexes: props.dataIndexes,
activeSeriesIndex: props.seriesIndex,
uPlotInstance: props.uPlotInstance,
yAxisUnit: props.yAxisUnit ?? '',
decimalPrecision: props.decimalPrecision,
}),
[
props.uPlotInstance,
props.seriesIndex,
props.dataIndexes,
props.yAxisUnit,
props.decimalPrecision,
],
);
return <Tooltip {...props} content={content} showTooltipHeader={false} />;
}

View File

@@ -1,8 +1,31 @@
import { TimeSeriesTooltipProps } from '../types';
import { useMemo } from 'react';
import { TimeSeriesTooltipProps, TooltipContentItem } from '../types';
import Tooltip from './Tooltip';
import { buildTooltipContent } from './utils';
export default function TimeSeriesTooltip(
props: TimeSeriesTooltipProps,
): JSX.Element {
return <Tooltip {...props} />;
const content = useMemo(
(): TooltipContentItem[] =>
buildTooltipContent({
data: props.uPlotInstance.data,
series: props.uPlotInstance.series,
dataIndexes: props.dataIndexes,
activeSeriesIndex: props.seriesIndex,
uPlotInstance: props.uPlotInstance,
yAxisUnit: props.yAxisUnit ?? '',
decimalPrecision: props.decimalPrecision,
}),
[
props.uPlotInstance,
props.seriesIndex,
props.dataIndexes,
props.yAxisUnit,
props.decimalPrecision,
],
);
return <Tooltip {...props} content={content} />;
}

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import cx from 'classnames';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
@@ -11,6 +11,7 @@ import './Tooltip.styles.scss';
const TOOLTIP_LIST_MAX_HEIGHT = 330;
const TOOLTIP_ITEM_HEIGHT = 38;
const TOOLTIP_LIST_PADDING = 10;
export default function Tooltip({
uPlotInstance,
@@ -19,7 +20,7 @@ export default function Tooltip({
showTooltipHeader = true,
}: TooltipProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [listHeight, setListHeight] = useState(0);
const tooltipContent = content ?? [];
const headerTitle = useMemo(() => {
@@ -41,34 +42,45 @@ export default function Tooltip({
showTooltipHeader,
]);
const virtuosoHeight = useMemo(() => {
return listHeight > 0
? Math.min(listHeight + TOOLTIP_LIST_PADDING, TOOLTIP_LIST_MAX_HEIGHT)
: Math.min(
tooltipContent.length * TOOLTIP_ITEM_HEIGHT,
TOOLTIP_LIST_MAX_HEIGHT,
);
}, [listHeight, tooltipContent.length]);
return (
<div
className={cx(
'uplot-tooltip-container',
isDarkMode ? 'darkMode' : 'lightMode',
)}
data-testid="uplot-tooltip-container"
>
{showTooltipHeader && (
<div className="uplot-tooltip-header">
<div className="uplot-tooltip-header" data-testid="uplot-tooltip-header">
<span>{headerTitle}</span>
</div>
)}
<div
style={{
height: Math.min(
tooltipContent.length * TOOLTIP_ITEM_HEIGHT,
TOOLTIP_LIST_MAX_HEIGHT,
),
minHeight: 0,
maxHeight: TOOLTIP_LIST_MAX_HEIGHT,
}}
data-testid="uplot-tooltip-list"
>
{tooltipContent.length > 0 ? (
<Virtuoso
className="uplot-tooltip-list"
data={tooltipContent}
defaultItemHeight={TOOLTIP_ITEM_HEIGHT}
style={{
height: virtuosoHeight,
width: '100%',
}}
totalListHeightChanged={setListHeight}
itemContent={(_, item): JSX.Element => (
<div className="uplot-tooltip-item">
<div className="uplot-tooltip-item" data-testid="uplot-tooltip-item">
<div
className="uplot-tooltip-item-marker"
style={{ borderColor: item.color }}

View File

@@ -0,0 +1,218 @@
import React from 'react';
import { VirtuosoMockContext } from 'react-virtuoso';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { render, RenderResult, screen } from 'tests/test-utils';
import uPlot from 'uplot';
import { TooltipContentItem } from '../../types';
import Tooltip from '../Tooltip';
type MockVirtuosoProps = {
data: TooltipContentItem[];
itemContent: (index: number, item: TooltipContentItem) => React.ReactNode;
className?: string;
style?: React.CSSProperties;
totalListHeightChanged?: (height: number) => void;
};
let mockTotalListHeight = 200;
jest.mock('react-virtuoso', () => {
const actual = jest.requireActual('react-virtuoso');
return {
...actual,
Virtuoso: ({
data,
itemContent,
className,
style,
totalListHeightChanged,
}: MockVirtuosoProps): JSX.Element => {
if (totalListHeightChanged) {
// Simulate Virtuoso reporting total list height
totalListHeightChanged(mockTotalListHeight);
}
return (
<div className={className} style={style}>
{data.map((item, index) => (
<div key={item.label ?? index.toString()}>{itemContent(index, item)}</div>
))}
</div>
);
},
};
});
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: jest.fn(),
}));
const mockUseIsDarkMode = useIsDarkMode as jest.MockedFunction<
typeof useIsDarkMode
>;
type TooltipTestProps = React.ComponentProps<typeof Tooltip>;
function createTooltipContent(
overrides: Partial<TooltipContentItem> = {},
): TooltipContentItem {
return {
label: 'Series A',
value: 10,
tooltipValue: '10',
color: '#ff0000',
isActive: true,
...overrides,
};
}
function createUPlotInstance(cursorIdx: number | null): uPlot {
return ({
data: [[1], []],
cursor: { idx: cursorIdx },
// The rest of the uPlot fields are not used by Tooltip
} as unknown) as uPlot;
}
function renderTooltip(props: Partial<TooltipTestProps> = {}): RenderResult {
const defaultProps: TooltipTestProps = {
uPlotInstance: createUPlotInstance(null),
timezone: 'UTC',
content: [],
showTooltipHeader: true,
// TooltipRenderArgs (not used directly in component but required by type)
dataIndexes: [],
seriesIndex: null,
isPinned: false,
dismiss: jest.fn(),
viaSync: false,
} as TooltipTestProps;
return render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 38 }}>
<Tooltip {...defaultProps} {...props} />
</VirtuosoMockContext.Provider>,
);
}
describe('Tooltip', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseIsDarkMode.mockReturnValue(false);
mockTotalListHeight = 200;
});
it('renders header title when showTooltipHeader is true and cursor index is present', () => {
const uPlotInstance = createUPlotInstance(0);
renderTooltip({ uPlotInstance });
const expectedTitle = dayjs(1 * 1000)
.tz('UTC')
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
expect(screen.getByText(expectedTitle)).toBeInTheDocument();
});
it('does not render header when showTooltipHeader is false', () => {
const uPlotInstance = createUPlotInstance(0);
renderTooltip({ uPlotInstance, showTooltipHeader: false });
const unexpectedTitle = dayjs(1 * 1000)
.tz('UTC')
.format(DATE_TIME_FORMATS.MONTH_DATETIME_SECONDS);
expect(screen.queryByText(unexpectedTitle)).not.toBeInTheDocument();
});
it('renders lightMode class when dark mode is disabled', () => {
const uPlotInstance = createUPlotInstance(null);
mockUseIsDarkMode.mockReturnValue(false);
renderTooltip({ uPlotInstance });
const container = document.querySelector(
'.uplot-tooltip-container',
) as HTMLElement;
expect(container).toHaveClass('lightMode');
expect(container).not.toHaveClass('darkMode');
});
it('renders darkMode class when dark mode is enabled', () => {
const uPlotInstance = createUPlotInstance(null);
mockUseIsDarkMode.mockReturnValue(true);
renderTooltip({ uPlotInstance });
const container = document.querySelector(
'.uplot-tooltip-container',
) as HTMLElement;
expect(container).toHaveClass('darkMode');
expect(container).not.toHaveClass('lightMode');
});
it('renders tooltip items when content is provided', () => {
const uPlotInstance = createUPlotInstance(null);
const content = [createTooltipContent()];
renderTooltip({ uPlotInstance, content });
const list = document.querySelector(
'.uplot-tooltip-list',
) as HTMLElement | null;
expect(list).not.toBeNull();
const marker = document.querySelector(
'.uplot-tooltip-item-marker',
) as HTMLElement;
const itemContent = document.querySelector(
'.uplot-tooltip-item-content',
) as HTMLElement;
expect(marker).toHaveStyle({ borderColor: '#ff0000' });
expect(itemContent).toHaveStyle({ color: '#ff0000', fontWeight: '700' });
expect(itemContent).toHaveTextContent('Series A: 10');
});
it('does not render tooltip list when content is empty', () => {
const uPlotInstance = createUPlotInstance(null);
renderTooltip({ uPlotInstance, content: [] });
const list = document.querySelector(
'.uplot-tooltip-list',
) as HTMLElement | null;
expect(list).toBeNull();
});
it('sets tooltip list height based on content length, height returned by Virtuoso', () => {
const uPlotInstance = createUPlotInstance(null);
const content = [createTooltipContent(), createTooltipContent()];
renderTooltip({ uPlotInstance, content });
const list = document.querySelector('.uplot-tooltip-list') as HTMLElement;
expect(list).toHaveStyle({ height: '210px' });
});
it('sets tooltip list height based on content length when Virtuoso reports 0 height', () => {
const uPlotInstance = createUPlotInstance(null);
const content = [createTooltipContent(), createTooltipContent()];
mockTotalListHeight = 0;
renderTooltip({ uPlotInstance, content });
const list = document.querySelector('.uplot-tooltip-list') as HTMLElement;
// Falls back to content length: 2 items * 38px = 76px
expect(list).toHaveStyle({ height: '76px' });
});
});

View File

@@ -0,0 +1,277 @@
import { PrecisionOption } from 'components/Graph/types';
import { getToolTipValue } from 'components/Graph/yAxisConfig';
import uPlot, { AlignedData, Series } from 'uplot';
import { TooltipContentItem } from '../../types';
import {
buildTooltipContent,
FALLBACK_SERIES_COLOR,
getTooltipBaseValue,
resolveSeriesColor,
} from '../utils';
jest.mock('components/Graph/yAxisConfig', () => ({
getToolTipValue: jest.fn(),
}));
const mockGetToolTipValue = getToolTipValue as jest.MockedFunction<
typeof getToolTipValue
>;
function createUPlotInstance(): uPlot {
return ({
data: [],
cursor: { idx: 0 },
} as unknown) as uPlot;
}
describe('Tooltip utils', () => {
describe('resolveSeriesColor', () => {
it('returns string stroke when provided', () => {
const u = createUPlotInstance();
const stroke: Series.Stroke = '#ff0000';
const color = resolveSeriesColor(stroke, u, 1);
expect(color).toBe('#ff0000');
});
it('returns result of stroke function when provided', () => {
const u = createUPlotInstance();
const strokeFn: Series.Stroke = (uInstance, seriesIdx): string =>
`color-${seriesIdx}-${uInstance.cursor.idx}`;
const color = resolveSeriesColor(strokeFn, u, 2);
expect(color).toBe('color-2-0');
});
it('returns fallback color when stroke is not provided', () => {
const u = createUPlotInstance();
const color = resolveSeriesColor(undefined, u, 1);
expect(color).toBe(FALLBACK_SERIES_COLOR);
});
});
describe('getTooltipBaseValue', () => {
it('returns value from aligned data for non-stacked charts', () => {
const data: AlignedData = [
[0, 1],
[10, 20],
];
const result = getTooltipBaseValue({
data,
index: 1,
dataIndex: 1,
isStackedBarChart: false,
});
expect(result).toBe(20);
});
it('returns null when value is missing', () => {
const data: AlignedData = [
[0, 1],
[10, null],
];
const result = getTooltipBaseValue({
data,
index: 1,
dataIndex: 1,
});
expect(result).toBeNull();
});
it('subtracts next visible stacked value for stacked bar charts', () => {
// data[1] and data[2] contain stacked values at dataIndex 1
const data: AlignedData = [
[0, 1],
[30, 60], // series 1 stacked
[10, 20], // series 2 stacked
];
const series: Series[] = [
{ label: 'x', show: true } as Series,
{ label: 'A', show: true } as Series,
{ label: 'B', show: true } as Series,
];
const result = getTooltipBaseValue({
data,
index: 1,
dataIndex: 1,
isStackedBarChart: true,
series,
});
// 60 (stacked at series 1) - 20 (next visible stacked) = 40
expect(result).toBe(40);
});
it('skips hidden series when computing base value for stacked charts', () => {
const data: AlignedData = [
[0, 1],
[30, 60], // series 1 stacked
[10, 20], // series 2 stacked but hidden
[5, 10], // series 3 stacked and visible
];
const series: Series[] = [
{ label: 'x', show: true } as Series,
{ label: 'A', show: true } as Series,
{ label: 'B', show: false } as Series,
{ label: 'C', show: true } as Series,
];
const result = getTooltipBaseValue({
data,
index: 1,
dataIndex: 1,
isStackedBarChart: true,
series,
});
// 60 (stacked at series 1) - 10 (next *visible* stacked, series 3) = 50
expect(result).toBe(50);
});
it('does not subtract when there is no next visible series', () => {
const data: AlignedData = [
[0, 1],
[10, 20], // series 1
[5, (null as unknown) as number], // series 2 missing
];
const series: Series[] = [
{ label: 'x', show: true } as Series,
{ label: 'A', show: true } as Series,
{ label: 'B', show: false } as Series,
];
const result = getTooltipBaseValue({
data,
index: 1,
dataIndex: 1,
isStackedBarChart: true,
series,
});
expect(result).toBe(20);
});
});
describe('buildTooltipContent', () => {
const yAxisUnit = 'ms';
const decimalPrecision: PrecisionOption = 2;
beforeEach(() => {
mockGetToolTipValue.mockReset();
mockGetToolTipValue.mockImplementation(
(value: string | number): string => `formatted-${value}`,
);
});
function createSeriesConfig(): Series[] {
return [
{ label: 'x', show: true } as Series,
{ label: 'A', show: true, stroke: '#ff0000' } as Series,
{
label: 'B',
show: true,
stroke: (_u: uPlot, idx: number): string => `color-${idx}`,
} as Series,
{ label: 'C', show: false, stroke: '#00ff00' } as Series,
];
}
it('builds tooltip content with active series first', () => {
const data: AlignedData = [[0], [10], [20], [30]];
const series = createSeriesConfig();
const dataIndexes = [null, 0, 0, 0];
const u = createUPlotInstance();
const result = buildTooltipContent({
data,
series,
dataIndexes,
activeSeriesIndex: 2,
uPlotInstance: u,
yAxisUnit,
decimalPrecision,
});
expect(result).toHaveLength(2);
// Active (series index 2) should come first
expect(result[0]).toMatchObject<Partial<TooltipContentItem>>({
label: 'B',
value: 20,
tooltipValue: 'formatted-20',
color: 'color-2',
isActive: true,
});
expect(result[1]).toMatchObject<Partial<TooltipContentItem>>({
label: 'A',
value: 10,
tooltipValue: 'formatted-10',
color: '#ff0000',
isActive: false,
});
});
it('skips series with null data index or non-finite values', () => {
const data: AlignedData = [[0], [42], [Infinity]];
const series: Series[] = [
{ label: 'x', show: true } as Series,
{ label: 'A', show: true, stroke: '#ff0000' } as Series,
{ label: 'B', show: true, stroke: '#00ff00' } as Series,
];
const dataIndexes = [null, 0, null];
const u = createUPlotInstance();
const result = buildTooltipContent({
data,
series,
dataIndexes,
activeSeriesIndex: 1,
uPlotInstance: u,
yAxisUnit,
decimalPrecision,
});
// Only the finite, non-null value from series A should be included
expect(result).toHaveLength(1);
expect(result[0].label).toBe('A');
});
it('uses stacked base values when building content for stacked bar charts', () => {
const data: AlignedData = [[0], [60], [30]];
const series: Series[] = [
{ label: 'x', show: true } as Series,
{ label: 'A', show: true, stroke: '#ff0000' } as Series,
{ label: 'B', show: true, stroke: '#00ff00' } as Series,
];
const dataIndexes = [null, 0, 0];
const u = createUPlotInstance();
const result = buildTooltipContent({
data,
series,
dataIndexes,
activeSeriesIndex: 1,
uPlotInstance: u,
yAxisUnit,
decimalPrecision,
isStackedBarChart: true,
});
// baseValue for series 1 at index 0 should be 60 - 30 (next visible) = 30
expect(result[0].value).toBe(30);
expect(result[1].value).toBe(30);
});
});
});

View File

@@ -4,7 +4,7 @@ import uPlot, { AlignedData, Series } from 'uplot';
import { TooltipContentItem } from '../types';
const FALLBACK_SERIES_COLOR = '#000000';
export const FALLBACK_SERIES_COLOR = '#000000';
export function resolveSeriesColor(
stroke: Series.Stroke | undefined,

View File

@@ -1,64 +0,0 @@
import { RoletypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
const orgId = '019ba2bb-2fa1-7b24-8159-cfca08617ef9';
export const managedRoles: RoletypesRoleDTO[] = [
{
id: '019c24aa-2248-756f-9833-984f1ab63819',
createdAt: new Date('2026-02-03T18:00:55.624356Z'),
updatedAt: new Date('2026-02-03T18:00:55.624356Z'),
name: 'signoz-admin',
description:
'Role assigned to users who have full administrative access to SigNoz resources.',
type: 'managed',
orgId,
},
{
id: '019c24aa-2248-757c-9faf-7b1e899751e0',
createdAt: new Date('2026-02-03T18:00:55.624359Z'),
updatedAt: new Date('2026-02-03T18:00:55.624359Z'),
name: 'signoz-editor',
description:
'Role assigned to users who can create, edit, and manage SigNoz resources but do not have full administrative privileges.',
type: 'managed',
orgId,
},
{
id: '019c24aa-2248-7585-a129-4188b3473c27',
createdAt: new Date('2026-02-03T18:00:55.624362Z'),
updatedAt: new Date('2026-02-03T18:00:55.624362Z'),
name: 'signoz-viewer',
description:
'Role assigned to users who have read-only access to SigNoz resources.',
type: 'managed',
orgId,
},
];
export const customRoles: RoletypesRoleDTO[] = [
{
id: '019c24aa-3333-0001-aaaa-111111111111',
createdAt: new Date('2026-02-10T10:30:00.000Z'),
updatedAt: new Date('2026-02-12T14:20:00.000Z'),
name: 'billing-manager',
description: 'Custom role for managing billing and invoices.',
type: 'custom',
orgId,
},
{
id: '019c24aa-3333-0002-bbbb-222222222222',
createdAt: new Date('2026-02-11T09:00:00.000Z'),
updatedAt: new Date('2026-02-13T11:45:00.000Z'),
name: 'dashboard-creator',
description: 'Custom role allowing users to create and manage dashboards.',
type: 'custom',
orgId,
},
];
export const allRoles: RoletypesRoleDTO[] = [...managedRoles, ...customRoles];
export const listRolesSuccessResponse = {
status: 'success',
data: allRoles,
};

View File

@@ -77,7 +77,6 @@ function SettingsPage(): JSX.Element {
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.CUSTOM_DOMAIN_SETTINGS ||
item.key === ROUTES.API_KEYS ||
@@ -108,7 +107,6 @@ function SettingsPage(): JSX.Element {
...item,
isEnabled:
item.key === ROUTES.BILLING ||
item.key === ROUTES.ROLES_SETTINGS ||
item.key === ROUTES.INTEGRATIONS ||
item.key === ROUTES.API_KEYS ||
item.key === ROUTES.ORG_SETTINGS ||
@@ -136,9 +134,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,
}));

View File

@@ -12,7 +12,6 @@ import IngestionSettings from 'container/IngestionSettings/IngestionSettings';
import MultiIngestionSettings from 'container/IngestionSettings/MultiIngestionSettings';
import MySettings from 'container/MySettings';
import OrganizationSettings from 'container/OrganizationSettings';
import RolesSettings from 'container/RolesSettings';
import { TFunction } from 'i18next';
import {
Backpack,
@@ -25,7 +24,6 @@ import {
KeySquare,
Pencil,
Plus,
Shield,
User,
} from 'lucide-react';
import ChannelsEdit from 'pages/ChannelsEdit';
@@ -150,19 +148,6 @@ export const billingSettings = (t: TFunction): RouteTabProps['routes'] => [
},
];
export const rolesSettings = (t: TFunction): RouteTabProps['routes'] => [
{
Component: RolesSettings,
name: (
<div className="periscope-tab">
<Shield size={16} /> {t('routes:roles').toString()}
</div>
),
route: ROUTES.ROLES_SETTINGS,
key: ROUTES.ROLES_SETTINGS,
},
];
export const keyboardShortcuts = (t: TFunction): RouteTabProps['routes'] => [
{
Component: Shortcuts,

View File

@@ -15,7 +15,6 @@ import {
multiIngestionSettings,
mySettings,
organizationSettings,
rolesSettings,
} from './config';
export const getRoutes = (
@@ -67,10 +66,6 @@ export const getRoutes = (
settings.push(...customDomainSettings(t), ...billingSettings(t));
}
if (isAdmin) {
settings.push(...rolesSettings(t));
}
settings.push(
...mySettings(t),
...createAlertChannels(t),

View File

@@ -1,8 +1,3 @@
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp } from 'types/api';
import APIError from 'types/api/error';
/**
* Extracts HTTP status code from various error types
* @param error - The error object (could be APIError, AxiosError, or other error types)
@@ -33,25 +28,3 @@ export const isRetryableError = (error: any): boolean => {
// If no status code is available, default to retryable
return !statusCode || statusCode >= 500;
};
export function toAPIError(
error: unknown,
defaultMessage = 'An unexpected error occurred.',
): APIError {
try {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
} catch (apiError) {
if (apiError instanceof APIError) {
return apiError;
}
}
return new APIError({
httpStatusCode: 500,
error: {
code: 'UNKNOWN_ERROR',
message: defaultMessage,
url: '',
errors: [],
},
});
}

View File

@@ -97,7 +97,6 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
GET_STARTED_AZURE_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'],
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
ROLES_SETTINGS: ['ADMIN'],
BILLING: ['ADMIN'],
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER'],
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],