Compare commits

...

5 Commits

Author SHA1 Message Date
Vinícius Lourenço
789fb391a9 chore(sidenav): add todo to remove the menu from sidebar 2026-06-10 16:48:32 -03:00
Vinícius Lourenço
127b30df27 chore(codeowners): move channels to pulse frontend 2026-06-10 16:44:01 -03:00
Vinícius Lourenço
f8563ac73d test(private): add test to cover redirects of channels 2026-06-10 16:37:01 -03:00
Vinícius Lourenço
7d3576cd1b refactor(channels): move to be under alerts 2026-06-10 16:36:44 -03:00
Vinícius Lourenço
26e0f3d5b6 refactor(sidenav): add support for routes with ?key=value 2026-06-10 16:36:16 -03:00
19 changed files with 227 additions and 116 deletions

7
.github/CODEOWNERS vendored
View File

@@ -189,6 +189,13 @@ go.mod @therealpandey
/frontend/src/container/TriggeredAlerts/ @SigNoz/pulse-frontend
/frontend/src/container/AnomalyAlertEvaluationView/ @SigNoz/pulse-frontend
## Notification Channels
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
/frontend/src/pages/ChannelsNew/ @SigNoz/pulse-frontend
/frontend/src/container/AllAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/CreateAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/container/EditAlertChannels/ @SigNoz/pulse-frontend
## OpenAPI Schema - Generated
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv

View File

