Compare commits

...

6 Commits

26 changed files with 1434 additions and 7 deletions

View File

@@ -51,3 +51,38 @@ export const opsGeniePriorityDefaultValue =
export const pagerDutySeverityTextDefaultValue =
'{{ (index .Alerts 0).Labels.severity }}';
export const notificationChannelsV2 = [
{
id: '3',
name: 'dummy-channel',
displayName: 'Dummy-Channel',
kind: 'slack',
createdAt: '2023-08-09T04:45:19.239344617Z',
updatedAt: '2024-06-27T11:37:14.841184399Z',
},
{
id: '4',
name: 'oncall-pagerduty',
displayName: 'Oncall PagerDuty',
kind: 'pagerduty',
createdAt: '2024-02-03T04:45:19.239344617Z',
updatedAt: '2024-06-28T11:37:14.841184399Z',
},
];
export const notificationChannelV2 = {
id: '3',
name: 'dummy-channel',
displayName: 'Dummy-Channel',
createdAt: '2023-08-09T04:45:19.239344617Z',
updatedAt: '2024-06-27T11:37:14.841184399Z',
config: {
kind: 'slack',
spec: {
apiUrl: 'https://hooks.slack.com/services/dummy',
channel: '#dummy_channel',
sendResolved: true,
},
},
};

View File

