mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-22 10:00:42 +01:00
Compare commits
2 Commits
feat/scatt
...
v0.147.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fc6fbb9fa | ||
|
|
93d054ee02 |
@@ -105,6 +105,13 @@ is there to look at. Shooting two narrow runs and diffing those against each
|
||||
other has the same problem. Either shoot the full set twice, or skip `--noise`
|
||||
and explain the few movers by hand.
|
||||
|
||||
**`--noise` ignores how far a pair moved.** A pair is marked flaky when the
|
||||
noise diff does not show it as unchanged, so a 70-pixel flicker in the noise run
|
||||
hides a real 8% change in the real one. Compare the two numbers before trusting
|
||||
the mark. When most of the flaky marks are hiding real changes, run the diff
|
||||
without `--noise` and tag the genuinely noisy pairs by hand instead, so nothing
|
||||
real is filtered out of the reviewer's view.
|
||||
|
||||
That is the trade: a second full capture costs about as long as the first, so on
|
||||
a small diff it is often quicker to crop the two or three movers and say what
|
||||
each one is. Use `--noise` when the diff is large, or when wiring `--fail-over`
|
||||
@@ -138,6 +145,15 @@ eye.
|
||||
|
||||
- A full capture is hundreds of shots. Scope with `--title "Pages/Alerts"` or
|
||||
`--stories "a,b,c"` (substring match on id or Title/Name, repeatable).
|
||||
- **On macOS a capture can pin itself to one story at a time.** sbshot picks how
|
||||
many to shoot at once by sampling free memory, and off Linux it reads
|
||||
`os.freemem()`, which counts only truly free pages. macOS parks almost
|
||||
everything in "inactive" instead, which is reclaimable on demand, so the
|
||||
sampler can read ~100 MB on a 25 GB machine and shrink to one forever.
|
||||
`--jobs` does not help: it is only an upper cap. Check `parallelism` in
|
||||
`<run>/events.ndjson`; if it sits at 1 while `vm_stat` shows gigabytes
|
||||
inactive, that is this. A 124-shot run takes about 90 minutes pinned and about
|
||||
6 minutes at four at a time, so it is worth spotting before a long capture.
|
||||
- A fresh worktree cannot build Storybook: `i18n-translations-hash.json` is
|
||||
gitignored and generated. Run `node ./i18-generate-hash.cjs` in
|
||||
`<worktree>/frontend`, and symlink `node_modules` from the main checkout.
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"section_connection": "Connection",
|
||||
"section_message": "Message",
|
||||
"section_advanced": "Advanced",
|
||||
"description_slack_advanced": "The rest of the Slack attachment. Every box here is optional.",
|
||||
"section_recipients": "Recipients",
|
||||
"field_channel_name": "Name",
|
||||
"help_channel_name_immutable": "Renaming is blocked while alert rules and routing policies still reference a channel by name.",
|
||||
@@ -186,8 +187,32 @@
|
||||
"pager_details_invalid_json": "Enter valid JSON, or clear the box to send no details",
|
||||
"slack_channel_help": "Specify channel or user, use #channel-name, @username (has to be all lowercase, no whitespace)",
|
||||
"api_key_required": "API Key is mandatory",
|
||||
"to_required": "To field is mandatory",
|
||||
"channel_name_required": "Channel name is mandatory",
|
||||
"to_required": "To field is mandatory",
|
||||
"channel_name_required": "Channel name is mandatory",
|
||||
"tooltip_send_resolved": "Also notify this channel when an alert stops firing.",
|
||||
"tooltip_channel_type": "Where the alert is delivered. It cannot be changed once the channel is saved.",
|
||||
"button_discard_channel": "Discard",
|
||||
"button_discard_confirm": "Discard",
|
||||
"discard_changes_title": "Discard your changes?",
|
||||
"discard_changes_body": "This form has unsaved changes. If you leave now, they are gone and the channel stays as it is.",
|
||||
"tooltip_save_no_changes": "Nothing has changed yet.",
|
||||
"entry_slack_field": "Field",
|
||||
"entry_slack_action": "Action",
|
||||
"entry_incidentio_metadata": "Entry",
|
||||
"label_slack_field_title": "Title",
|
||||
"tooltip_slack_field_title": "The bold label Slack shows above the value, for example Severity.",
|
||||
"label_slack_field_value": "Value",
|
||||
"tooltip_slack_field_value": "What Slack shows under the title. Templates are allowed, so `{{ .CommonLabels.severity }}` fills in from the alert.",
|
||||
"label_incidentio_metadata_key": "Key",
|
||||
"label_incidentio_metadata_value": "Value",
|
||||
"tooltip_slack_fields": "Extra entries Slack renders as a small table inside the alert attachment.",
|
||||
"tooltip_slack_actions": "Buttons Slack renders under the attachment. A button with a URL links out; the rest are for a Slack app to handle.",
|
||||
"tooltip_slack_action_text": "The label on the button.",
|
||||
"tooltip_slack_action_url": "Where the button sends the reader. Leave empty for a button a Slack app handles instead.",
|
||||
"tooltip_slack_action_name": "Identifies the button to a Slack app. Not needed for a button that only links out.",
|
||||
"tooltip_slack_action_value": "Sent to a Slack app along with the name, so one app can tell its buttons apart.",
|
||||
"tooltip_slack_action_style": "default, primary or danger. Slack colours the button accordingly.",
|
||||
"tooltip_slack_action_confirm": "Text for a confirmation prompt shown before the action runs. Leave empty for no prompt.",
|
||||
"bot_token_required": "Bot token is mandatory for creating a telegram channel",
|
||||
"chat_id_required": "Chat ID is mandatory for creating a telegram channel",
|
||||
"telegram_tip": "Create a bot with @BotFather, add it to the chat, then paste its token and the chat's numeric id below.",
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-vanilla-100);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-vanilla-400);
|
||||
color: var(--l2-foreground);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,18 +9,18 @@
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--text-vanilla-400);
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-vanilla-100);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-vanilla-400);
|
||||
color: var(--l2-foreground);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
|
||||
24
frontend/src/components/BackHeader/BackHeader.module.scss
Normal file
24
frontend/src/components/BackHeader/BackHeader.module.scss
Normal file
@@ -0,0 +1,24 @@
|
||||
/* The space sits between the way back and the title, rather than above both of
|
||||
them, so the two read as separate steps. */
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
min-height: 26px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.divider {
|
||||
--divider-color: var(--l1-border);
|
||||
--divider-margin: 10px 0 16px 0;
|
||||
}
|
||||
56
frontend/src/components/BackHeader/BackHeader.tsx
Normal file
56
frontend/src/components/BackHeader/BackHeader.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { ArrowLeft } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Divider } from '@signozhq/ui/divider';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { isModifierKeyPressed } from 'utils/app';
|
||||
|
||||
import styles from './BackHeader.module.scss';
|
||||
|
||||
export interface BackHeaderProps {
|
||||
/** Where the page came from, named as the place rather than the action. */
|
||||
backLabel: string;
|
||||
backRoute: string;
|
||||
title: ReactNode;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A way back on its own line, then the page's own title. The two rows keep the
|
||||
* title clear of whatever sits beside it, such as a copy button.
|
||||
*/
|
||||
function BackHeader({
|
||||
backLabel,
|
||||
backRoute,
|
||||
title,
|
||||
testId,
|
||||
}: BackHeaderProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.header} data-testid={testId}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<ArrowLeft size={12} />}
|
||||
onClick={(e: React.MouseEvent): void => {
|
||||
safeNavigate(backRoute, { newTab: isModifierKeyPressed(e) });
|
||||
}}
|
||||
testId="back-link"
|
||||
>
|
||||
{backLabel}
|
||||
</Button>
|
||||
<div className={styles.title}>{title}</div>
|
||||
</div>
|
||||
<Divider className={styles.divider} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
BackHeader.defaultProps = {
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
export default BackHeader;
|
||||
@@ -3,23 +3,21 @@ import { Columns3 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView';
|
||||
import {
|
||||
hideColumn,
|
||||
showColumn,
|
||||
useHiddenColumnIds,
|
||||
} from 'components/TanStackTableView';
|
||||
|
||||
import styles from './ColumnSelector.module.scss';
|
||||
import type { TableColumnDef } from './types';
|
||||
import { hideColumn, showColumn, useHiddenColumnIds } from './useColumnStore';
|
||||
|
||||
interface ColumnSelectorProps<TData> {
|
||||
columns: TableColumnDef<TData>[];
|
||||
/** Shared with the table's `columnStorageKey`, which is what it writes to. */
|
||||
storageKey: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function ColumnSelector<TData>({
|
||||
columns,
|
||||
storageKey,
|
||||
testId = 'column-selector-button',
|
||||
}: ColumnSelectorProps<TData>): JSX.Element {
|
||||
const hiddenColumnIds = useHiddenColumnIds(storageKey);
|
||||
|
||||
@@ -47,7 +45,7 @@ function ColumnSelector<TData>({
|
||||
size="sm"
|
||||
color="secondary"
|
||||
prefix={<Columns3 size={14} />}
|
||||
testId="alert-columns-button"
|
||||
testId={testId}
|
||||
>
|
||||
Columns
|
||||
</Button>
|
||||
@@ -77,4 +75,8 @@ function ColumnSelector<TData>({
|
||||
);
|
||||
}
|
||||
|
||||
ColumnSelector.defaultProps = {
|
||||
testId: 'column-selector-button',
|
||||
};
|
||||
|
||||
export default ColumnSelector;
|
||||
@@ -2,6 +2,7 @@ import { TanStackHoverTooltip } from './TanStackHoverTooltip';
|
||||
import { TanStackTableBase } from './TanStackTable';
|
||||
import TanStackTableText from './TanStackTableText';
|
||||
|
||||
export { default as ColumnSelector } from './ColumnSelector';
|
||||
export * from './TanStackHoverTooltip';
|
||||
export * from './TanStackTableStateContext';
|
||||
export * from './types';
|
||||
|
||||
@@ -101,6 +101,16 @@ const SPECS: Record<string, Record<string, unknown>> = {
|
||||
metadata: { team: 'ops' },
|
||||
sendResolved: true,
|
||||
},
|
||||
telegram: {
|
||||
apiUrl: 'https://api.telegram.org',
|
||||
botToken: '123456789:AA-token',
|
||||
chatId: -1001234567890,
|
||||
messageThreadId: 42,
|
||||
title: 't',
|
||||
message: 'm',
|
||||
disableNotifications: true,
|
||||
sendResolved: true,
|
||||
},
|
||||
};
|
||||
|
||||
describe('load then save round trip', () => {
|
||||
|
||||
@@ -22,13 +22,26 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Laid out like the alert pages: discard on the left, test and save together on
|
||||
the right. Sticky rather than fixed, so it stays inside the page's own scroll
|
||||
instead of adding another offset over the bottom strip. */
|
||||
.actions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-4) 0;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
border-top: 1px solid var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.actionGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
// TODO: @AshwinBhatkal to be removed once textarea is in signoz ui components
|
||||
@@ -36,3 +49,21 @@
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* The @signozhq inputs leave their surface transparent and take the card's
|
||||
colour. antd paints its textareas instead, so beside them they read as a
|
||||
different control, most visibly in light mode. */
|
||||
// TODO: @AshwinBhatkal to be removed once textarea is in signoz ui components
|
||||
.fields :global(textarea.ant-input),
|
||||
.fields :global(textarea.ant-input:hover),
|
||||
.fields :global(textarea.ant-input:focus),
|
||||
.fields :global(textarea.ant-input-focused) {
|
||||
background: var(--input-background, transparent);
|
||||
border-width: var(--input-border-width, 1px);
|
||||
border-style: var(--input-border-style, solid);
|
||||
border-color: var(--input-border-color, var(--border));
|
||||
border-radius: var(--input-border-radius, calc(var(--radius) - 2px));
|
||||
font-size: var(--input-font-size, var(--periscope-font-size-base, 13px));
|
||||
box-shadow: var(--input-box-shadow, none);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
margin-bottom: var(--spacing-4);
|
||||
gap: var(--spacing-3);
|
||||
margin-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
.label {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { Minus, Plus } from '@signozhq/icons';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input as AntdInput } from 'antd';
|
||||
@@ -10,6 +10,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import FormCard from '../FormCard';
|
||||
import FormField from '../FormField';
|
||||
import RowEntry from './RowEntry';
|
||||
import styles from './RowEditor.module.scss';
|
||||
|
||||
const { TextArea } = AntdInput;
|
||||
@@ -139,10 +140,19 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
|
||||
help={t('help_incidentio_metadata')}
|
||||
>
|
||||
{metadataRows.map((row, index) => (
|
||||
// rows have no stable identity beyond their position
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={index} className={styles.row}>
|
||||
<div className={styles.field}>
|
||||
<RowEntry
|
||||
// rows have no stable identity beyond their position
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
index={index}
|
||||
noun={t('entry_incidentio_metadata')}
|
||||
removeLabel={t('button_incidentio_remove_metadata')}
|
||||
removeTestId={`incidentio-metadata-remove-${index}`}
|
||||
onRemove={(): void =>
|
||||
syncMetadata(metadataRows.filter((_, i) => i !== index))
|
||||
}
|
||||
>
|
||||
<FormField label={t('label_incidentio_metadata_key')}>
|
||||
<Input
|
||||
placeholder={t('placeholder_incidentio_metadata_key')}
|
||||
value={row.key}
|
||||
@@ -155,8 +165,9 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
|
||||
}
|
||||
data-testid={`incidentio-metadata-key-${index}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('label_incidentio_metadata_value')}>
|
||||
<Input
|
||||
placeholder={t('placeholder_incidentio_metadata_value')}
|
||||
value={row.value}
|
||||
@@ -169,22 +180,10 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
|
||||
}
|
||||
data-testid={`incidentio-metadata-value-${index}`}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={t('button_incidentio_remove_metadata')}
|
||||
onClick={(): void =>
|
||||
syncMetadata(metadataRows.filter((_, i) => i !== index))
|
||||
}
|
||||
testId={`incidentio-metadata-remove-${index}`}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</FormField>
|
||||
</RowEntry>
|
||||
))}
|
||||
|
||||
<div className={styles.add}>
|
||||
<Button
|
||||
variant="dashed"
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/* One height for every control a repeating row renders, so the remove button,
|
||||
the add button and the inputs beside them agree. */
|
||||
.entry,
|
||||
.add {
|
||||
--row-control-height: 28px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -23,7 +30,9 @@
|
||||
}
|
||||
|
||||
.add {
|
||||
margin-top: var(--spacing-1);
|
||||
/* Sits with the blocks it adds to, not against the next field's label. */
|
||||
margin-top: var(--spacing-2);
|
||||
--button-height: var(--row-control-height);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@@ -35,3 +44,52 @@
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* One entry per block, every input labelled. A row of bare boxes gave no way to
|
||||
tell which column was which once a value was typed. The block keeps a readable
|
||||
width while the rest of the form fills the page, so its two columns do not
|
||||
stretch into a row of six. */
|
||||
.entry {
|
||||
max-width: 760px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--l2-background);
|
||||
padding: var(--spacing-6) var(--spacing-7);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.entryTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-4);
|
||||
|
||||
/* The remove control is an icon button, so give it the height the design
|
||||
system gives a small control rather than letting the glyph set it. */
|
||||
--button-height: var(--row-control-height);
|
||||
--button-icon-size: 14px;
|
||||
}
|
||||
|
||||
.entryIndex {
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.entryGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 0 var(--spacing-6);
|
||||
}
|
||||
|
||||
/* The toggle reads as a sentence, so it takes the whole width rather than
|
||||
sitting in a column sized for an input. */
|
||||
.entryWide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.entryGrid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Minus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './RowEditor.module.scss';
|
||||
|
||||
interface RowEntryProps {
|
||||
/** Zero-based; shown to the reader as a count from one. */
|
||||
index: number;
|
||||
/** Singular noun for one entry, such as "Field" or "Action". */
|
||||
noun: string;
|
||||
removeLabel: string;
|
||||
removeTestId: string;
|
||||
onRemove: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function RowEntry({
|
||||
index,
|
||||
noun,
|
||||
removeLabel,
|
||||
removeTestId,
|
||||
onRemove,
|
||||
children,
|
||||
}: RowEntryProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.entry}>
|
||||
<div className={styles.entryTop}>
|
||||
<Typography.Text size="sm" className={styles.entryIndex}>
|
||||
{noun} {index + 1}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={removeLabel}
|
||||
onClick={onRemove}
|
||||
testId={removeTestId}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.entryGrid}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RowEntry;
|
||||
@@ -42,7 +42,9 @@ function Slack(): JSX.Element {
|
||||
>
|
||||
<Input {...register('channel')} data-testid="slack-channel-textbox" />
|
||||
</FormField>
|
||||
</FormCard>
|
||||
|
||||
<FormCard title={t('section_message')}>
|
||||
<FormField label={t('field_slack_title')} error={errors.title?.message}>
|
||||
<Controller
|
||||
control={control}
|
||||
@@ -82,7 +84,12 @@ function Slack(): JSX.Element {
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormCard>
|
||||
|
||||
<FormCard
|
||||
title={t('section_advanced')}
|
||||
description={t('description_slack_advanced')}
|
||||
>
|
||||
<FormField
|
||||
label={t('field_slack_color')}
|
||||
help={t('help_slack_color')}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
import { Minus, Plus } from '@signozhq/icons';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AlertmanagertypesChannelSlackActionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import FormField from '../FormField';
|
||||
import RowEntry from './RowEntry';
|
||||
import styles from './RowEditor.module.scss';
|
||||
|
||||
/** `confirm` is an object on the DTO, so its one editable field is reached by path. */
|
||||
@@ -29,49 +29,37 @@ function SlackActions(): JSX.Element {
|
||||
const { control, register } = useFormContext<ChannelSpecFormValues>();
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'actions' });
|
||||
|
||||
const labelFor = (column: ActionColumn): string =>
|
||||
t(`label_slack_action_${column.replace('.text', '')}`);
|
||||
const keyFor = (column: ActionColumn): string => column.replace('.text', '');
|
||||
|
||||
return (
|
||||
<FormField label={t('field_slack_actions')} help={t('help_slack_actions')}>
|
||||
<FormField
|
||||
label={t('field_slack_actions')}
|
||||
tooltip={t('tooltip_slack_actions')}
|
||||
>
|
||||
<div>
|
||||
{fields.length > 0 && (
|
||||
<div className={styles.row}>
|
||||
{COLUMNS.map((column) => (
|
||||
<div key={column} className={styles.field}>
|
||||
<Typography.Text size="sm" className={styles.heading}>
|
||||
{labelFor(column)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fields.map((row, index) => (
|
||||
<div key={row.id} className={styles.row}>
|
||||
<RowEntry
|
||||
key={row.id}
|
||||
index={index}
|
||||
noun={t('entry_slack_action')}
|
||||
removeLabel={t('remove_slack_action')}
|
||||
removeTestId={`slack-action-remove-${index}`}
|
||||
onRemove={(): void => remove(index)}
|
||||
>
|
||||
{COLUMNS.map((column) => (
|
||||
<div key={column} className={styles.field}>
|
||||
<FormField
|
||||
key={column}
|
||||
label={t(`label_slack_action_${keyFor(column)}`)}
|
||||
tooltip={t(`tooltip_slack_action_${keyFor(column)}`)}
|
||||
>
|
||||
<Input
|
||||
{...register(`actions.${index}.${column}` as const)}
|
||||
placeholder={t(
|
||||
`placeholder_slack_action_${column.replace('.text', '')}`,
|
||||
)}
|
||||
testId={`slack-action-${column.replace('.text', '')}-${index}`}
|
||||
placeholder={t(`placeholder_slack_action_${keyFor(column)}`)}
|
||||
testId={`slack-action-${keyFor(column)}-${index}`}
|
||||
/>
|
||||
</div>
|
||||
</FormField>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={t('remove_slack_action')}
|
||||
onClick={(): void => remove(index)}
|
||||
testId={`slack-action-remove-${index}`}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</RowEntry>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
|
||||
import { Minus, Plus } from '@signozhq/icons';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
@@ -8,6 +8,7 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
|
||||
import FormField from '../FormField';
|
||||
import RowEntry from './RowEntry';
|
||||
import styles from './RowEditor.module.scss';
|
||||
|
||||
// Slack renders these as the attachment's table of short or full-width entries.
|
||||
@@ -17,52 +18,61 @@ function SlackFields(): JSX.Element {
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'fields' });
|
||||
|
||||
return (
|
||||
<FormField label={t('field_slack_fields')} help={t('help_slack_fields')}>
|
||||
<FormField
|
||||
label={t('field_slack_fields')}
|
||||
tooltip={t('tooltip_slack_fields')}
|
||||
>
|
||||
<div>
|
||||
{fields.map((row, index) => (
|
||||
<div key={row.id} className={styles.row}>
|
||||
<div className={styles.field}>
|
||||
<RowEntry
|
||||
key={row.id}
|
||||
index={index}
|
||||
noun={t('entry_slack_field')}
|
||||
removeLabel={t('remove_slack_field')}
|
||||
removeTestId={`slack-field-remove-${index}`}
|
||||
onRemove={(): void => remove(index)}
|
||||
>
|
||||
<FormField
|
||||
label={t('label_slack_field_title')}
|
||||
tooltip={t('tooltip_slack_field_title')}
|
||||
>
|
||||
<Input
|
||||
{...register(`fields.${index}.title`)}
|
||||
placeholder={t('placeholder_slack_field_title')}
|
||||
testId={`slack-field-title-${index}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t('label_slack_field_value')}
|
||||
tooltip={t('tooltip_slack_field_value')}
|
||||
>
|
||||
<Input
|
||||
{...register(`fields.${index}.value`)}
|
||||
placeholder={t('placeholder_slack_field_value')}
|
||||
testId={`slack-field-value-${index}`}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className={styles.entryWide}>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`fields.${index}.short`}
|
||||
render={({ field }): JSX.Element => (
|
||||
<Checkbox
|
||||
color="primary"
|
||||
value={!!field.value}
|
||||
onChange={(checked): void => field.onChange(checked === true)}
|
||||
testId={`slack-field-short-${index}`}
|
||||
>
|
||||
<Typography.Text size="sm">
|
||||
{t('field_slack_field_short')}
|
||||
</Typography.Text>
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`fields.${index}.short`}
|
||||
render={({ field }): JSX.Element => (
|
||||
<Checkbox
|
||||
color="primary"
|
||||
value={!!field.value}
|
||||
onChange={(checked): void => field.onChange(checked === true)}
|
||||
testId={`slack-field-short-${index}`}
|
||||
>
|
||||
<Typography.Text size="sm">
|
||||
{t('field_slack_field_short')}
|
||||
</Typography.Text>
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={t('remove_slack_field')}
|
||||
onClick={(): void => remove(index)}
|
||||
testId={`slack-field-remove-${index}`}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</RowEntry>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ describe('FormAlertChannels', () => {
|
||||
onTypeChangeHandler={jest.fn()}
|
||||
onSaveHandler={jest.fn()}
|
||||
onTestHandler={jest.fn()}
|
||||
onDiscardHandler={jest.fn()}
|
||||
testingState={false}
|
||||
savingState={false}
|
||||
editing
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Controller, FormProvider, UseFormReturn } from 'react-hook-form';
|
||||
import { X } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
@@ -48,10 +49,12 @@ function FormAlertChannels({
|
||||
onTypeChangeHandler,
|
||||
onTestHandler,
|
||||
onSaveHandler,
|
||||
onDiscardHandler,
|
||||
savingState,
|
||||
testingState,
|
||||
editing = false,
|
||||
readOnly = false,
|
||||
hasChanges = true,
|
||||
saveChecks = [],
|
||||
testChecks = [],
|
||||
}: FormAlertChannelsProps): JSX.Element {
|
||||
@@ -115,7 +118,10 @@ function FormAlertChannels({
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('field_send_resolved')}>
|
||||
<FormField
|
||||
label={t('field_send_resolved')}
|
||||
tooltip={t('tooltip_send_resolved')}
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="sendResolved"
|
||||
@@ -131,7 +137,11 @@ function FormAlertChannels({
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('field_channel_type')} htmlFor="channel-type">
|
||||
<FormField
|
||||
label={t('field_channel_type')}
|
||||
tooltip={t('tooltip_channel_type')}
|
||||
htmlFor="channel-type"
|
||||
>
|
||||
<SelectSimple
|
||||
id="channel-type"
|
||||
items={CHANNEL_TYPE_OPTIONS}
|
||||
@@ -148,30 +158,45 @@ function FormAlertChannels({
|
||||
</fieldset>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<AuthZTooltip checks={saveChecks}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
testId="save-channel-button"
|
||||
loading={savingState}
|
||||
onClick={onSaveHandler}
|
||||
>
|
||||
{t('button_save_channel')}
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<AuthZTooltip checks={testChecks}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
testId="test-channel-button"
|
||||
loading={testingState}
|
||||
onClick={onTestHandler}
|
||||
>
|
||||
{t('button_test_channel')}
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
testId="discard-channel-button"
|
||||
prefix={<X size={14} />}
|
||||
onClick={onDiscardHandler}
|
||||
>
|
||||
{t('button_discard_channel')}
|
||||
</Button>
|
||||
|
||||
<div className={styles.actionGroup}>
|
||||
<AuthZTooltip checks={testChecks}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
testId="test-channel-button"
|
||||
loading={testingState}
|
||||
onClick={onTestHandler}
|
||||
>
|
||||
{t('button_test_channel')}
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<AuthZTooltip checks={saveChecks}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
testId="save-channel-button"
|
||||
loading={savingState}
|
||||
disabled={!hasChanges}
|
||||
disabledTooltip={t('tooltip_save_no_changes')}
|
||||
onClick={onSaveHandler}
|
||||
>
|
||||
{t('button_save_channel')}
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
@@ -184,12 +209,15 @@ interface FormAlertChannelsProps {
|
||||
onTypeChangeHandler: (value: ChannelKind) => void;
|
||||
onSaveHandler: () => void;
|
||||
onTestHandler: () => void;
|
||||
onDiscardHandler: () => void;
|
||||
testingState: boolean;
|
||||
savingState: boolean;
|
||||
// editing indicates if the form is opened in edit mode
|
||||
editing?: boolean;
|
||||
/** The reader has `read` but not `update`, so the fields render disabled. */
|
||||
readOnly?: boolean;
|
||||
/** Nothing edited yet, so there is nothing to save and nothing to discard. */
|
||||
hasChanges?: boolean;
|
||||
/** Gates saving; a denial disables the button and names the missing scope. */
|
||||
saveChecks?: BrandedPermission[];
|
||||
/** Gates a test send, which the API allows with `create`. */
|
||||
@@ -199,6 +227,7 @@ interface FormAlertChannelsProps {
|
||||
FormAlertChannels.defaultProps = {
|
||||
editing: undefined,
|
||||
readOnly: false,
|
||||
hasChanges: true,
|
||||
saveChecks: [],
|
||||
testChecks: [],
|
||||
};
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export { default as ActionsMenu } from './ActionsMenu';
|
||||
export { default as ColumnSelector } from './ColumnSelector';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
|
||||
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import TanStackTable, { ColumnSelector } from 'components/TanStackTableView';
|
||||
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
|
||||
import { useTableParams } from 'components/TanStackTableView/useTableParams';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
@@ -15,7 +15,7 @@ import { useTimezone } from 'providers/Timezone';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
|
||||
import { AlertsEmptyState } from './AlertsEmptyState/AlertsEmptyState';
|
||||
import { ActionsMenu, ColumnSelector } from './components';
|
||||
import { ActionsMenu } from './components';
|
||||
import { ALERT_RULES_PARAMS, useAlertRulesFilters } from './hooks';
|
||||
import styles from './ListAlertRules.module.scss';
|
||||
import { getAlertRuleColumns } from './table.config';
|
||||
@@ -134,7 +134,11 @@ function ListAlertRules(): JSX.Element {
|
||||
{!isEmptyNoRules && (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.refreshRow}>
|
||||
<ColumnSelector columns={columns} storageKey="alert-rules-columns" />
|
||||
<ColumnSelector
|
||||
columns={columns}
|
||||
storageKey="alert-rules-columns"
|
||||
testId="alert-columns-button"
|
||||
/>
|
||||
{addNewAlert && (
|
||||
<Button
|
||||
variant="solid"
|
||||
|
||||
@@ -22,12 +22,17 @@ export function defineJsonEditorTheme(monaco: Monaco, el: HTMLElement): void {
|
||||
const keyColor = token(el, '--bg-vanilla-400');
|
||||
const valueColor = token(el, '--bg-robin-400');
|
||||
|
||||
// Both token colours are picked to read on a dark surface, so on a light one
|
||||
// they wash out, the keys worst of all. Monaco's own `vs` palette is built
|
||||
// for that surface, so let it keep its colours there, as the role viewer does.
|
||||
const rules: { token: string; foreground: string }[] = [];
|
||||
if (keyColor) {
|
||||
rules.push({ token: 'string.key.json', foreground: keyColor });
|
||||
}
|
||||
if (valueColor) {
|
||||
rules.push({ token: 'string.value.json', foreground: valueColor });
|
||||
if (isDark(background)) {
|
||||
if (keyColor) {
|
||||
rules.push({ token: 'string.key.json', foreground: keyColor });
|
||||
}
|
||||
if (valueColor) {
|
||||
rules.push({ token: 'string.value.json', foreground: valueColor });
|
||||
}
|
||||
}
|
||||
|
||||
const colors: Record<string, string> = {};
|
||||
|
||||
@@ -58,6 +58,12 @@ export const Default: Story = {};
|
||||
/** A workspace with nowhere to send an alert yet. */
|
||||
export const NoChannels: Story = {
|
||||
args: { channels: 0 },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByTestId('channels-empty-state', undefined, UNTIL_LOADED);
|
||||
await canvas.findByTestId('channels-empty-create');
|
||||
},
|
||||
};
|
||||
|
||||
/** Data: the channel list's existing loading spinner. */
|
||||
@@ -105,10 +111,24 @@ export const KindFilter: Story = {
|
||||
UNTIL_LOADED,
|
||||
);
|
||||
await canvas.findByText('sre-email');
|
||||
// the heading and the create button both name the filtered type
|
||||
await canvas.findByText('All Email channels');
|
||||
},
|
||||
};
|
||||
|
||||
/** Interaction: a search matching nothing offers to clear itself. */
|
||||
/** Interaction: the column menu, which turns table columns off and on. */
|
||||
export const ColumnSelector: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
|
||||
await userEvent.click(canvas.getByTestId('channels-columns'));
|
||||
|
||||
await screen.findByText('Toggle Columns');
|
||||
},
|
||||
};
|
||||
|
||||
/** Interaction: a search that matches no channel. */
|
||||
export const NoResults: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -120,6 +140,21 @@ export const NoResults: Story = {
|
||||
);
|
||||
|
||||
await canvas.findByText('No matching channels', undefined, UNTIL_LOADED);
|
||||
},
|
||||
};
|
||||
|
||||
/** Interaction: clearing the search from the empty state brings the list back. */
|
||||
export const ClearSearch: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
|
||||
await userEvent.type(
|
||||
canvas.getByTestId('channels-search'),
|
||||
'no-such-channel',
|
||||
);
|
||||
await canvas.findByText('No matching channels', undefined, UNTIL_LOADED);
|
||||
|
||||
await userEvent.click(canvas.getByRole('button', { name: 'Clear Search' }));
|
||||
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
|
||||
},
|
||||
|
||||
@@ -90,13 +90,16 @@ export const SaveFailure: Story = {
|
||||
// the point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId(
|
||||
'save-channel-button',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// Save is off until the form is edited, so the failure is reached through
|
||||
// a real change rather than an empty submit.
|
||||
await userEvent.type(
|
||||
await canvas.findByTestId('slack-channel-textbox', undefined, untilLoaded),
|
||||
'-edited',
|
||||
);
|
||||
await userEvent.click(canvas.getByTestId('save-channel-button'));
|
||||
|
||||
await screen.findByText('Storybook forced channel failure');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -82,6 +82,12 @@ export const NoCreate: Story = {
|
||||
await expectDisabled(
|
||||
await canvas.findByTestId('test-channel-button', undefined, PAGE_LOAD),
|
||||
);
|
||||
// Save is also off until the form is edited, so the edit comes first and
|
||||
// what is left is the permission.
|
||||
await userEvent.type(
|
||||
await canvas.findByTestId('slack-channel-textbox', undefined, PAGE_LOAD),
|
||||
'-edited',
|
||||
);
|
||||
await expectDisabled(
|
||||
await canvas.findByTestId('save-channel-button', undefined, PAGE_LOAD),
|
||||
false,
|
||||
@@ -168,12 +174,13 @@ export const CheckFailed: Story = {
|
||||
// point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const save = await within(canvasElement).findByTestId(
|
||||
'save-channel-button',
|
||||
undefined,
|
||||
PAGE_LOAD,
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.type(
|
||||
await canvas.findByTestId('slack-channel-textbox', undefined, PAGE_LOAD),
|
||||
'-edited',
|
||||
);
|
||||
|
||||
await expectDisabled(save, false);
|
||||
await expectDisabled(canvas.getByTestId('save-channel-button'), false);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
} from '../../stories/__story_mockdata__/alerts';
|
||||
|
||||
const ACTIONS = 'Channel · actions';
|
||||
const ROUTE = 'Channel · route';
|
||||
|
||||
/** The `?kind=` values worth reaching: a known type, and one the form rejects. */
|
||||
const URL_KINDS = ['none', 'jira', 'not-a-kind'] as const;
|
||||
type UrlKind = (typeof URL_KINDS)[number];
|
||||
|
||||
const resolveChannelCreated: MockResolver = (_req, res, ctx) =>
|
||||
res(ctx.status(201), ctx.json({ status: 'success', data: null }));
|
||||
@@ -29,7 +34,8 @@ const rejectChannelAction: MockResolver = (_req, res, ctx) =>
|
||||
/**
|
||||
* The form holds the channel type in component state, so the type itself is
|
||||
* stories with a `play` that picks one rather than a control; saving and
|
||||
* testing the channel are.
|
||||
* testing the channel are. The type can also arrive in the URL, which the list
|
||||
* uses to carry its filter over, so that is a control on the route.
|
||||
*/
|
||||
export const channelsNewMocks = defineStoryMocks({
|
||||
controls: {
|
||||
@@ -43,6 +49,11 @@ export const channelsNewMocks = defineStoryMocks({
|
||||
options: CHANNEL_ACTION_OUTCOMES,
|
||||
value: 'succeeds',
|
||||
}),
|
||||
kindInUrl: choiceControl<UrlKind>('Channel type in the URL', {
|
||||
group: ROUTE,
|
||||
options: URL_KINDS,
|
||||
value: 'none',
|
||||
}),
|
||||
},
|
||||
handlers: (values) => [
|
||||
rest.post(
|
||||
@@ -55,5 +66,10 @@ export const channelsNewMocks = defineStoryMocks({
|
||||
values.testOutcome === 'fails' ? rejectChannelAction : resolveChannelTested,
|
||||
),
|
||||
],
|
||||
config: () => ({ route: '/alerts/channels/new' }),
|
||||
config: (values) => ({
|
||||
route:
|
||||
values.kindInUrl === 'none'
|
||||
? '/alerts/channels/new'
|
||||
: `/alerts/channels/new?kind=${values.kindInUrl}`,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -58,9 +58,16 @@ export const ChannelTypeSelectOpen: Story = {
|
||||
/** Mutation: saving without a channel name shows the form's required-field feedback. */
|
||||
export const InvalidRequiredFields: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
await userEvent.click(
|
||||
await within(canvasElement).findByTestId('save-channel-button'),
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// Save stays off until the form is touched, so filling the webhook first is
|
||||
// how a missing name is reached.
|
||||
await userEvent.type(
|
||||
await canvas.findByTestId('webhook-url-textbox'),
|
||||
'https://hooks.slack.com/services/T/B/X',
|
||||
);
|
||||
await userEvent.click(canvas.getByTestId('save-channel-button'));
|
||||
|
||||
await screen.findByText('Channel name is mandatory');
|
||||
},
|
||||
};
|
||||
@@ -231,3 +238,38 @@ export const RequiredFields: Story = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The type carried in the URL. Filtering the list by a type and pressing its
|
||||
* create button lands here with `?kind=`, so the form opens on that type with
|
||||
* nothing to pick.
|
||||
*/
|
||||
export const KindFromUrl: Story = {
|
||||
args: { kindInUrl: 'jira' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const trigger = await within(canvasElement).findByTestId(
|
||||
'channel-type-select',
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await within(trigger).findByText(/^Jira$/);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A `?kind=` the app does not have. The form opens on its own default rather
|
||||
* than on an empty type, so a stale or hand-edited link still works.
|
||||
*/
|
||||
export const UnknownKindInUrl: Story = {
|
||||
args: { kindInUrl: 'not-a-kind' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const trigger = await within(canvasElement).findByTestId(
|
||||
'channel-type-select',
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await within(trigger).findByText(/^Slack$/);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import { expect, userEvent, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -81,12 +81,13 @@ export const CheckFailed: Story = {
|
||||
// point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const save = await within(canvasElement).findByTestId(
|
||||
'save-channel-button',
|
||||
undefined,
|
||||
PAGE_LOAD,
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.type(
|
||||
await canvas.findByTestId('channel-name-textbox', undefined, PAGE_LOAD),
|
||||
'check-failed',
|
||||
);
|
||||
|
||||
await expectDisabled(save, false);
|
||||
await expectDisabled(canvas.getByTestId('save-channel-button'), false);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,13 +11,12 @@
|
||||
--select-trigger-height: var(--control-height);
|
||||
--input-height: var(--control-height);
|
||||
--input-wrapper-height: var(--control-height);
|
||||
--button-height: var(--control-height);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-6);
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
padding: 0 var(--spacing-8) var(--spacing-6) var(--spacing-8);
|
||||
}
|
||||
|
||||
/* Title and count on the left, the create action at the far end, the way the
|
||||
@@ -25,7 +24,7 @@
|
||||
.commandHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.headingBlock {
|
||||
|
||||
@@ -7,10 +7,11 @@ import { Skeleton } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
|
||||
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import TanStackTable, { ColumnSelector } from 'components/TanStackTableView';
|
||||
import { useCalculatedPageSize } from 'components/TanStackTableView/useCalculatedPageSize';
|
||||
import { useTableParams } from 'components/TanStackTableView/useTableParams';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { ChannelKind } from 'container/CreateAlertChannels/types';
|
||||
import { useUrlSearchState } from 'hooks/useUrlSearchState';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { useNotificationChannelCollectionPermissions } from 'hooks/notificationChannels/useNotificationChannelCollectionPermissions';
|
||||
@@ -25,7 +26,9 @@ import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import ChannelActionsMenu from './components/ChannelActionsMenu/ChannelActionsMenu';
|
||||
import ChannelJsonDialog from './components/ChannelJsonDialog/ChannelJsonDialog';
|
||||
import ChannelsEmptyState from './components/ChannelsEmptyState/ChannelsEmptyState';
|
||||
import {
|
||||
CHANNEL_KIND_LABEL,
|
||||
KIND_FILTER_ALL,
|
||||
KIND_FILTER_OPTIONS,
|
||||
KIND_KEY,
|
||||
@@ -156,6 +159,10 @@ function NotificationChannels(): JSX.Element {
|
||||
);
|
||||
|
||||
const hasActiveFilters = searchText.length > 0 || kind !== KIND_FILTER_ALL;
|
||||
// With a kind picked, creating one of that kind is the likely next step, so
|
||||
// the button offers it rather than making the type be chosen again.
|
||||
const filteredKind =
|
||||
kind !== KIND_FILTER_ALL ? (kind as ChannelKind) : undefined;
|
||||
const isEmpty = !isFetching && channels.length === 0;
|
||||
|
||||
const handleClearFilters = useCallback((): void => {
|
||||
@@ -169,27 +176,38 @@ function NotificationChannels(): JSX.Element {
|
||||
<div className={styles.commandHeader}>
|
||||
<div className={styles.headingBlock}>
|
||||
<Typography.Title className={styles.title}>
|
||||
All notification channels
|
||||
{filteredKind
|
||||
? `All ${CHANNEL_KIND_LABEL[filteredKind]} channels`
|
||||
: 'All notification channels'}
|
||||
</Typography.Title>
|
||||
<span className={styles.countPill}>{total}</span>
|
||||
</div>
|
||||
<div className={styles.grow} />
|
||||
{!isEmpty && (
|
||||
<ColumnSelector
|
||||
columns={columnsWithActions}
|
||||
storageKey="notification-channels-columns"
|
||||
testId="channels-columns"
|
||||
/>
|
||||
)}
|
||||
<AuthZButton
|
||||
checks={CREATE_CHECKS}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void => formView.openCreate(filteredKind)}
|
||||
testId="channels-create"
|
||||
>
|
||||
{filteredKind
|
||||
? `New ${CHANNEL_KIND_LABEL[filteredKind]} Channel`
|
||||
: 'New Channel'}
|
||||
</AuthZButton>
|
||||
<TextToolTip
|
||||
text="More details on how to configure notification channels"
|
||||
url="https://signoz.io/docs/setup-alerts-notification/"
|
||||
urlText="Learn More"
|
||||
/>
|
||||
<AuthZButton
|
||||
checks={CREATE_CHECKS}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={formView.openCreate}
|
||||
testId="channels-create"
|
||||
>
|
||||
New channel
|
||||
</AuthZButton>
|
||||
</div>
|
||||
|
||||
<div className={styles.filtersRow}>
|
||||
@@ -215,12 +233,8 @@ function NotificationChannels(): JSX.Element {
|
||||
</header>
|
||||
|
||||
<div ref={containerRef} className={styles.tableContainer}>
|
||||
{/* Fails closed, unlike the guard's default: the fetch is gated on the
|
||||
same check, so falling open would render an empty table shell with
|
||||
no reason given. The header and filters stay, as on dashboards. */}
|
||||
<AuthZGuardContent
|
||||
checks={LIST_CHECKS}
|
||||
onFailRenderContent={false}
|
||||
fallback={({ deniedPermissions }): JSX.Element => (
|
||||
<PermissionDeniedCallout
|
||||
deniedPermissions={deniedPermissions}
|
||||
@@ -245,6 +259,10 @@ function NotificationChannels(): JSX.Element {
|
||||
onClear={handleClearFilters}
|
||||
clearButtonText="Clear Search"
|
||||
/>
|
||||
) : isEmpty ? (
|
||||
<ChannelsEmptyState
|
||||
onCreate={(): void => formView.openCreate(filteredKind)}
|
||||
/>
|
||||
) : (
|
||||
<TanStackTable<Channel>
|
||||
data={channels}
|
||||
|
||||
@@ -72,6 +72,52 @@ describe('ChannelForm save', () => {
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'sends no telegram message, so the backend keeps its own template',
|
||||
async () => {
|
||||
let body: Record<string, unknown> | undefined;
|
||||
server.use(
|
||||
rest.post(
|
||||
'http://localhost/api/v2/notification_channels',
|
||||
async (req, res, ctx) => {
|
||||
body = await req.json();
|
||||
return res(ctx.status(201), ctx.json({ status: 'success', data: {} }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<ChannelForm onDone={jest.fn()} />);
|
||||
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
await user.click(await screen.findByTestId('channel-type-select', {}, FIND));
|
||||
await user.click(await screen.findByRole('option', { name: /^Telegram$/ }));
|
||||
|
||||
await user.type(
|
||||
screen.getByTestId('channel-name-textbox'),
|
||||
'telegram alerts',
|
||||
);
|
||||
await user.type(screen.getByTestId('telegram-bot-token-textbox'), '123:abc');
|
||||
await user.type(screen.getByTestId('telegram-chat-id-textbox'), '-100123');
|
||||
|
||||
// the box is empty on screen, and an empty box must not become an empty
|
||||
// string in the request: that would overwrite the backend's default
|
||||
expect(screen.getByTestId('telegram-message-textarea')).toHaveValue('');
|
||||
|
||||
const save = screen.getByTestId('save-channel-button');
|
||||
await waitFor(
|
||||
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
|
||||
FIND,
|
||||
);
|
||||
await user.click(save);
|
||||
|
||||
await waitFor(() => expect(body).toBeDefined(), FIND);
|
||||
const spec = (body as { config: { spec: Record<string, unknown> } }).config
|
||||
.spec;
|
||||
expect(spec).not.toHaveProperty('message');
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'updates with the config only, since both names are immutable',
|
||||
async () => {
|
||||
@@ -162,15 +208,20 @@ describe('ChannelForm save', () => {
|
||||
|
||||
render(<ChannelForm channelId="3" onDone={jest.fn()} />);
|
||||
|
||||
// Save is disabled while the checks are in flight and settles once they
|
||||
// resolve, so waiting on it means the failure has landed
|
||||
const save = await screen.findByTestId('save-channel-button', {}, FIND);
|
||||
const channel = await screen.findByTestId('slack-channel-textbox', {}, FIND);
|
||||
await waitFor(() => expect(channel).toBeEnabled(), FIND);
|
||||
|
||||
// Save is off until something changes, so one edit is what tells a
|
||||
// settled failure apart from a denial
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
await user.type(channel, '-edited');
|
||||
|
||||
const save = screen.getByTestId('save-channel-button');
|
||||
await waitFor(
|
||||
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
|
||||
FIND,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('slack-channel-textbox')).toBeEnabled();
|
||||
expect(
|
||||
screen.queryByTestId('channel-read-only-notice'),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -178,6 +229,69 @@ describe('ChannelForm save', () => {
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'keeps save off until something is edited',
|
||||
async () => {
|
||||
render(<ChannelForm channelId="3" onDone={jest.fn()} />);
|
||||
|
||||
// Save is also off while the permission checks are in flight. Test is
|
||||
// gated on those checks alone, so it settling means Save is only off
|
||||
// because nothing has changed.
|
||||
const testButton = await screen.findByTestId(
|
||||
'test-channel-button',
|
||||
{},
|
||||
FIND,
|
||||
);
|
||||
await waitFor(
|
||||
() => expect(testButton).toHaveAttribute('aria-disabled', 'false'),
|
||||
FIND,
|
||||
);
|
||||
|
||||
const save = screen.getByTestId('save-channel-button');
|
||||
expect(save).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
await user.type(screen.getByTestId('slack-channel-textbox'), '-edited');
|
||||
|
||||
await waitFor(
|
||||
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
|
||||
FIND,
|
||||
);
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'asks before discarding an edited form, and leaves straight away otherwise',
|
||||
async () => {
|
||||
const onDone = jest.fn();
|
||||
render(<ChannelForm channelId="3" onDone={onDone} />);
|
||||
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
const discard = await screen.findByTestId(
|
||||
'discard-channel-button',
|
||||
{},
|
||||
FIND,
|
||||
);
|
||||
|
||||
await user.click(discard);
|
||||
expect(onDone).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.type(screen.getByTestId('slack-channel-textbox'), '-edited');
|
||||
await user.click(discard);
|
||||
|
||||
// the test harness stubs `t`, so the modal renders the keys themselves
|
||||
await screen.findByText('discard_changes_title', {}, FIND);
|
||||
expect(onDone).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'button_discard_confirm' }),
|
||||
);
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(2));
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'explains a channel the v2 API cannot model instead of showing a blank form',
|
||||
async () => {
|
||||
|
||||
@@ -6,16 +6,27 @@ import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/pe
|
||||
|
||||
import ChannelFormBody from './ChannelFormBody';
|
||||
import styles from './ChannelForm.module.scss';
|
||||
import { ChannelKind } from 'container/CreateAlertChannels/types';
|
||||
|
||||
import { useChannelDraft } from './useChannelDraft';
|
||||
|
||||
interface ChannelFormProps {
|
||||
channelId?: string;
|
||||
/** Preselects the type when creating, so a filtered list can offer its kind. */
|
||||
initialKind?: ChannelKind;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
function ChannelForm({ channelId, onDone }: ChannelFormProps): JSX.Element {
|
||||
function ChannelForm({
|
||||
channelId,
|
||||
initialKind,
|
||||
onDone,
|
||||
}: ChannelFormProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
const { draft, isLoading, loadError } = useChannelDraft(channelId);
|
||||
const { draft, isLoading, loadError } = useChannelDraft(
|
||||
channelId,
|
||||
initialKind,
|
||||
);
|
||||
|
||||
const {
|
||||
canEdit,
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useDeleteConfirm } from 'components/DeleteConfirmModal/useDeleteConfirm';
|
||||
import FormAlertChannels from 'container/FormAlertChannels';
|
||||
import { ChannelFormState } from 'container/CreateAlertChannels/channelFormValues';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
@@ -24,23 +27,54 @@ function ChannelFormBody({
|
||||
saveChecks,
|
||||
testChecks,
|
||||
}: ChannelFormBodyProps): JSX.Element {
|
||||
const { form, type, onTypeChange, onSave, onTest, isSaving, isTesting } =
|
||||
useChannelFormState({ draft, target, onDone });
|
||||
const { t } = useTranslation('channels');
|
||||
const { contextHolder, confirmDelete } = useDeleteConfirm();
|
||||
const {
|
||||
form,
|
||||
type,
|
||||
isDirty,
|
||||
onTypeChange,
|
||||
onSave,
|
||||
onTest,
|
||||
isSaving,
|
||||
isTesting,
|
||||
} = useChannelFormState({ draft, target, onDone });
|
||||
|
||||
const onDiscard = (): void => {
|
||||
if (!isDirty) {
|
||||
onDone();
|
||||
return;
|
||||
}
|
||||
|
||||
confirmDelete({
|
||||
title: (
|
||||
<Typography.Title level={5}>{t('discard_changes_title')}</Typography.Title>
|
||||
),
|
||||
content: t('discard_changes_body'),
|
||||
confirmText: t('button_discard_confirm'),
|
||||
onConfirm: onDone,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FormAlertChannels
|
||||
form={form}
|
||||
type={type}
|
||||
onTypeChangeHandler={onTypeChange}
|
||||
onTestHandler={onTest}
|
||||
onSaveHandler={onSave}
|
||||
savingState={isSaving}
|
||||
testingState={isTesting}
|
||||
editing={target.mode === 'edit'}
|
||||
readOnly={readOnly}
|
||||
saveChecks={saveChecks}
|
||||
testChecks={testChecks}
|
||||
/>
|
||||
<>
|
||||
<FormAlertChannels
|
||||
form={form}
|
||||
type={type}
|
||||
onTypeChangeHandler={onTypeChange}
|
||||
onTestHandler={onTest}
|
||||
onSaveHandler={onSave}
|
||||
onDiscardHandler={onDiscard}
|
||||
savingState={isSaving}
|
||||
testingState={isTesting}
|
||||
editing={target.mode === 'edit'}
|
||||
readOnly={readOnly}
|
||||
hasChanges={isDirty}
|
||||
saveChecks={saveChecks}
|
||||
testChecks={testChecks}
|
||||
/>
|
||||
{contextHolder}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ChannelInitialConfig,
|
||||
DEFAULT_CHANNEL_KIND,
|
||||
} from 'container/CreateAlertChannels/defaults';
|
||||
import { ChannelKind } from 'container/CreateAlertChannels/types';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
export interface ChannelDraftResult {
|
||||
@@ -22,7 +23,10 @@ export interface ChannelDraftResult {
|
||||
* Kept apart from the form so the form is built once its values are known,
|
||||
* rather than mounted empty and reset.
|
||||
*/
|
||||
export function useChannelDraft(channelId?: string): ChannelDraftResult {
|
||||
export function useChannelDraft(
|
||||
channelId?: string,
|
||||
initialKind?: ChannelKind,
|
||||
): ChannelDraftResult {
|
||||
const isEditing = !!channelId;
|
||||
|
||||
const { data, error: loadFailure } = useGetNotificationChannel(
|
||||
@@ -34,10 +38,10 @@ export function useChannelDraft(channelId?: string): ChannelDraftResult {
|
||||
channelId
|
||||
? null
|
||||
: {
|
||||
kind: DEFAULT_CHANNEL_KIND,
|
||||
kind: initialKind ?? DEFAULT_CHANNEL_KIND,
|
||||
values: {
|
||||
sendResolved: true,
|
||||
...ChannelInitialConfig[DEFAULT_CHANNEL_KIND],
|
||||
...ChannelInitialConfig[initialKind ?? DEFAULT_CHANNEL_KIND],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -31,6 +31,8 @@ import { toAPIError } from 'utils/errorUtils';
|
||||
export interface ChannelFormStateResult {
|
||||
form: UseFormReturn<ChannelFormValues>;
|
||||
type: ChannelKind;
|
||||
/** False until something is edited, which is what gates saving. */
|
||||
isDirty: boolean;
|
||||
onTypeChange: (value: string) => void;
|
||||
onSave: () => void;
|
||||
onTest: () => void;
|
||||
@@ -84,10 +86,13 @@ export function useChannelFormState({
|
||||
|
||||
setTypeState(nextType);
|
||||
|
||||
// shouldDirty, because the type lives outside the form: without it
|
||||
// picking a different type leaves the form pristine and unsavable.
|
||||
Object.entries(ChannelInitialConfig[nextType]).forEach(([field, value]) => {
|
||||
form.setValue(
|
||||
field as keyof ChannelFormValues,
|
||||
value as ChannelFormValues[keyof ChannelFormValues],
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
@@ -177,6 +182,7 @@ export function useChannelFormState({
|
||||
return {
|
||||
form,
|
||||
type,
|
||||
isDirty: form.formState.isDirty,
|
||||
onTypeChange,
|
||||
onSave: form.handleSubmit(submitSave),
|
||||
onTest: form.handleSubmit(submitTest),
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/* The same shape as the page's no-matches state, so the two empty screens read
|
||||
as the same product rather than two designs. */
|
||||
.emptyState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-3);
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
/* The button sizes to its label here, so "New channel" is never clipped. */
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2);
|
||||
margin-top: var(--spacing-1);
|
||||
}
|
||||
|
||||
.docsLink {
|
||||
color: var(--l2-foreground);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Bell, Plus } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
|
||||
|
||||
import styles from './ChannelsEmptyState.module.scss';
|
||||
|
||||
const CREATE_CHECKS = [NotificationChannelCreatePermission];
|
||||
const DOCS_URL = 'https://signoz.io/docs/setup-alerts-notification/';
|
||||
|
||||
interface ChannelsEmptyStateProps {
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
function ChannelsEmptyState({
|
||||
onCreate,
|
||||
}: ChannelsEmptyStateProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.emptyState} data-testid="channels-empty-state">
|
||||
<Bell className={styles.icon} size={16} />
|
||||
<Typography.Text className={styles.title}>
|
||||
No notification channels yet
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.subtitle}>
|
||||
A channel is where an alert ends up. Create one and your alert rules and
|
||||
routing policies can point at it.
|
||||
</Typography.Text>
|
||||
<div className={styles.actions}>
|
||||
<AuthZButton
|
||||
checks={CREATE_CHECKS}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={onCreate}
|
||||
testId="channels-empty-create"
|
||||
>
|
||||
New channel
|
||||
</AuthZButton>
|
||||
</div>
|
||||
<a
|
||||
className={styles.docsLink}
|
||||
href={DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="channels-empty-docs"
|
||||
>
|
||||
Read the setup guide
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelsEmptyState;
|
||||
@@ -4,7 +4,7 @@ import history from 'lib/history';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
export interface ChannelFormView {
|
||||
openCreate: () => void;
|
||||
openCreate: (kind?: string) => void;
|
||||
openEdit: (channelId: string, options?: { newTab?: boolean }) => void;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ export function useChannelFormView(): ChannelFormView {
|
||||
};
|
||||
|
||||
return {
|
||||
openCreate: (): void => history.push(ROUTES.CHANNELS_NEW),
|
||||
openCreate: (kind?: string): void =>
|
||||
history.push(
|
||||
kind ? `${ROUTES.CHANNELS_NEW}?kind=${kind}` : ROUTES.CHANNELS_NEW,
|
||||
),
|
||||
openEdit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -791,6 +791,22 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
|
||||
channel: '#ops-alerts',
|
||||
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}',
|
||||
text: '{{ range .Alerts -}}*Alert:* {{ .Labels.alertname }}\n{{ end }}',
|
||||
// one of each so the repeating row editors render with content
|
||||
fields: [
|
||||
{ title: 'Severity', value: '{{ .CommonLabels.severity }}', short: true },
|
||||
{
|
||||
title: 'Service',
|
||||
value: '{{ .CommonLabels.service_name }}',
|
||||
short: false,
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
type: 'button',
|
||||
text: 'Runbook',
|
||||
url: 'https://runbook.example.com/{{ .CommonLabels.alertname }}',
|
||||
},
|
||||
],
|
||||
sendResolved: true,
|
||||
},
|
||||
webhook: {
|
||||
|
||||
@@ -2,12 +2,6 @@
|
||||
padding: var(--spacing-6);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 var(--spacing-6) var(--spacing-6);
|
||||
}
|
||||
|
||||
.crumbName {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
.page {
|
||||
padding: var(--spacing-4) var(--spacing-6) var(--spacing-6);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Spinner } from '@signozhq/ui/spinner';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useGetNotificationChannel } from 'api/generated/services/channels';
|
||||
import AlertBreadcrumb from 'components/AlertBreadcrumb';
|
||||
import BackHeader from 'components/BackHeader/BackHeader';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import ChannelForm from 'pages/AlertList/NotificationChannels/components/ChannelForm/ChannelForm';
|
||||
@@ -13,42 +14,45 @@ interface ChannelsEditPageProps {
|
||||
}
|
||||
|
||||
function ChannelsEditPage({ channelId }: ChannelsEditPageProps): JSX.Element {
|
||||
const { data } = useGetNotificationChannel({ id: channelId });
|
||||
const { data, isLoading } = useGetNotificationChannel({ id: channelId });
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertBreadcrumb
|
||||
items={[
|
||||
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
|
||||
{
|
||||
title: (
|
||||
<span className={styles.crumbName}>
|
||||
<span className="translate-safe">
|
||||
{data?.data?.displayName || 'Edit Channel'}
|
||||
</span>
|
||||
<CopyButton
|
||||
value={channelId}
|
||||
ariaLabel="Copy channel ID"
|
||||
testId="copy-channel-id"
|
||||
onCopy={(): void => {
|
||||
toast.success('Channel ID copied to clipboard');
|
||||
}}
|
||||
/>
|
||||
<div className={styles.page}>
|
||||
<BackHeader
|
||||
backLabel="All channels"
|
||||
backRoute={ROUTES.ALL_CHANNELS}
|
||||
testId="channel-edit-header"
|
||||
title={
|
||||
isLoading ? (
|
||||
<Spinner
|
||||
size={16}
|
||||
aria-label="Loading channel"
|
||||
testId="channel-name-loading"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="translate-safe">
|
||||
{data?.data?.displayName || 'Edit Channel'}
|
||||
</span>
|
||||
),
|
||||
isLast: true,
|
||||
},
|
||||
]}
|
||||
<CopyButton
|
||||
value={channelId}
|
||||
ariaLabel="Copy channel ID"
|
||||
testId="copy-channel-id"
|
||||
onCopy={(): void => {
|
||||
toast.success('Channel ID copied to clipboard');
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
<ChannelForm
|
||||
channelId={channelId}
|
||||
onDone={(): void => {
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<ChannelForm
|
||||
channelId={channelId}
|
||||
onDone={(): void => {
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,46 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Skeleton } from 'antd';
|
||||
import AlertBreadcrumb from 'components/AlertBreadcrumb';
|
||||
import BackHeader from 'components/BackHeader/BackHeader';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import { withAuthZPage } from 'lib/authz/components/withAuthZ/withAuthZPage';
|
||||
import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
|
||||
import { ChannelKind } from 'container/CreateAlertChannels/types';
|
||||
import ChannelForm from 'pages/AlertList/NotificationChannels/components/ChannelForm/ChannelForm';
|
||||
import { KIND_KEY } from 'pages/AlertList/NotificationChannels/constants';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
function ChannelsNew(): JSX.Element {
|
||||
const requested = new URLSearchParams(useLocation().search).get('kind');
|
||||
// an unknown kind in the URL falls back to the form's own default
|
||||
const initialKind = (Object.values(ChannelKind) as string[]).includes(
|
||||
requested ?? '',
|
||||
)
|
||||
? (requested as ChannelKind)
|
||||
: undefined;
|
||||
|
||||
// Leaving goes back to the list the way it was left: filtered by the kind
|
||||
// that opened this form.
|
||||
const listRoute = initialKind
|
||||
? `${ROUTES.ALL_CHANNELS}&${KIND_KEY}=${initialKind}`
|
||||
: ROUTES.ALL_CHANNELS;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertBreadcrumb
|
||||
items={[
|
||||
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
|
||||
{ title: 'New channel', isLast: true },
|
||||
]}
|
||||
<div className={styles.page}>
|
||||
<BackHeader
|
||||
backLabel="All channels"
|
||||
backRoute={listRoute}
|
||||
title="New channel"
|
||||
testId="channel-new-header"
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
<ChannelForm
|
||||
onDone={(): void => {
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<ChannelForm
|
||||
initialKind={initialKind}
|
||||
onDone={(): void => {
|
||||
history.replace(listRoute);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
padding: var(--spacing-6);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0 var(--spacing-6) var(--spacing-6);
|
||||
.page {
|
||||
padding: var(--spacing-4) var(--spacing-6) var(--spacing-6);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import cx from 'classnames';
|
||||
|
||||
import styles from './CopyButton.module.scss';
|
||||
import { useCopyButton } from './useCopyButton';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
|
||||
export interface CopyButtonProps {
|
||||
/** Text written to the clipboard on click. */
|
||||
@@ -16,6 +17,8 @@ export interface CopyButtonProps {
|
||||
testId?: string;
|
||||
/** Called after the copy is triggered (e.g. to show a toast). */
|
||||
onCopy?: () => void;
|
||||
/** Side on which the tooltip should appear. Default "right". */
|
||||
tooltipSide?: 'top' | 'right' | 'bottom' | 'left';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,6 +32,7 @@ function CopyButton({
|
||||
ariaLabel = 'Copy',
|
||||
testId,
|
||||
onCopy,
|
||||
tooltipSide = 'top',
|
||||
}: CopyButtonProps): JSX.Element {
|
||||
const { copyToClipboard, isCopied } = useCopyButton();
|
||||
|
||||
@@ -44,20 +48,27 @@ function CopyButton({
|
||||
const stackStyle: CSSProperties = { width: size, height: size };
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
onClick={handleClick}
|
||||
aria-label={isCopied ? 'Copied' : ariaLabel}
|
||||
testId={testId}
|
||||
>
|
||||
<span className={styles.iconStack} style={stackStyle} data-copied={isCopied}>
|
||||
<Copy size={size} className={cx(styles.icon, styles.copyIcon)} />
|
||||
<Check size={size} className={cx(styles.icon, styles.checkIcon)} />
|
||||
</span>
|
||||
</Button>
|
||||
// TODO: remove wrapper once Button supports tooltip
|
||||
<Tooltip title={isCopied ? 'Copied' : ariaLabel} side={tooltipSide}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
onClick={handleClick}
|
||||
aria-label={isCopied ? 'Copied' : ariaLabel}
|
||||
testId={testId}
|
||||
>
|
||||
<span
|
||||
className={styles.iconStack}
|
||||
style={stackStyle}
|
||||
data-copied={isCopied}
|
||||
>
|
||||
<Copy size={size} className={cx(styles.icon, styles.copyIcon)} />
|
||||
<Check size={size} className={cx(styles.icon, styles.checkIcon)} />
|
||||
</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
369
tests/e2e/drivers/channels.ts
generated
Normal file
369
tests/e2e/drivers/channels.ts
generated
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { authToken } from '../helpers/common';
|
||||
|
||||
import { driver, kit, seeded } from './kit';
|
||||
|
||||
// Search and the type filter are both served by the API, so every assertion on
|
||||
// the list is also a guard on the query the list sends.
|
||||
|
||||
export const PATH = {
|
||||
list: '/alerts?tab=Channels',
|
||||
new: '/alerts/channels/new',
|
||||
/** Matches any channel's edit route. */
|
||||
edit: /\/alerts\/channels\/edit\//,
|
||||
} as const;
|
||||
|
||||
const CHANNELS_API = '/api/v2/notification_channels';
|
||||
|
||||
/**
|
||||
* One entry per channel type the form offers. `fields` carries only what the
|
||||
* validator refuses to save without, and `spec` is the same channel as the API
|
||||
* takes it, for specs that seed rather than type.
|
||||
*/
|
||||
export const CHANNEL_TYPES: ChannelType[] = [
|
||||
{
|
||||
label: 'Slack',
|
||||
kind: 'slack',
|
||||
fields: [['webhook-url-textbox', 'https://hooks.slack.com/services/T/B/X']],
|
||||
edit: ['webhook-url-textbox', 'https://hooks.slack.com/services/T/B/edited'],
|
||||
spec: { apiUrl: 'https://hooks.slack.com/services/T/B/X' },
|
||||
},
|
||||
{
|
||||
label: 'Webhook',
|
||||
kind: 'webhook',
|
||||
fields: [['webhook-url-textbox', 'https://example.test/e2e-hook']],
|
||||
edit: ['webhook-url-textbox', 'https://example.test/e2e-hook-edited'],
|
||||
spec: { url: 'https://example.test/e2e-hook' },
|
||||
},
|
||||
{
|
||||
label: 'Pagerduty',
|
||||
kind: 'pagerduty',
|
||||
fields: [['pager-routing-key-textbox', 'e2e-routing-key']],
|
||||
edit: ['pager-routing-key-textbox', 'e2e-routing-key-edited'],
|
||||
spec: { routingKey: 'e2e-routing-key' },
|
||||
},
|
||||
{
|
||||
label: 'Opsgenie',
|
||||
kind: 'opsgenie',
|
||||
fields: [['opsgenie-api-key-textbox', 'e2e-api-key']],
|
||||
edit: ['opsgenie-api-key-textbox', 'e2e-api-key-edited'],
|
||||
spec: { apiKey: 'e2e-api-key' },
|
||||
},
|
||||
{
|
||||
label: 'Email',
|
||||
kind: 'email',
|
||||
fields: [['email-to-textbox', 'e2e@signoz.test']],
|
||||
edit: ['email-to-textbox', 'edited@signoz.test'],
|
||||
spec: { to: 'e2e@signoz.test' },
|
||||
},
|
||||
{
|
||||
label: 'Microsoft Teams',
|
||||
kind: 'msteams',
|
||||
fields: [
|
||||
['webhook-url-textbox', 'https://outlook.office.com/webhook/e2e-teams'],
|
||||
],
|
||||
edit: [
|
||||
'webhook-url-textbox',
|
||||
'https://outlook.office.com/webhook/e2e-teams-edited',
|
||||
],
|
||||
spec: { webhookUrl: 'https://outlook.office.com/webhook/e2e-teams' },
|
||||
},
|
||||
{
|
||||
label: 'Google Chat',
|
||||
kind: 'googlechat',
|
||||
// the validator accepts https on chat.googleapis.com and nothing else
|
||||
fields: [
|
||||
[
|
||||
'webhook-url-textbox',
|
||||
'https://chat.googleapis.com/v1/spaces/AAA/messages?key=k&token=t',
|
||||
],
|
||||
],
|
||||
edit: [
|
||||
'webhook-url-textbox',
|
||||
'https://chat.googleapis.com/v1/spaces/AAA/messages?key=k&token=edited',
|
||||
],
|
||||
spec: {
|
||||
webhookUrl:
|
||||
'https://chat.googleapis.com/v1/spaces/AAA/messages?key=k&token=t',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Jira',
|
||||
kind: 'jira',
|
||||
// the site has to be an https atlassian.net host
|
||||
fields: [
|
||||
['jira-site-textbox', 'https://e2e.atlassian.net'],
|
||||
['jira-email-textbox', 'e2e@signoz.test'],
|
||||
['jira-api-token-textbox', 'e2e-api-token'],
|
||||
['jira-project-textbox', 'E2E'],
|
||||
['jira-issue-type-textbox', 'Task'],
|
||||
],
|
||||
edit: ['jira-project-textbox', 'E2EEDIT'],
|
||||
spec: {
|
||||
site: 'https://e2e.atlassian.net',
|
||||
email: 'e2e@signoz.test',
|
||||
apiToken: 'e2e-api-token',
|
||||
project: 'E2E',
|
||||
issueType: 'Task',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Jira Service Management Ops',
|
||||
kind: 'jsmops',
|
||||
fields: [['jsmops-api-key-textbox', 'e2e-api-key']],
|
||||
edit: ['jsmops-api-key-textbox', 'e2e-api-key-edited'],
|
||||
spec: { apiKey: 'e2e-api-key' },
|
||||
},
|
||||
{
|
||||
label: 'incident.io',
|
||||
kind: 'incidentio',
|
||||
// the path has to carry an id after /v2/alert_events/http/
|
||||
fields: [
|
||||
[
|
||||
'incidentio-url-textbox',
|
||||
'https://api.incident.io/v2/alert_events/http/e2e-source',
|
||||
],
|
||||
['incidentio-token-textbox', 'e2e-token'],
|
||||
],
|
||||
edit: ['incidentio-token-textbox', 'e2e-token-edited'],
|
||||
spec: {
|
||||
url: 'https://api.incident.io/v2/alert_events/http/e2e-source',
|
||||
token: 'e2e-token',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Telegram',
|
||||
kind: 'telegram',
|
||||
// the API rejects a chat id of 0, so this one is deliberately non-zero
|
||||
fields: [
|
||||
['telegram-bot-token-textbox', '123456:e2e-bot-token'],
|
||||
['telegram-chat-id-textbox', '-1001234567890'],
|
||||
],
|
||||
edit: ['telegram-bot-token-textbox', '123456:e2e-bot-token-edited'],
|
||||
spec: { botToken: '123456:e2e-bot-token', chatId: -1001234567890 },
|
||||
},
|
||||
];
|
||||
|
||||
export interface SeededChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Everything a channel spec needs: the form labels, and the API equivalent. */
|
||||
export interface ChannelType {
|
||||
label: string;
|
||||
kind: string;
|
||||
/** Only what the form refuses to save without, in the order it is filled. */
|
||||
fields: [string, string][];
|
||||
/**
|
||||
* The field an edit pass changes, and the value to change it to. Spelled out
|
||||
* per type rather than derived: appending to a Jira site or a Google Chat
|
||||
* webhook breaks the format its validator demands, and the save then never
|
||||
* leaves the page.
|
||||
*/
|
||||
edit: [string, string];
|
||||
spec: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Channels {
|
||||
list: ChannelList;
|
||||
form: ChannelForm;
|
||||
api: ChannelApi;
|
||||
}
|
||||
|
||||
interface ChannelApi {
|
||||
create(
|
||||
name: string,
|
||||
kind?: string,
|
||||
spec?: Record<string, unknown>,
|
||||
): Promise<SeededChannel>;
|
||||
remove(id: string | undefined): Promise<void>;
|
||||
}
|
||||
|
||||
interface ChannelList {
|
||||
goto(options?: { search?: string; kind?: string }): Promise<void>;
|
||||
search(): Locator;
|
||||
rows(): Locator;
|
||||
row(name: string): Locator;
|
||||
openRowMenu(name: string): Promise<void>;
|
||||
chooseMenuItem(name: string): Promise<void>;
|
||||
jsonDialog(): Locator;
|
||||
noResults(): Locator;
|
||||
deletePrompt(name: string): Locator;
|
||||
confirmDelete(): Promise<void>;
|
||||
clearSearch(): Promise<void>;
|
||||
}
|
||||
|
||||
interface ChannelForm {
|
||||
gotoNew(): Promise<void>;
|
||||
name(): Locator;
|
||||
pickType(label: string): Promise<void>;
|
||||
fillField(testId: string, value: string): Promise<void>;
|
||||
/** Saves and returns the id the API assigned, so the test can clean up. */
|
||||
saveAndReturnId(): Promise<string>;
|
||||
/** Saves an existing channel and waits for the write to land. */
|
||||
saveEdit(): Promise<void>;
|
||||
save(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds a channel through the v2 API. The spec only has to be valid enough for
|
||||
* the API to store it: nothing is ever delivered.
|
||||
*/
|
||||
async function createViaApi(
|
||||
page: Page,
|
||||
name: string,
|
||||
kind = 'slack',
|
||||
spec: Record<string, unknown> = {
|
||||
apiUrl: 'https://hooks.slack.com/services/T/B/X',
|
||||
channel: '#e2e',
|
||||
},
|
||||
): Promise<SeededChannel> {
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.post(CHANNELS_API, {
|
||||
data: { generateName: true, displayName: name, config: { kind, spec } },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`POST ${CHANNELS_API} ${res.status()}: ${await res.text()}`);
|
||||
}
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
return { id: String(json.data.id), name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tolerates an empty id and an already-deleted channel, so a cleanup block
|
||||
* never has to branch on whether the seed got that far.
|
||||
*/
|
||||
async function removeViaApi(page: Page, id: string | undefined): Promise<void> {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A test that already failed often leaves a closed page behind, and a throw
|
||||
// here would replace its error with one about the cleanup.
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
await page.request.delete(`${CHANNELS_API}/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch {
|
||||
/* the channel outlives the run rather than hiding why it failed */
|
||||
}
|
||||
}
|
||||
|
||||
function channelsDriver(page: Page): Channels {
|
||||
const rows = (): Locator => page.locator('tbody tr');
|
||||
|
||||
const list: ChannelList = {
|
||||
async goto({ search, kind } = {}) {
|
||||
const url = new URL(PATH.list, 'http://localhost');
|
||||
if (search) {
|
||||
url.searchParams.set('search', search);
|
||||
}
|
||||
if (kind) {
|
||||
url.searchParams.set('kind', kind);
|
||||
}
|
||||
|
||||
await page.goto(`${url.pathname}${url.search}`);
|
||||
await expect(list.search()).toBeVisible();
|
||||
},
|
||||
search: () => page.getByTestId('channels-search'),
|
||||
rows,
|
||||
row: (name) => rows().filter({ hasText: name }),
|
||||
async openRowMenu(name) {
|
||||
await page.getByRole('button', { name: `Actions for ${name}` }).click();
|
||||
await expect(page.getByRole('menu')).toBeVisible();
|
||||
},
|
||||
async chooseMenuItem(name) {
|
||||
await page.getByRole('menuitem', { name }).click();
|
||||
},
|
||||
jsonDialog: () => page.getByTestId('channel-json-dialog'),
|
||||
noResults: () => page.getByText('No matching channels'),
|
||||
deletePrompt: (name) =>
|
||||
page.getByText(`Are you sure you want to delete the ${name} channel?`),
|
||||
async confirmDelete() {
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().includes(CHANNELS_API) && r.request().method() === 'DELETE',
|
||||
),
|
||||
page.getByRole('button', { name: 'Delete', exact: true }).click(),
|
||||
]);
|
||||
},
|
||||
async clearSearch() {
|
||||
await page.getByRole('button', { name: 'Clear Search' }).click();
|
||||
await expect(list.search()).toHaveValue('');
|
||||
},
|
||||
};
|
||||
|
||||
const form: ChannelForm = {
|
||||
async gotoNew() {
|
||||
await page.goto(PATH.new);
|
||||
await expect(form.name()).toBeVisible();
|
||||
},
|
||||
name: () => page.getByTestId('channel-name-textbox'),
|
||||
async pickType(label) {
|
||||
await page.getByTestId('channel-type-select').click();
|
||||
await page.getByRole('option', { name: label, exact: true }).click();
|
||||
},
|
||||
async fillField(testId, value) {
|
||||
await page.getByTestId(testId).fill(value);
|
||||
},
|
||||
async saveAndReturnId() {
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().includes(CHANNELS_API) && r.request().method() === 'POST',
|
||||
),
|
||||
page.getByTestId('save-channel-button').click(),
|
||||
]);
|
||||
return ((await response.json()) as { data: { id: string } }).data.id;
|
||||
},
|
||||
async saveEdit() {
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().includes(CHANNELS_API) && r.request().method() === 'PUT',
|
||||
),
|
||||
page.getByTestId('save-channel-button').click(),
|
||||
]);
|
||||
},
|
||||
async save() {
|
||||
await page.getByTestId('save-channel-button').click();
|
||||
},
|
||||
};
|
||||
|
||||
const api: ChannelApi = {
|
||||
create: (name, kind, spec) => createViaApi(page, name, kind, spec),
|
||||
remove: (id) => removeViaApi(page, id),
|
||||
};
|
||||
|
||||
return { list, form, api };
|
||||
}
|
||||
|
||||
const withChannels = driver('channels', ({ page }) => channelsDriver(page));
|
||||
|
||||
// One slack and one email channel sharing a prefix, so a search can hold both
|
||||
// and the type filter has something to tell apart.
|
||||
const withChannelSet = seeded(
|
||||
'channelSet',
|
||||
async ({ page, workerIndex }) => {
|
||||
const prefix = `e2e-nc-${workerIndex}-${Date.now()}`;
|
||||
const slack = await createViaApi(page, `${prefix}-slack`);
|
||||
const email = await createViaApi(page, `${prefix}-email`, 'email', {
|
||||
to: 'e2e@signoz.test',
|
||||
});
|
||||
return { prefix, slack, email };
|
||||
},
|
||||
async ({ page, value }) => {
|
||||
await removeViaApi(page, value.slack.id);
|
||||
await removeViaApi(page, value.email.id);
|
||||
},
|
||||
);
|
||||
|
||||
export const test = withChannelSet(withChannels(kit));
|
||||
|
||||
export { expect, onAdminPage } from './kit';
|
||||
36
tests/e2e/tests/alerts/channels/create-each-type.spec.ts
Normal file
36
tests/e2e/tests/alerts/channels/create-each-type.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { CHANNEL_TYPES, expect, test } from '../../../drivers/channels';
|
||||
|
||||
// One pass per channel type, filling only what the form refuses to save
|
||||
// without. The point is that every type can be created at all: a type whose
|
||||
// settings component or validator drifts fails here rather than in support.
|
||||
test.describe('Notification channels — create one of each type', () => {
|
||||
CHANNEL_TYPES.forEach(({ label, fields }, index) => {
|
||||
const number = String(index + 1).padStart(2, '0');
|
||||
|
||||
test(`TC-${number} a ${label} channel saves and shows up in the list`, async ({
|
||||
channels,
|
||||
}) => {
|
||||
const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
const name = `e2e-nct-${slug}-${Date.now()}`;
|
||||
let id: string | undefined;
|
||||
|
||||
try {
|
||||
await channels.form.gotoNew();
|
||||
await channels.form.name().fill(name);
|
||||
await channels.form.pickType(label);
|
||||
|
||||
for (const [testId, value] of fields) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await channels.form.fillField(testId, value);
|
||||
}
|
||||
|
||||
id = await channels.form.saveAndReturnId();
|
||||
|
||||
await channels.list.goto({ search: name });
|
||||
await expect(channels.list.row(name)).toBeVisible();
|
||||
} finally {
|
||||
await channels.api.remove(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
43
tests/e2e/tests/alerts/channels/create.spec.ts
Normal file
43
tests/e2e/tests/alerts/channels/create.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { expect, test } from '../../../drivers/channels';
|
||||
|
||||
test.describe('Notification channels — create', () => {
|
||||
test('TC-01 a webhook channel saves and shows up in the list', async ({
|
||||
channels,
|
||||
}) => {
|
||||
const name = `e2e-ncc-${Date.now()}`;
|
||||
let id: string | undefined;
|
||||
|
||||
try {
|
||||
await channels.form.gotoNew();
|
||||
await channels.form.name().fill(name);
|
||||
await channels.form.pickType('Webhook');
|
||||
await channels.form.fillField(
|
||||
'webhook-url-textbox',
|
||||
'https://example.test/e2e-hook',
|
||||
);
|
||||
|
||||
id = await channels.form.saveAndReturnId();
|
||||
|
||||
await channels.list.goto({ search: name });
|
||||
await expect(channels.list.row(name)).toBeVisible();
|
||||
} finally {
|
||||
await channels.api.remove(id);
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-02 saving without a name reports the missing field', async ({
|
||||
channels,
|
||||
authedPage,
|
||||
}) => {
|
||||
await channels.form.gotoNew();
|
||||
// Save stays off until the form is touched, so the webhook goes in first and
|
||||
// the name is what is left missing.
|
||||
await channels.form.fillField(
|
||||
'webhook-url-textbox',
|
||||
'https://hooks.slack.com/services/T/B/X',
|
||||
);
|
||||
await channels.form.save();
|
||||
|
||||
await expect(authedPage.getByText('Channel name is mandatory')).toBeVisible();
|
||||
});
|
||||
});
|
||||
24
tests/e2e/tests/alerts/channels/delete.spec.ts
Normal file
24
tests/e2e/tests/alerts/channels/delete.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { expect, test } from '../../../drivers/channels';
|
||||
|
||||
test.describe('Notification channels — delete', () => {
|
||||
test('TC-01 deleting from the row menu takes the channel out of the list', async ({
|
||||
channels,
|
||||
}) => {
|
||||
const name = `e2e-ncd-${Date.now()}`;
|
||||
const { id } = await channels.api.create(name);
|
||||
|
||||
try {
|
||||
await channels.list.goto({ search: name });
|
||||
await channels.list.openRowMenu(name);
|
||||
await channels.list.chooseMenuItem('Delete');
|
||||
|
||||
await expect(channels.list.deletePrompt(name)).toBeVisible();
|
||||
await channels.list.confirmDelete();
|
||||
|
||||
await expect(channels.list.row(name)).toHaveCount(0);
|
||||
} finally {
|
||||
// the test deletes it; this only catches a run that stopped earlier
|
||||
await channels.api.remove(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
39
tests/e2e/tests/alerts/channels/edit-each-type.spec.ts
Normal file
39
tests/e2e/tests/alerts/channels/edit-each-type.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { CHANNEL_TYPES, expect, test } from '../../../drivers/channels';
|
||||
|
||||
// The mirror of the create pass: seed a channel of each type through the API,
|
||||
// open it, change the field its table entry nominates and check the change
|
||||
// survives a reload. A prefill or a save that drops a type's fields fails here.
|
||||
test.describe('Notification channels — edit one of each type', () => {
|
||||
CHANNEL_TYPES.forEach(({ label, kind, fields, edit, spec }, index) => {
|
||||
const number = String(index + 1).padStart(2, '0');
|
||||
const [testId, edited] = edit;
|
||||
// every entry nominates one of its own required fields, so a miss here
|
||||
// fails the assertion below rather than skipping it
|
||||
const original = fields.find(([id]) => id === testId)?.[1] ?? '';
|
||||
|
||||
test(`TC-${number} an edited ${label} channel keeps its change`, async ({
|
||||
channels,
|
||||
authedPage,
|
||||
}) => {
|
||||
const slug = label.toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
const name = `e2e-nte-${slug}-${Date.now()}`;
|
||||
let id: string | undefined;
|
||||
|
||||
try {
|
||||
({ id } = await channels.api.create(name, kind, spec));
|
||||
|
||||
await authedPage.goto(`/alerts/channels/edit/${id}`);
|
||||
const field = authedPage.getByTestId(testId);
|
||||
await expect(field).toHaveValue(original);
|
||||
|
||||
await field.fill(edited);
|
||||
await channels.form.saveEdit();
|
||||
|
||||
await authedPage.goto(`/alerts/channels/edit/${id}`);
|
||||
await expect(authedPage.getByTestId(testId)).toHaveValue(edited);
|
||||
} finally {
|
||||
await channels.api.remove(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
63
tests/e2e/tests/alerts/channels/list.spec.ts
Normal file
63
tests/e2e/tests/alerts/channels/list.spec.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { expect, test } from '../../../drivers/channels';
|
||||
|
||||
test.describe('Notification channels — list', () => {
|
||||
test('TC-01 search narrows the list to the matching channel', async ({
|
||||
channels,
|
||||
channelSet,
|
||||
}) => {
|
||||
await channels.list.goto({ search: channelSet.prefix });
|
||||
await expect(channels.list.rows()).toHaveCount(2);
|
||||
|
||||
await channels.list.search().fill(channelSet.slack.name);
|
||||
await expect(channels.list.row(channelSet.slack.name)).toBeVisible();
|
||||
await expect(channels.list.row(channelSet.email.name)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-02 the kind filter keeps only channels of that kind', async ({
|
||||
channels,
|
||||
channelSet,
|
||||
}) => {
|
||||
await channels.list.goto({ search: channelSet.prefix, kind: 'email' });
|
||||
|
||||
await expect(channels.list.row(channelSet.email.name)).toBeVisible();
|
||||
await expect(channels.list.row(channelSet.slack.name)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-03 a search matching nothing offers to clear itself', async ({
|
||||
channels,
|
||||
channelSet,
|
||||
}) => {
|
||||
await channels.list.goto({ search: `${channelSet.prefix}-nothing-matches` });
|
||||
|
||||
await expect(channels.list.noResults()).toBeVisible();
|
||||
await channels.list.clearSearch();
|
||||
});
|
||||
|
||||
test('TC-04 clicking a row opens that channel for editing', async ({
|
||||
channels,
|
||||
channelSet,
|
||||
authedPage,
|
||||
}) => {
|
||||
await channels.list.goto({ search: channelSet.slack.name });
|
||||
await channels.list.row(channelSet.slack.name).click();
|
||||
|
||||
await authedPage.waitForURL(/\/alerts\/channels\/edit\//);
|
||||
await expect(channels.form.name()).toHaveValue(channelSet.slack.name);
|
||||
});
|
||||
|
||||
test('TC-05 View JSON shows the channel with its secrets masked', async ({
|
||||
channels,
|
||||
channelSet,
|
||||
authedPage,
|
||||
}) => {
|
||||
await channels.list.goto({ search: channelSet.slack.name });
|
||||
await channels.list.openRowMenu(channelSet.slack.name);
|
||||
await channels.list.chooseMenuItem('View JSON');
|
||||
|
||||
await expect(channels.list.jsonDialog()).toBeVisible();
|
||||
await expect(channels.list.jsonDialog()).toContainText('"kind": "slack"');
|
||||
await expect(
|
||||
authedPage.getByTestId('channel-json-toggle-secrets'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user