@@ -55,7 +55,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
),
[pathname],
);
const isOldRoute = oldRoutes.indexOf(pathname) > -1;
const currentRoute = mapRoutes.get('current');
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
@@ -83,12 +82,36 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
}, [usersData?.data]);
// Handle old routes - redirect to new routes
const isOldRoute = oldRoutes.indexOf(pathname) > -1;
if (isOldRoute) {
const redirectUrl = oldNewRoutesMapping[pathname];
// TODO(H4ad): Remove this after https://github.com/SigNoz/engineering-pod/issues/5322
// A mapped target may itself carry a query string (e.g. `/alerts?tab=Channels`).
// react-router does not re-parse a `?` embedded in the `pathname` field, so split
// it out and merge with the incoming search params.
const [redirectPath, redirectSearch = ''] = redirectUrl.split('?');
const mergedParams = new URLSearchParams(location.search);
new URLSearchParams(redirectSearch).forEach((value, name) => {
mergedParams.set(name, value);
});
const search = mergedParams.toString();
return (
<Redirect
to={{
pathname: redirectUrl,
pathname: redirectPath,
search: search ? `?${search}` : '',
hash: location.hash,
}}
/>
);
}
if (pathname.startsWith('/settings/channels/edit/')) {
const channelId = pathname.replace('/settings/channels/edit/', '');
return (
<Redirect
to={{
pathname: `/alerts/channels/edit/${channelId}`,
search: location.search,
hash: location.hash,
}}

View File

@@ -73,7 +73,13 @@ const queryClient = new QueryClient({
// Component to capture current location for assertions
function LocationDisplay(): ReactElement {
const location = useLocation();
return <div data-testid="location-display">{location.pathname}</div>;
return (
<>
<div data-testid="location-display">{location.pathname}</div>
<div data-testid="location-search">{location.search}</div>
<div data-testid="location-hash">{location.hash}</div>
</>
);
}
// Helper to create mock user
@@ -1475,12 +1481,10 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
});
it('should not redirect VIEWER from /settings/channels/new due to route matching order (ALL_CHANNELS matches last)', () => {
// Note: This tests the ACTUAL behavior of Private.tsx route matching
// CHANNELS_NEW has path '/settings/channels/new' with permission ['ADMIN']
// ALL_CHANNELS has path '/settings/channels' with permission ['ADMIN', 'EDITOR', 'VIEWER']
// Due to non-exact matching and array order, ALL_CHANNELS matches LAST for '/settings/channels/new'
// This is a known limitation - actual permission enforcement happens in the page component
it('should redirect VIEWER from /alerts/channels/new (ADMIN only)', async () => {
// After moving channels under /alerts, CHANNELS_NEW ('/alerts/channels/new')
// is an exact, ADMIN-only route with no overlapping non-exact ALL_CHANNELS
// route to match last, so a VIEWER is now correctly redirected.
renderPrivateRoute({
initialRoute: ROUTES.CHANNELS_NEW,
appContext: {
@@ -1489,8 +1493,7 @@ describe('PrivateRoute', () => {
},
});
assertRendersChildren();
assertStaysOnRoute(ROUTES.CHANNELS_NEW);
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
});
it('should allow EDITOR to access /get-started route', () => {
@@ -1548,4 +1551,60 @@ describe('PrivateRoute', () => {
await assertRedirectsTo(ROUTES.UN_AUTHORIZED);
});
});
describe('Old channel route redirects', () => {
it.each([
['/settings/channels', '/alerts', 'tab=Channels'],
['/settings/channels/new', '/alerts/channels/new', ''],
])(
'should redirect %s to %s',
async (oldRoute, expectedPath, expectedSearch) => {
renderPrivateRoute({
initialRoute: oldRoute,
appContext: { isLoggedIn: true },
});
await waitFor(() => {
expect(screen.getByTestId('location-display')).toHaveTextContent(
expectedPath,
);
});
if (expectedSearch) {
const search = screen.getByTestId('location-search').textContent ?? '';
const params = new URLSearchParams(search);
new URLSearchParams(expectedSearch).forEach((value, name) => {
expect(params.get(name)).toBe(value);
});
} else {
expect(screen.getByTestId('location-search')).toHaveTextContent('');
}
},
);
it('should redirect dynamic channel edit route preserving the channel id', async () => {
renderPrivateRoute({
initialRoute: '/settings/channels/edit/abc123',
appContext: { isLoggedIn: true },
});
await assertRedirectsTo('/alerts/channels/edit/abc123');
});
it('should merge incoming query params with the embedded query of the target', async () => {
renderPrivateRoute({
initialRoute: '/settings/channels?foo=bar',
appContext: { isLoggedIn: true },
});
await waitFor(() => {
expect(screen.getByTestId('location-display')).toHaveTextContent('/alerts');
});
const search = screen.getByTestId('location-search').textContent ?? '';
const params = new URLSearchParams(search);
expect(params.get('tab')).toBe('Channels');
expect(params.get('foo')).toBe('bar');
});
});
});

View File

@@ -135,12 +135,12 @@ export const AlertOverview = Loadable(
() => import(/* webpackChunkName: "Alert Overview" */ 'pages/AlertList'),
);
export const CreateAlertChannelAlerts = Loadable(
() => import(/* webpackChunkName: "Create Channels" */ 'pages/Settings'),
export const ChannelsNew = Loadable(
() => import(/* webpackChunkName: "Create Channels" */ 'pages/AlertList'),
);
export const AllAlertChannels = Loadable(
() => import(/* webpackChunkName: "All Channels" */ 'pages/Settings'),
export const ChannelsEdit = Loadable(
() => import(/* webpackChunkName: "Edit Channels" */ 'pages/AlertList'),
);
export const AllErrors = Loadable(

View File

@@ -5,10 +5,10 @@ import {
AIAssistantPage,
AlertHistory,
AlertOverview,
AllAlertChannels,
AllErrors,
ApiMonitoring,
CreateAlertChannelAlerts,
ChannelsEdit,
ChannelsNew,
CreateNewAlerts,
DashboardPage,
DashboardsListPage,
@@ -271,16 +271,16 @@ const routes: AppRoutes[] = [
{
path: ROUTES.CHANNELS_NEW,
exact: true,
component: CreateAlertChannelAlerts,
component: ChannelsNew,
isPrivate: true,
key: 'CHANNELS_NEW',
},
{
path: ROUTES.ALL_CHANNELS,
path: ROUTES.CHANNELS_EDIT,
exact: true,
component: AllAlertChannels,
component: ChannelsEdit,
isPrivate: true,
key: 'ALL_CHANNELS',
key: 'CHANNELS_EDIT',
},
{
path: ROUTES.ALL_ERROR,
@@ -536,6 +536,9 @@ export const oldNewRoutesMapping: Record<string, string> = {
'/messaging-queues': '/messaging-queues/overview',
'/alerts/edit': '/alerts/overview',
'/alerts/type-selection': '/alerts/new',
// TODO(H4ad): Update this after https://github.com/SigNoz/engineering-pod/issues/5322
'/settings/channels': '/alerts?tab=Channels',
'/settings/channels/new': '/alerts/channels/new',
};
export const oldRoutes = Object.keys(oldNewRoutesMapping);

View File

@@ -29,9 +29,10 @@ const ROUTES = {
ALERTS_NEW: '/alerts/new',
ALERT_HISTORY: '/alerts/history',
ALERT_OVERVIEW: '/alerts/overview',
ALL_CHANNELS: '/settings/channels',
CHANNELS_NEW: '/settings/channels/new',
CHANNELS_EDIT: '/settings/channels/edit/:channelId',
// TODO(H4ad): Add test to forbidden ? in this map after https://github.com/SigNoz/engineering-pod/issues/5322
ALL_CHANNELS: '/alerts?tab=Channels',
CHANNELS_NEW: '/alerts/channels/new',
CHANNELS_EDIT: '/alerts/channels/edit/:channelId',
ALL_ERROR: '/exceptions',
ERROR_DETAIL: '/error-detail',
VERSION: '/status',

View File

@@ -1,4 +1,4 @@
.alert-channels-container {
width: 90%;
margin: 12px auto;
width: 100%;
padding: 0 var(--spacing-8);
}

View File

@@ -1,7 +1,5 @@
.create-alert-channels-container {
width: 90%;
margin: 12px auto;
width: 100%;
border: 1px solid var(--l1-border);
background: var(--l2-background);
border-radius: 3px;

View File

@@ -80,7 +80,7 @@ import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
import { useCmdK } from '../../providers/cmdKProvider';
import { routeConfig } from './config';
import { getQueryString } from './helper';
import { buildNavUrl, getQueryString } from './helper';
import {
defaultMoreMenuItems,
getUserSettingsDropdownMenuItems,
@@ -486,12 +486,13 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
const availableParams = routeConfig[key];
const queryString = getQueryString(availableParams || [], params);
const url = buildNavUrl(key, queryString);
if (pathname !== key) {
if (event && isModifierKeyPressed(event)) {
openInNewTab(`${key}?${queryString.join('&')}`);
openInNewTab(url);
} else {
history.push(`${key}?${queryString.join('&')}`, {
history.push(url, {
from: pathname,
});
}

View File

@@ -8,3 +8,14 @@ export const getQueryString = (
}
return '';
});
/**
* @deprecated This should be removed after https://github.com/SigNoz/engineering-pod/issues/5322 is done
*/
export const buildNavUrl = (key: string, queryString: string[]): string => {
if (key.includes('?')) {
const extra = queryString.filter(Boolean).join('&');
return extra ? `${key}&${extra}` : key;
}
return `${key}?${queryString.join('&')}`;
};

View File

@@ -337,6 +337,7 @@ export const settingsNavSections: SettingsNavSection[] = [
isEnabled: true,
itemKey: 'account',
},
// TODO(@SigNoz/pulse-frontend): https://github.com/SigNoz/engineering-pod/issues/5323
{
key: ROUTES.ALL_CHANNELS,
label: 'Notification Channels',

View File

@@ -4,14 +4,17 @@ import { Tabs, TabsProps } from 'antd';
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
import ROUTES from 'constants/routes';
import AllAlertChannels from 'container/AllAlertChannels';
import AllAlertRules from 'container/ListAlertRules';
import { PlannedDowntime } from 'container/PlannedDowntime/PlannedDowntime';
import RoutingPolicies from 'container/RoutingPolicies';
import TriggeredAlerts from 'container/TriggeredAlerts';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import { Cable, GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import AlertDetails from 'pages/AlertDetails';
import ChannelsEdit from 'pages/ChannelsEdit';
import ChannelsNew from 'pages/ChannelsNew';
import { AlertListSubTabs, AlertListTabs } from './types';
@@ -26,6 +29,9 @@ function AllAlertList(): JSX.Element {
const subTab = urlQuery.get('subTab');
const isAlertHistory = location.pathname === ROUTES.ALERT_HISTORY;
const isAlertOverview = location.pathname === ROUTES.ALERT_OVERVIEW;
const isChannelsNew = location.pathname === ROUTES.CHANNELS_NEW;
const isChannelsEdit = location.pathname.startsWith('/alerts/channels/edit/');
const isChannelDetails = isChannelsNew || isChannelsEdit;
const handleConfigurationTabChange = useCallback(
(subTab: string): void => {
@@ -86,6 +92,22 @@ function AllAlertList(): JSX.Element {
</div>
),
},
{
label: (
<div className="periscope-tab top-level-tab">
<Cable size={14} />
Notification Channels
</div>
),
key: AlertListTabs.CHANNELS,
children: (
<div className="alert-rules-container">
{isChannelsNew && <ChannelsNew />}
{isChannelsEdit && <ChannelsEdit />}
{!isChannelDetails && <AllAlertChannels />}
</div>
),
},
{
label: (
<div className="periscope-tab top-level-tab">
@@ -98,11 +120,21 @@ function AllAlertList(): JSX.Element {
},
];
const getActiveKey = (): string => {
if (isAlertHistory || isAlertOverview) {
return AlertListTabs.ALERT_RULES;
}
if (isChannelDetails) {
return AlertListTabs.CHANNELS;
}
return tab || AlertListTabs.ALERT_RULES;
};
return (
<Tabs
destroyInactiveTabPane
items={items}
activeKey={tab || AlertListTabs.ALERT_RULES}
activeKey={getActiveKey()}
onChange={(tab): void => {
const queryParams = new URLSearchParams();
@@ -120,7 +152,9 @@ function AllAlertList(): JSX.Element {
safeNavigate(`/alerts?${queryParams.toString()}`);
}}
className={`alerts-container ${
isAlertHistory || isAlertOverview ? 'alert-details-tabs' : ''
isAlertHistory || isAlertOverview || isChannelDetails
? 'alert-details-tabs'
: ''
}`}
tabBarExtraContent={
<HeaderRightSection

View File

@@ -7,4 +7,5 @@ export enum AlertListTabs {
TRIGGERED_ALERTS = 'TriggeredAlerts',
ALERT_RULES = 'AlertRules',
CONFIGURATION = 'Configuration',
CHANNELS = 'Channels',
}

View File

@@ -4,7 +4,9 @@ import { useTranslation } from 'react-i18next';
import { useQuery } from 'react-query';
import { Typography } from '@signozhq/ui/typography';
import get from 'api/channels/get';
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import Spinner from 'components/Spinner';
import ROUTES from 'constants/routes';
import {
ChannelType,
MsTeamsChannel,
@@ -22,9 +24,9 @@ import './ChannelsEdit.styles.scss';
function ChannelsEdit(): JSX.Element {
const { t } = useTranslation();
// Extract channelId from URL pathname since useParams doesn't work in nested routing
// Extract channelId from URL pathname
const { pathname } = window.location;
const channelIdMatch = pathname.match(/\/settings\/channels\/edit\/([^/]+)/);
const channelIdMatch = pathname.match(/\/alerts\/channels\/edit\/([^/]+)/);
const channelId = channelIdMatch ? channelIdMatch[1] : undefined;
const { isFetching, isError, data, error } = useQuery<
@@ -135,17 +137,25 @@ function ChannelsEdit(): JSX.Element {
const target = prepChannelConfig();
return (
<div className="edit-alert-channels-container">
<EditAlertChannels
{...{
initialValue: {
...target.channel,
type: target.type,
name: value.name,
},
}}
<>
<AlertBreadcrumb
items={[
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
{ title: value.name || 'Edit Channel', isLast: true },
]}
/>
</div>
<div className="edit-alert-channels-container">
<EditAlertChannels
{...{
initialValue: {
...target.channel,
type: target.type,
name: value.name,
},
}}
/>
</div>
</>
);
}

View File

@@ -0,0 +1,23 @@
import AlertBreadcrumb from 'components/AlertBreadcrumb';
import ROUTES from 'constants/routes';
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import styles from './styles.module.scss';
function ChannelsNew(): JSX.Element {
return (
<>
<AlertBreadcrumb
items={[
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
{ title: 'New Channel', isLast: true },
]}
/>
<div className={styles.content}>
<CreateAlertChannels preType={ChannelType.Slack} />
</div>
</>
);
}
export default ChannelsNew;

View File

@@ -0,0 +1,4 @@
.content {
padding: var(--spacing-8);
padding-top: 0px;
}

View File

@@ -6,7 +6,7 @@ import RouteTab from 'components/RouteTab';
import { FeatureKeys } from 'constants/features';
import ROUTES from 'constants/routes';
import { routeConfig } from 'container/SideNav/config';
import { getQueryString } from 'container/SideNav/helper';
import { buildNavUrl, getQueryString } from 'container/SideNav/helper';
import { settingsNavSections } from 'container/SideNav/menuItems';
import NavItem from 'container/SideNav/NavItem/NavItem';
import { SidebarItem } from 'container/SideNav/sideNav.types';
@@ -240,12 +240,13 @@ function SettingsPage(): JSX.Element {
const availableParams = routeConfig[key];
const queryString = getQueryString(availableParams || [], params);
const url = buildNavUrl(key, queryString);
if (pathname !== key) {
if (event && isModifierKeyPressed(event)) {
openInNewTab(`${key}?${queryString.join('&')}`);
openInNewTab(url);
} else {
history.push(`${key}?${queryString.join('&')}`, {
history.push(url, {
from: pathname,
});
}
@@ -259,17 +260,6 @@ function SettingsPage(): JSX.Element {
};
const isActiveNavItem = (key: string): boolean => {
if (pathname.startsWith(ROUTES.ALL_CHANNELS) && key === ROUTES.ALL_CHANNELS) {
return true;
}
if (
pathname.startsWith(ROUTES.CHANNELS_EDIT) &&
key === ROUTES.ALL_CHANNELS
) {
return true;
}
if (
pathname.startsWith(ROUTES.ROLES_SETTINGS) &&
key === ROUTES.ROLES_SETTINGS

View File

@@ -1,9 +1,6 @@
import { RouteTabProps } from 'components/RouteTab/types';
import ROUTES from 'constants/routes';
import AlertChannels from 'container/AllAlertChannels';
import BillingContainer from 'container/BillingContainer/BillingContainer';
import CreateAlertChannels from 'container/CreateAlertChannels';
import { ChannelType } from 'container/CreateAlertChannels/config';
import GeneralSettings from 'container/GeneralSettings';
import GeneralSettingsCloud from 'container/GeneralSettingsCloud';
import IngestionSettings from 'container/IngestionSettings/IngestionSettings';
@@ -16,20 +13,16 @@ import RoleDetailsPage from 'container/RolesSettings/RoleDetails';
import { TFunction } from 'i18next';
import {
Backpack,
BellDot,
Bot,
Building,
Cpu,
CreditCard,
Keyboard,
Pencil,
Plus,
Shield,
Sparkles,
User,
Users,
} from '@signozhq/icons';
import ChannelsEdit from 'pages/ChannelsEdit';
import MembersSettings from 'pages/MembersSettings';
import ServiceAccountsSettings from 'pages/ServiceAccountsSettings';
import Shortcuts from 'pages/Shortcuts';
@@ -47,19 +40,6 @@ export const organizationSettings = (t: TFunction): RouteTabProps['routes'] => [
},
];
export const alertChannels = (t: TFunction): RouteTabProps['routes'] => [
{
Component: AlertChannels,
name: (
<div className="periscope-tab">
<BellDot size={16} /> {t('routes:alert_channels').toString()}
</div>
),
route: ROUTES.ALL_CHANNELS,
key: ROUTES.ALL_CHANNELS,
},
];
export const ingestionSettings = (t: TFunction): RouteTabProps['routes'] => [
{
Component: IngestionSettings,
@@ -219,31 +199,3 @@ export const mcpServerSettings = (t: TFunction): RouteTabProps['routes'] => [
key: ROUTES.MCP_SERVER,
},
];
export const createAlertChannels = (t: TFunction): RouteTabProps['routes'] => [
{
Component: (): JSX.Element => (
<CreateAlertChannels preType={ChannelType.Slack} />
),
name: (
<div className="periscope-tab">
<Plus size={16} /> {t('routes:create_alert_channels').toString()}
</div>
),
route: ROUTES.CHANNELS_NEW,
key: ROUTES.CHANNELS_NEW,
},
];
export const editAlertChannels = (t: TFunction): RouteTabProps['routes'] => [
{
Component: ChannelsEdit,
name: (
<div className="periscope-tab">
<Pencil size={16} /> {t('routes:edit_alert_channels').toString()}
</div>
),
route: ROUTES.CHANNELS_EDIT,
key: ROUTES.CHANNELS_EDIT,
},
];

View File

@@ -3,10 +3,7 @@ import { TFunction } from 'i18next';
import { ROLES, USER_ROLES } from 'types/roles';
import {
alertChannels,
billingSettings,
createAlertChannels,
editAlertChannels,
generalSettings,
ingestionSettings,
keyboardShortcuts,
@@ -60,8 +57,6 @@ export const getRoutes = (
settings.push(...ingestionSettings(t));
}
settings.push(...alertChannels(t));
// Visible to all authenticated users
settings.push(
...serviceAccountsSettings(t),
@@ -80,8 +75,6 @@ export const getRoutes = (
settings.push(
...mySettings(t),
...createAlertChannels(t),
...editAlertChannels(t),
...keyboardShortcuts(t),
...mcpServerSettings(t),
);