@@ -2,7 +2,11 @@ import { rest } from 'msw';
import commonEnTranslation from '../../public/locales/en/common.json';
import enTranslation from '../../public/locales/en/translation.json';
import { allAlertChannels } from './__mockdata__/alerts';
import {
allAlertChannels,
notificationChannelsV2,
notificationChannelV2,
} from './__mockdata__/alerts';
import { alertRulesFixture } from './__mockdata__/alert_rules';
import { triggeredAlertsFixture } from './__mockdata__/triggered_alerts';
import { billingSuccessResponse } from './__mockdata__/billing';
@@ -205,6 +209,32 @@ export const handlers = [
rest.get('http://localhost/api/v1/channels', (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: allAlertChannels, status: 'success' })),
),
rest.get('http://localhost/api/v2/notification_channels', (req, res, ctx) => {
const query = req.url.searchParams.get('query')?.toLowerCase() ?? '';
const kind = req.url.searchParams.get('kind');
const channels = notificationChannelsV2.filter(
(channel) =>
(!query || channel.displayName.toLowerCase().includes(query)) &&
(!kind || channel.kind === kind),
);
return res(
ctx.status(200),
ctx.json({
data: { channels, total: channels.length },
status: 'success',
}),
);
}),
rest.get('http://localhost/api/v2/notification_channels/:id', (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: notificationChannelV2, status: 'success' }),
),
),
rest.delete(
'http://localhost/api/v2/notification_channels/:id',
(_, res, ctx) => res(ctx.status(204)),
),
rest.get('http://localhost/api/v1/alerts', (_, res, ctx) =>
res(
ctx.status(200),

View File

@@ -10,7 +10,7 @@ import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
CHANNEL_MAX,
channelsResponse,
notificationChannelsResponse,
} from '../../stories/__story_mockdata__/alerts';
import { AlertListTabs } from '../../types';
@@ -27,8 +27,8 @@ export const channelsMocks = defineStoryMocks({
},
handlers: (values, response) => [
rest.get(
'http://localhost/api/v1/channels',
response.json(() => channelsResponse(values.channels)),
'http://localhost/api/v2/notification_channels',
response.json(() => notificationChannelsResponse(values.channels)),
),
],
config: () => ({ route: `/alerts?tab=${AlertListTabs.CHANNELS}` }),

View File

@@ -0,0 +1,144 @@
.container {
--control-height: 2rem;
display: flex;
flex-direction: column;
height: calc(100vh - 62px);
min-height: 400px;
--combobox-trigger-height: var(--control-height);
--select-trigger-height: var(--control-height);
--input-height: var(--control-height);
--input-wrapper-height: var(--control-height);
--button-height: var(--control-height);
}
/* Title, count and filters sit on the page surface, with matching space above
and below before the table starts. */
.headerBand {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
flex-shrink: 0;
padding: var(--spacing-6) var(--spacing-8);
}
/* Title and count on the left, the create action at the far end, the way the
dashboards list opens. */
.commandHeader {
display: flex;
align-items: center;
gap: 12px;
}
.headingBlock {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.title {
margin: 0;
color: var(--l1-foreground);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-medium);
line-height: 28px;
letter-spacing: -0.09px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.countPill {
padding: 2px 9px;
border-radius: 999px;
background: var(--l2-background);
border: 1px solid var(--l2-border);
color: var(--l2-foreground);
font-size: var(--font-size-xs);
font-variant-numeric: tabular-nums;
}
.grow {
flex: 1;
}
/* Search and the type filter, the band's second row. */
.filtersRow {
position: relative;
z-index: 10;
display: flex;
align-items: center;
gap: var(--spacing-4);
flex-wrap: wrap;
}
/* Spans the row, the way the dashboards filter bar does. The input's own
wrapper is `width: 100%`, so the basis has to be 0 or `auto` resolves to the
full row and wraps the type filter onto the next line. */
.searchInput {
flex: 1 1 0;
min-width: 220px;
}
.kindSelect {
/* the trigger sizes itself to 100%, so the flex item has to be bounded */
flex: 0 0 180px;
width: 180px;
max-width: 180px;
}
/* The options panel is rendered inline (withPortal={false}) so it can be told to
match the trigger; Radix publishes that width at the popper layer. */
.filtersRow :global([data-slot='select-content']) {
width: var(--radix-select-trigger-width);
min-width: var(--radix-select-trigger-width);
}
.commandHeader > button {
flex: 0 0 auto;
}
.searchIcon {
color: var(--l2-foreground);
}
.actionsHeader {
display: block;
text-align: right;
}
/* Left on the table's own defaults, the way Infrastructure Monitoring uses it:
transparent cells over the page background rather than a raised slab. */
.tableContainer {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
--tanstack-table-row-hover-bg: var(--l2-background-hover);
--tanstack-table-row-active-bg: var(--l2-background-active);
--tanstack-table-row-height: 42px;
--tanstack-cell-padding-top-override: 5px;
--tanstack-cell-padding-bottom-override: 5px;
--tanstack-cell-padding-left-override: 16px;
--tanstack-cell-padding-right-override: 16px;
--tanstack-table-row-odd-bg: color-mix(
in srgb,
var(--l1-foreground) 2%,
transparent
);
--tanstack-table-row-even-bg: color-mix(
in srgb,
var(--l1-foreground) 1%,
transparent
);
}
.paginationContainer {
padding-right: var(--spacing-12);
height: 62px;
}

View File

@@ -0,0 +1,253 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Plus, Search } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
import TanStackTable from 'components/TanStackTableView';
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
import { useTableParams } from 'components/TanStackTableView/useTableParams';
import TextToolTip from 'components/TextToolTip';
import { useUrlSearchState } from 'hooks/useUrlSearchState';
import { parseAsString, useQueryState } from 'nuqs';
import { useTimezone } from 'providers/Timezone';
import ChannelActionsMenu from './components/ChannelActionsMenu/ChannelActionsMenu';
import ChannelJsonDialog from './components/ChannelJsonDialog/ChannelJsonDialog';
import {
KIND_FILTER_ALL,
KIND_FILTER_OPTIONS,
KIND_KEY,
SEARCH_KEY,
} from './constants';
import { useChannelDelete } from './hooks/useChannelDelete';
import { useChannelFormView } from './hooks/useChannelFormView';
import { useChannelsData } from './hooks/useChannelsData';
import styles from './NotificationChannels.module.scss';
import { getChannelColumns } from './table.config';
import type { Channel } from './types';
const QUERY_PARAMS_CONFIG = {
orderBy: 'orderBy',
page: 'page',
limit: 'limit',
} as const;
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 10;
function NotificationChannels(): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const formView = useChannelFormView();
const { containerRef, calculatedPageSize } = useCalculatedPageSize({
rowHeight: 46,
});
const { orderBy, page, limit, setLimit, setPage } = useTableParams(
QUERY_PARAMS_CONFIG,
{
page: DEFAULT_PAGE,
limit: DEFAULT_LIMIT,
storageKey: 'notification-channels',
calculatedPageSize,
cleanupOnUnmount: true,
},
);
const resetPageOnSearch = useCallback((): void => {
setPage(1);
}, [setPage]);
const { searchText, debouncedSearch, handleSearchChange, clearSearch } =
useUrlSearchState(SEARCH_KEY, { onDebouncedChange: resetPageOnSearch });
const [kind, setKind] = useQueryState(
KIND_KEY,
parseAsString.withDefault(KIND_FILTER_ALL),
);
const handleKindChange = useCallback(
(value: string | string[]): void => {
const next = value as string;
void setKind(next === KIND_FILTER_ALL ? null : next);
setPage(1);
},
[setKind, setPage],
);
const { channels, total, isFetching, isError, refetch } = useChannelsData({
search: debouncedSearch,
kind,
orderBy,
page,
limit,
});
const deletion = useChannelDelete();
const [jsonChannel, setJsonChannel] = useState<Channel | null>(null);
useEffect(() => {
void logEvent('Alert Channel: Channel list page visited', {});
}, []);
const handleEdit = useCallback(
(channel: Channel, options?: { newTab?: boolean }): void => {
formView.openEdit(channel.id, options);
},
[formView],
);
const handleRowClick = useCallback(
(channel: Channel): void => formView.openEdit(channel.id),
[formView],
);
const handleRowClickNewTab = useCallback(
(channel: Channel): void => formView.openEdit(channel.id, { newTab: true }),
[formView],
);
const columns = useMemo(
() => getChannelColumns(formatTimezoneAdjustedTimestamp),
[formatTimezoneAdjustedTimestamp],
);
const columnsWithActions = useMemo(
() => [
...columns,
{
id: 'actions',
header: (): JSX.Element => (
<span className={styles.actionsHeader}>Actions</span>
),
accessorKey: 'id',
width: { fixed: '96px', ignoreLastColumnFill: true },
enableSort: false,
enableResize: false,
enableRemove: false,
enableMove: false,
pin: 'right' as const,
cell: ({ row }: { row: Channel }): JSX.Element => (
<ChannelActionsMenu
channel={row}
onOpen={handleEdit}
onDelete={deletion.request}
onViewJson={setJsonChannel}
/>
),
},
],
[columns, handleEdit, deletion.request],
);
const hasActiveFilters = searchText.length > 0 || kind !== KIND_FILTER_ALL;
const isEmpty = !isFetching && channels.length === 0;
const handleClearFilters = useCallback((): void => {
clearSearch();
void setKind(null);
}, [clearSearch, setKind]);
return (
<div className={styles.container}>
<header className={styles.headerBand}>
<div className={styles.commandHeader}>
<div className={styles.headingBlock}>
<Typography.Title className={styles.title}>
All notification channels
</Typography.Title>
<span className={styles.countPill}>{total}</span>
</div>
<div className={styles.grow} />
<TextToolTip
text="More details on how to configure notification channels"
url="https://signoz.io/docs/setup-alerts-notification/"
urlText="Learn More"
/>
<Button
variant="solid"
color="primary"
size="sm"
prefix={<Plus size={14} />}
onClick={formView.openCreate}
testId="channels-create"
>
New channel
</Button>
</div>
<div className={styles.filtersRow}>
<Input
containerClassName={styles.searchInput}
placeholder="Search by channel name"
value={searchText}
onChange={handleSearchChange}
suffix={<Search size={14} className={styles.searchIcon} />}
testId="channels-search"
/>
<SelectSimple
className={styles.kindSelect}
items={KIND_FILTER_OPTIONS}
value={kind}
onChange={handleKindChange}
withPortal={false}
testId="channels-kind-filter"
/>
</div>
</header>
<div ref={containerRef} className={styles.tableContainer}>
{isError ? (
<ErrorEmptyState
title="Failed to load notification channels"
onRefresh={refetch}
/>
) : isEmpty && hasActiveFilters ? (
<NoResultsEmptyState
title="No matching channels"
subtitle="No notification channels match your search. Try adjusting your search criteria."
onClear={handleClearFilters}
clearButtonText="Clear Search"
/>
) : (
<TanStackTable<Channel>
data={channels}
columns={columnsWithActions}
isLoading={isFetching}
getRowKey={(row): string => row.id}
getItemKey={(row): string => row.id}
columnStorageKey="notification-channels-columns"
enableQueryParams={QUERY_PARAMS_CONFIG}
onRowClick={handleRowClick}
onRowClickNewTab={handleRowClickNewTab}
pagination={{
total,
calculatedPageSize,
onLimitChange: setLimit,
showTotalCount: true,
}}
paginationClassname={styles.paginationContainer}
enableAlternatingRowColors
plainTextCellLineClamp={1}
/>
)}
</div>
{jsonChannel && (
<ChannelJsonDialog
channelId={jsonChannel.id}
channelName={jsonChannel.displayName}
onClose={(): void => setJsonChannel(null)}
/>
)}
{deletion.contextHolder}
</div>
);
}
export default NotificationChannels;

View File

@@ -0,0 +1,100 @@
import { server } from 'mocks-server/server';
import { screen, userEvent, waitFor } from 'tests/test-utils';
import { renderChannels } from './renderChannels';
const FIND = { timeout: 5000 };
const TEST_TIMEOUT = 20000;
describe('Notification channels list', () => {
beforeEach(() => {
// search, filter and page live in the url, so one test's filter would
// otherwise still be applied in the next
window.history.replaceState({}, '', '/');
});
afterEach(() => {
jest.restoreAllMocks();
server.resetHandlers();
});
it(
'heads the page with the channel count',
async () => {
renderChannels();
await expect(
screen.findByText('All notification channels', {}, FIND),
).resolves.toBeInTheDocument();
// the count comes from the API's total, not the loaded page
await expect(screen.findByText('2', {}, FIND)).resolves.toBeInTheDocument();
},
TEST_TIMEOUT,
);
it(
'lists every channel with its type',
async () => {
renderChannels();
await expect(
screen.findByText('Dummy-Channel', {}, FIND),
).resolves.toBeInTheDocument();
expect(screen.getByText('Oncall PagerDuty')).toBeInTheDocument();
// the type reads as plain text, no colour coding
expect(screen.getByText('Slack')).toBeInTheDocument();
expect(screen.getByText('PagerDuty')).toBeInTheDocument();
},
TEST_TIMEOUT,
);
it(
'filters server-side as the user types',
async () => {
renderChannels();
await screen.findByText('Dummy-Channel', {}, FIND);
await userEvent.type(screen.getByTestId('channels-search'), 'oncall');
await waitFor(() => {
expect(screen.queryByText('Dummy-Channel')).not.toBeInTheDocument();
}, FIND);
await expect(
screen.findByText('Oncall PagerDuty', {}, FIND),
).resolves.toBeInTheDocument();
},
TEST_TIMEOUT,
);
it(
'asks for confirmation before deleting, from the row menu',
async () => {
renderChannels();
await screen.findByText('Dummy-Channel', {}, FIND);
// the table paints a loading layer over the rows until the fetch settles
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
await user.click(screen.getByTestId('channel-actions-3'));
// Radix renders the menu into a portal once the trigger is activated
await screen.findByRole('menu', {}, FIND);
// the menu lives inside the row's clickable cell, so opening it must not
// also fire the row click and route to the edit screen
expect(window.location.pathname).not.toContain('/channels/edit/');
await user.click(
await screen.findByRole('menuitem', { name: 'Delete' }, FIND),
);
// the shared destructive confirmation, the one dashboards also use
await expect(
screen.findByText(
/Are you sure you want to delete the Dummy-Channel channel\?/,
{},
FIND,
),
).resolves.toBeInTheDocument();
},
TEST_TIMEOUT,
);
});

View File

@@ -0,0 +1,54 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
import {
channelToJson,
hasSecrets,
SECRET_PLACEHOLDER,
} from '../utils/channelJson';
const slackChannel = {
id: '1',
name: 'prod-alerts',
displayName: 'prod alerts',
createdAt: '2026-09-01T00:00:00Z',
updatedAt: '2026-09-01T00:00:00Z',
config: {
kind: 'slack',
spec: {
apiUrl: 'https://hooks.slack.com/services/T/B/SECRET',
channel: '#alerts',
sendResolved: true,
},
},
} as AlertmanagertypesGettableNotificationChannelDTO;
describe('channelToJson', () => {
it('hides the credential the API returns in the clear', () => {
const json = JSON.parse(channelToJson(slackChannel, false));
expect(json.config.spec.apiUrl).toBe(SECRET_PLACEHOLDER);
expect(json.config.spec.channel).toBe('#alerts');
expect(channelToJson(slackChannel, false)).not.toContain('SECRET');
});
it('reveals it only when asked', () => {
const json = JSON.parse(channelToJson(slackChannel, true));
expect(json.config.spec.apiUrl).toBe(
'https://hooks.slack.com/services/T/B/SECRET',
);
});
it('leaves the timestamps and id out, so the JSON can be reused', () => {
const json = JSON.parse(channelToJson(slackChannel, true));
expect(json).not.toHaveProperty('id');
expect(json).not.toHaveProperty('createdAt');
expect(json.name).toBe('prod-alerts');
});
it('knows which kinds carry a credential', () => {
expect(hasSecrets('slack')).toBe(true);
expect(hasSecrets('email')).toBe(false);
});
});

View File

@@ -0,0 +1,13 @@
import { VirtuosoMockContext } from 'react-virtuoso';
import { render } from 'tests/test-utils';
import NotificationChannels from '../NotificationChannels';
/** The list is virtualized, so jsdom needs a viewport for rows to mount. */
export function renderChannels(): ReturnType<typeof render> {
return render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 800, itemHeight: 46 }}>
<NotificationChannels />
</VirtuosoMockContext.Provider>,
);
}

View File

@@ -0,0 +1,15 @@
.actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 6px;
}
.actions button {
opacity: 0.7;
transition: opacity 0.15s ease;
&:hover {
opacity: 1;
}
}

View File

@@ -0,0 +1,87 @@
import { useMemo } from 'react';
import {
Braces,
Ellipsis,
PenLine,
SquareArrowOutUpRight,
Trash2,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import type { Channel } from '../../types';
import styles from './ChannelActionsMenu.module.scss';
interface ChannelActionsMenuProps {
channel: Channel;
onOpen: (channel: Channel, options?: { newTab?: boolean }) => void;
onDelete: (channel: Channel) => void;
onViewJson: (channel: Channel) => void;
}
function ChannelActionsMenu({
channel,
onOpen,
onDelete,
onViewJson,
}: ChannelActionsMenuProps): JSX.Element {
const menuItems = useMemo<DropdownItemType[]>(
() => [
{
type: 'item',
value: 'open',
label: 'Edit',
prefix: <PenLine size={14} />,
onClick: (): void => onOpen(channel),
},
{
type: 'item',
value: 'open-new-tab',
label: 'Open in New Tab',
prefix: <SquareArrowOutUpRight size={14} />,
onClick: (): void => onOpen(channel, { newTab: true }),
},
{
type: 'item',
value: 'view-json',
label: 'View JSON',
prefix: <Braces size={14} />,
onClick: (): void => onViewJson(channel),
},
{ type: 'separator', value: 'before-delete' },
{
type: 'item',
value: 'delete',
label: 'Delete',
prefix: <Trash2 size={14} />,
danger: true,
onClick: (): void => onDelete(channel),
},
],
[channel, onOpen, onDelete, onViewJson],
);
return (
// the row itself opens the channel, so the actions keep their clicks
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
className={styles.actions}
onClick={(event): void => event.stopPropagation()}
>
<Dropdown items={menuItems} nativeButton align="end" side="bottom">
<Button
variant="outlined"
color="secondary"
size="sm"
icon
testId={`channel-actions-${channel.id}`}
aria-label={`Actions for ${channel.displayName}`}
>
<Ellipsis size={16} />
</Button>
</Dropdown>
</div>
);
}
export default ChannelActionsMenu;

View File

@@ -0,0 +1,26 @@
.body {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
height: 100%;
min-height: 420px;
}
.toolbar {
display: flex;
align-items: center;
gap: var(--spacing-2);
flex-wrap: wrap;
}
.spacer {
flex: 1 1 auto;
}
.editor {
flex: 1 1 auto;
min-height: 360px;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
overflow: hidden;
}

View File

@@ -0,0 +1,116 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import MEditor from '@monaco-editor/react';
import { DrawerWrapper } from '@signozhq/ui/drawer';
import { useGetNotificationChannel } from 'api/generated/services/channels';
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
import Spinner from 'components/Spinner';
import { READONLY_EDITOR_OPTIONS } from 'container/RolesSettings/monaco.config';
import {
defineJsonEditorTheme,
JSON_EDITOR_THEME,
} from 'lib/monaco/jsonEditorTheme';
import { channelToJson, hasSecrets } from '../../utils/channelJson';
import styles from './ChannelJsonDialog.module.scss';
import ChannelJsonToolbar from './ChannelJsonToolbar';
interface ChannelJsonDialogProps {
channelId: string;
channelName: string;
onClose: () => void;
}
function ChannelJsonDialog({
channelId,
channelName,
onClose,
}: ChannelJsonDialogProps): JSX.Element {
const [copyState, copyToClipboard] = useCopyToClipboard();
const [copied, setCopied] = useState(false);
const [showSecrets, setShowSecrets] = useState(false);
const { data, isLoading, refetch } = useGetNotificationChannel({
id: channelId,
});
const channel = data?.data;
const json = useMemo(
() => (channel ? channelToJson(channel, showSecrets) : ''),
[channel, showSecrets],
);
useEffect(() => {
if (copyState.value) {
setCopied(true);
const timer = setTimeout(() => setCopied(false), 1500);
return (): void => clearTimeout(timer);
}
return undefined;
}, [copyState]);
const handleCopy = useCallback(
(): void => copyToClipboard(json),
[copyToClipboard, json],
);
const handleRefetch = useCallback((): void => {
void refetch();
}, [refetch]);
const handleToggleSecrets = useCallback(
(): void => setShowSecrets((value) => !value),
[],
);
const kindHasSecrets = channel ? hasSecrets(channel.config.kind) : false;
return (
<DrawerWrapper
open
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title={channelName}
subTitle="Channel JSON"
width="wide"
testId="channel-json-dialog"
>
{isLoading ? (
<Spinner tip="Loading channel..." />
) : !channel ? (
<ErrorEmptyState title="Failed to load channel" onRefresh={handleRefetch} />
) : (
<div className={styles.body}>
<ChannelJsonToolbar
kindHasSecrets={kindHasSecrets}
showSecrets={showSecrets}
onToggleSecrets={handleToggleSecrets}
copied={copied}
onCopy={handleCopy}
/>
<div className={styles.editor}>
<MEditor
value={json}
language="json"
options={READONLY_EDITOR_OPTIONS}
height="100%"
theme="vs-dark"
// the same theme the dashboard JSON drawer uses: it reads the
// page's own tokens, so it follows light and dark
onMount={(editor, monaco): void => {
defineJsonEditorTheme(monaco, editor.getContainerDomNode());
monaco.editor.setTheme(JSON_EDITOR_THEME);
}}
/>
</div>
</div>
)}
</DrawerWrapper>
);
}
export default ChannelJsonDialog;

View File

@@ -0,0 +1,63 @@
import { Color } from '@signozhq/design-tokens';
import { Check, Copy, Eye, EyeOff } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import styles from './ChannelJsonDialog.module.scss';
interface ChannelJsonToolbarProps {
kindHasSecrets: boolean;
showSecrets: boolean;
onToggleSecrets: () => void;
copied: boolean;
onCopy: () => void;
}
function ChannelJsonToolbar({
kindHasSecrets,
showSecrets,
onToggleSecrets,
copied,
onCopy,
}: ChannelJsonToolbarProps): JSX.Element {
return (
<div className={styles.toolbar}>
{kindHasSecrets && (
<Typography.Text color="muted" size="sm">
{showSecrets ? 'Credentials are visible.' : 'Credentials are hidden.'}
</Typography.Text>
)}
<span className={styles.spacer} />
{kindHasSecrets && (
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={showSecrets ? <EyeOff size={14} /> : <Eye size={14} />}
onClick={onToggleSecrets}
testId="channel-json-toggle-secrets"
>
{showSecrets ? 'Hide credentials' : 'Show credentials'}
</Button>
)}
<Button
variant="outlined"
color="secondary"
size="sm"
prefix={
copied ? (
<Check size={14} color={Color.BG_FOREST_400} />
) : (
<Copy size={14} />
)
}
onClick={onCopy}
testId="channel-json-copy"
>
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
);
}
export default ChannelJsonToolbar;

View File

@@ -0,0 +1,34 @@
import { AlertmanagertypesChannelKindDTO } from 'api/generated/services/sigNoz.schemas';
export const SEARCH_KEY = 'search';
export const KIND_KEY = 'kind';
export const PAGE_KEY = 'page';
export const PAGE_SIZE = 20;
export const SEARCH_DEBOUNCE_MS = 300;
export const CHANNEL_KIND_LABEL: Record<
AlertmanagertypesChannelKindDTO,
string
> = {
[AlertmanagertypesChannelKindDTO.slack]: 'Slack',
[AlertmanagertypesChannelKindDTO.email]: 'Email',
[AlertmanagertypesChannelKindDTO.webhook]: 'Webhook',
[AlertmanagertypesChannelKindDTO.pagerduty]: 'PagerDuty',
[AlertmanagertypesChannelKindDTO.opsgenie]: 'Opsgenie',
[AlertmanagertypesChannelKindDTO.msteams]: 'MS Teams',
[AlertmanagertypesChannelKindDTO.googlechat]: 'Google Chat',
[AlertmanagertypesChannelKindDTO.jira]: 'Jira',
[AlertmanagertypesChannelKindDTO.jsmops]: 'JSM Ops',
[AlertmanagertypesChannelKindDTO.incidentio]: 'incident.io',
};
export const KIND_FILTER_ALL = 'all';
export const KIND_FILTER_OPTIONS = [
{ value: KIND_FILTER_ALL, label: 'All types' },
...Object.entries(CHANNEL_KIND_LABEL).map(([value, label]) => ({
value,
label,
})),
];

View File

@@ -0,0 +1,55 @@
import { useQueryClient } from 'react-query';
import { Typography } from '@signozhq/ui/typography';
import {
invalidateListNotificationChannels,
useDeleteNotificationChannel,
} from 'api/generated/services/channels';
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { ReactNode } from 'react';
import APIError from 'types/api/error';
import type { Channel } from '../types';
export interface ChannelDeleteState {
/** Must be rendered by the caller for the confirmation to appear. */
contextHolder: ReactNode;
request: (channel: Channel) => void;
}
/**
* Deleting a channel goes through the shared destructive confirmation, the same
* one dashboards and saved views use, so the wording and the danger button match.
*/
export function useChannelDelete(onDeleted?: () => void): ChannelDeleteState {
const queryClient = useQueryClient();
const { showErrorModal } = useErrorModal();
const { contextHolder, confirmDelete } = useDeleteConfirm();
const { mutateAsync } = useDeleteNotificationChannel();
const request = (channel: Channel): void => {
confirmDelete({
title: (
<Typography.Title level={5}>
Are you sure you want to delete the {channel.displayName} channel?
</Typography.Title>
),
// Alert rules and routing policies reference a channel by name, so a
// delete can silence them.
content:
'Alert rules and routing policies that notify this channel will stop reaching it.',
// Returning the promise keeps Delete loading until the request settles.
onConfirm: async (): Promise<void> => {
try {
await mutateAsync({ pathParams: { id: channel.id } });
await invalidateListNotificationChannels(queryClient);
onDeleted?.();
} catch (error) {
showErrorModal(error as APIError);
}
},
});
};
return { contextHolder, request };
}

View File

@@ -0,0 +1,28 @@
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { openInNewTab } from 'utils/navigation';
export interface ChannelFormView {
openCreate: () => void;
openEdit: (channelId: string, options?: { newTab?: boolean }) => void;
}
/** Create and edit both render on their own route. */
export function useChannelFormView(): ChannelFormView {
const openEdit = (channelId: string, options?: { newTab?: boolean }): void => {
const path = generatePath(ROUTES.CHANNELS_EDIT, { channelId });
if (options?.newTab) {
openInNewTab(path);
return;
}
history.push(path);
};
return {
openCreate: (): void => history.push(ROUTES.CHANNELS_NEW),
openEdit,
};
}

View File

@@ -0,0 +1,75 @@
import { useListNotificationChannels } from 'api/generated/services/channels';
import {
AlertmanagertypesChannelKindDTO,
AlertmanagertypesChannelListOrderDTO,
AlertmanagertypesChannelListSortDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { SortState } from 'components/TanStackTableView';
import { KIND_FILTER_ALL } from '../constants';
import type { Channel } from '../types';
/** Table column ids the list API can order by. */
const SORT_BY_COLUMN: Record<string, AlertmanagertypesChannelListSortDTO> = {
name: AlertmanagertypesChannelListSortDTO.name,
createdAt: AlertmanagertypesChannelListSortDTO.created_at,
updatedAt: AlertmanagertypesChannelListSortDTO.updated_at,
};
interface UseChannelsDataArgs {
search: string;
kind: string;
orderBy: SortState | null;
page: number;
limit: number;
enabled?: boolean;
}
export interface ChannelsData {
channels: Channel[];
total: number;
isFetching: boolean;
isError: boolean;
refetch: () => void;
}
/**
* Search, kind filter, sort and paging all run on the API, so the table shows
* one page at a time rather than filtering a full download in the browser.
*/
export function useChannelsData({
search,
kind,
orderBy,
page,
limit,
enabled = true,
}: UseChannelsDataArgs): ChannelsData {
const sort = orderBy ? SORT_BY_COLUMN[orderBy.columnName] : undefined;
const { data, isFetching, isError, refetch } = useListNotificationChannels(
{
limit,
offset: (page - 1) * limit,
...(sort
? {
sort,
order: orderBy?.order as AlertmanagertypesChannelListOrderDTO,
}
: {}),
...(search ? { query: search } : {}),
...(kind !== KIND_FILTER_ALL
? { kind: kind as AlertmanagertypesChannelKindDTO }
: {}),
},
{ query: { enabled, keepPreviousData: true } },
);
return {
channels: data?.data?.channels ?? [],
total: data?.data?.total ?? 0,
isFetching,
isError,
refetch,
};
}

View File

@@ -0,0 +1,88 @@
import type { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { CHANNEL_KIND_LABEL } from './constants';
import styles from './table.module.scss';
import type { Channel } from './types';
import { toRelativeTime } from './utils/relativeTime';
export function getChannelColumns(
formatTimezoneAdjustedTimestamp: (date: string, format: string) => string,
): TableColumnDef<Channel>[] {
const timestampCell = (row: Channel, value: string): JSX.Element => {
if (!value) {
return <TanStackTable.Text>-</TanStackTable.Text>;
}
return (
<TanStackTable.HoverTooltip
rowId={row.id}
title={formatTimezoneAdjustedTimestamp(
value,
DATE_TIME_FORMATS.DASH_DATETIME,
)}
>
{/* A plain inline-block hugs the text. TanStackTable.Text is a
`-webkit-box` for line clamping, which fills the cell and leaves
the arrow pointing at the cell's middle instead of the words. */}
<span className={styles.timeText}>{toRelativeTime(value)}</span>
</TanStackTable.HoverTooltip>
);
};
return [
{
id: 'name',
header: 'Channel',
accessorKey: 'displayName',
width: { default: '100%', min: '220px' },
enableSort: true,
enableResize: true,
enableRemove: false,
enableMove: false,
cell: ({ row }): JSX.Element => (
<TanStackTable.Text
title={row.displayName}
data-testid={`channel-row-${row.id}-name`}
>
{row.displayName}
</TanStackTable.Text>
),
},
{
id: 'kind',
header: 'Type',
accessorKey: 'kind',
width: { default: '150px', min: '110px' },
enableSort: false,
enableResize: true,
enableMove: false,
cell: ({ row }): JSX.Element => (
<TanStackTable.Text data-testid={`channel-row-${row.id}-kind`}>
{CHANNEL_KIND_LABEL[row.kind]}
</TanStackTable.Text>
),
},
{
id: 'createdAt',
header: 'Created At',
accessorKey: 'createdAt',
width: { default: '150px', min: '120px' },
enableSort: true,
enableResize: true,
enableMove: false,
cell: ({ row }): JSX.Element => timestampCell(row, row.createdAt),
},
{
id: 'updatedAt',
header: 'Updated At',
accessorKey: 'updatedAt',
width: { default: '150px', min: '120px' },
enableSort: true,
enableResize: true,
enableMove: false,
cell: ({ row }): JSX.Element => timestampCell(row, row.updatedAt),
},
];
}

View File

@@ -0,0 +1,7 @@
.timeText {
display: inline-block;
white-space: nowrap;
font-size: var(--tanstack-plain-cell-font-size, 14px);
line-height: var(--tanstack-plain-cell-line-height, 18px);
color: inherit;
}

View File

@@ -0,0 +1,4 @@
import { AlertmanagertypesListedNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
/** A channel as the list API returns it: identity and kind, but no config. */
export type Channel = AlertmanagertypesListedNotificationChannelDTO;

View File

@@ -0,0 +1,50 @@
import { AlertmanagertypesGettableNotificationChannelDTO } from 'api/generated/services/sigNoz.schemas';
export const SECRET_PLACEHOLDER = '••••••••';
/**
* The spec fields the API marks `format: password`. A fetch by ID returns them
* in the clear, so the JSON view hides them until asked.
*/
const SECRET_FIELDS_BY_KIND: Record<string, readonly string[]> = {
slack: ['apiUrl'],
webhook: ['url', 'password', 'bearerToken'],
pagerduty: ['routingKey'],
opsgenie: ['apiKey'],
msteams: ['webhookUrl'],
googlechat: ['webhookUrl'],
jira: ['apiToken'],
jsmops: ['apiKey'],
incidentio: ['token'],
};
export function channelToJson(
channel: AlertmanagertypesGettableNotificationChannelDTO,
showSecrets: boolean,
): string {
const { kind, spec } = channel.config;
const secrets = SECRET_FIELDS_BY_KIND[kind] ?? [];
const safeSpec = showSecrets
? spec
: Object.fromEntries(
Object.entries(spec).map(([key, value]) => [
key,
secrets.includes(key) && value ? SECRET_PLACEHOLDER : value,
]),
);
return JSON.stringify(
{
name: channel.name,
displayName: channel.displayName,
config: { kind, spec: safeSpec },
},
null,
2,
);
}
export function hasSecrets(kind: string): boolean {
return (SECRET_FIELDS_BY_KIND[kind] ?? []).length > 0;
}

View File

@@ -0,0 +1,10 @@
import dayjs from 'dayjs';
import relativeTimePlugin from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTimePlugin);
/** "2 months ago". The exact timestamp stays available on hover. */
export function toRelativeTime(value: string | undefined): string {
const parsed = value ? dayjs(value) : null;
return parsed?.isValid() ? parsed.fromNow() : '-';
}

View File

@@ -4,7 +4,6 @@ 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';
@@ -16,6 +15,8 @@ import AlertDetails from 'pages/AlertDetails';
import ChannelsEdit from 'pages/ChannelsEdit';
import ChannelsNew from 'pages/ChannelsNew';
import NotificationChannels from './NotificationChannels/NotificationChannels';
import { AlertListSubTabs, AlertListTabs } from './types';
import './AlertList.styles.scss';
@@ -104,7 +105,7 @@ function AllAlertList(): JSX.Element {
<div className="alert-rules-container">
{isChannelsNew && <ChannelsNew />}
{isChannelsEdit && <ChannelsEdit />}
{!isChannelDetails && <AllAlertChannels />}
{!isChannelDetails && <NotificationChannels />}
</div>
),
},

View File

@@ -26,6 +26,9 @@ import {
type RuletypesAlertCompositeQueryDTO,
type RuletypesRuleConditionDTO,
type RuletypesRuleDTO,
type AlertmanagertypesChannelConfigDTO,
type AlertmanagertypesGettableNotificationChannelDTO,
type AlertmanagertypesListedNotificationChannelDTO,
} from 'api/generated/services/sigNoz.schemas';
import { NEW_ALERT_SCHEMA_VERSION } from 'types/api/alerts/alertTypesV2';
import type { Channels } from 'types/api/channels/getAll';
@@ -704,6 +707,22 @@ const buildChannel = (index: number): Channels => {
};
};
const buildListedChannel = (
index: number,
): AlertmanagertypesListedNotificationChannelDTO => {
const seed = CHANNEL_SEEDS[index % CHANNEL_SEEDS.length];
return {
id: String(index + 1),
name: seed.name,
displayName: seed.name,
kind: seed.type as AlertmanagertypesListedNotificationChannelDTO['kind'],
createdAt: ago((index + 10) * DAY),
updatedAt: ago((index + 1) * DAY),
};
};
/** The v2 list, which is what the channel pickers read. */
export const channelsResponse = (
count: number,
): { status: string; data: Channels[] } => ({
@@ -711,10 +730,97 @@ export const channelsResponse = (
data: Array.from({ length: count }, (_unused, index) => buildChannel(index)),
});
/** The v2 list, which is what the redesigned channels list reads. */
export const notificationChannelsResponse = (
count: number,
): {
status: string;
data: {
channels: AlertmanagertypesListedNotificationChannelDTO[];
total: number;
};
} => {
const channels = Array.from({ length: count }, (_unused, index) =>
buildListedChannel(index),
);
return { status: 'success', data: { channels, total: channels.length } };
};
/** Channel names the alert form and the routing policies pick from. */
export const channelNames = (count: number): string[] =>
channelsResponse(count).data.map((channel) => channel.name);
/**
* The v2 spec each kind is fetched with, so the edit form renders populated
* fields. Keys mirror the generated channel spec schemas.
*/
const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
slack: {
apiUrl: 'https://hooks.slack.com/services/T000/B000/story-token',
channel: '#ops-alerts',
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}',
text: '{{ range .Alerts -}}*Alert:* {{ .Labels.alertname }}\n{{ end }}',
sendResolved: true,
},
webhook: {
url: 'https://example.com/story-hook',
username: 'story-user',
sendResolved: true,
},
pagerduty: {
routingKey: 'story-routing-key',
description: '{{ .CommonLabels.alertname }}',
severity: 'critical',
sendResolved: true,
},
opsgenie: {
apiKey: 'story-opsgenie-key',
message: '{{ .CommonLabels.alertname }}',
description: 'Story channel',
sendResolved: true,
},
email: {
to: 'oncall@story.signoz.io',
html: '<p>{{ .CommonLabels.alertname }}</p>',
sendResolved: true,
},
msteams: {
webhookUrl: 'https://story.webhook.office.com/webhookb2/story',
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}',
text: 'Story channel',
sendResolved: true,
},
googlechat: {
webhookUrl: 'https://chat.googleapis.com/v1/spaces/STORY/messages',
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}',
text: 'Story channel',
sendResolved: true,
},
jira: {
site: 'https://story.atlassian.net',
project: 'OPS',
issueType: 'Task',
email: 'story@signoz.io',
apiToken: 'story-jira-token',
summary: '{{ .CommonLabels.alertname }}',
sendResolved: true,
},
jsmops: {
apiKey: 'story-jsm-key',
message: '{{ .CommonLabels.alertname }}',
description: 'Story channel',
sendResolved: true,
},
incidentio: {
url: 'https://api.incident.io/v2/alert_events/http/story',
token: 'story-incidentio-token',
title: '{{ .CommonLabels.alertname }}',
sendResolved: true,
},
};
/** A single channel as the v2 API returns it, which the edit screen reads. */
export const channelResponse = (
id: string,
type: ChannelType,
@@ -727,6 +833,36 @@ export const channelResponse = (
};
};
/** The v2 fetch by id, which is what the redesigned screens read. */
export const notificationChannelResponse = (
id: string,
type: ChannelType,
): {
status: string;
data: AlertmanagertypesGettableNotificationChannelDTO;
} => {
const index = Math.max(
CHANNEL_SEEDS.findIndex((seed) => seed.type === type),
0,
);
const seed = CHANNEL_SEEDS[index];
return {
status: 'success',
data: {
id,
name: seed.name,
displayName: seed.name,
createdAt: ago((index + 10) * DAY),
updatedAt: ago((index + 1) * DAY),
config: {
kind: type,
spec: CHANNEL_SPECS[type],
} as unknown as AlertmanagertypesChannelConfigDTO,
},
};
};
export const CHANNEL_ACTION_OUTCOMES = ['succeeds', 'fails'] as const;
export type ChannelActionOutcome = (typeof CHANNEL_ACTION_OUTCOMES)[number];

View File

@@ -12,7 +12,10 @@ import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import { defineJsonEditorTheme, JSON_EDITOR_THEME } from './editorTheme';
import {
defineJsonEditorTheme,
JSON_EDITOR_THEME,
} from 'lib/monaco/jsonEditorTheme';
import styles from './JsonEditorDrawer.module.scss';
import JsonEditorToolbar from './JsonEditorToolbar';
import { useJsonEditor } from './useJsonEditor';