Compare commits

...

7 Commits

Author SHA1 Message Date
Ashwin Bhatkal
9fc6fbb9fa test(e2e): cover the notification channel list, create and delete flows (#13216)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

End-to-end cover for the rebuilt notification channel screens.
`edit.spec.ts` came with the v2 move; this adds the flows around it.

- **list** — search narrows the table, the type filter keeps only that
type, a search that matches nothing offers to clear itself, clicking a
row opens the channel, and View JSON masks the secrets.
- **create** — a webhook channel saves and appears in the list, and
saving without a name reports the missing field.
- **delete** — removing from the row menu takes the channel out of the
list.
- **one of each type** — eleven create specs and eleven edit specs, one
per channel type. Create fills only what the validator refuses to save
without and checks the row appears; edit seeds the channel through the
API, changes its first required field and checks the change survives a
reload. A type whose settings component or validator drifts fails here
rather than in support.

Everything the specs need lives in `drivers/channels.ts`, following
`drivers/routing.ts`: a `channels` driver for the list, the form and the
API, a worker-scoped `channelSet` that seeds one Slack and one email
channel sharing a prefix, and `CHANNEL_TYPES`, the one table both
per-type specs read so a new channel type is added in a single place.
The specs touch `page` only where they wait on a URL.

The type fixtures respect the real validators rather than guessing:
Google Chat needs https on `chat.googleapis.com`, Jira an https
`.atlassian.net` host, incident.io an id after `/v2/alert_events/http/`,
and Telegram a non-zero chat id.
2026-10-21 19:57:19 +00:00
Ashwin Bhatkal
93d054ee02 fix(notification-channels): apply the review follow-ups on the channel screens (#13214)
#### Description

From the review of SigNoz/signoz#12976 (channel list and form):

- Add the missing spacing and tooltip - fixed. Tooltip has not been a
common pattern for this though. We should make changes once the button
component supports it
- Add a discard button to the form - fixed
- Lay out the form's action buttons the way the alert pages do - fixed
- Fix light mode, which still looks off on these screens. - fixed
- Fix the text that is not visible in light mode. - fixed
- When the list is filtered by a kind, such as Jira or Google Chat, show
a button that creates a channel of that kind - fixed.
- Fix the field styling, which still looks off - fixed, updated design
- Give the TextArea the same background as the other inputs in light
mode. It differs because it comes from antd. - fixed background and
borders as well.
- Make the delete button the same height as the input next to it -
updated design, fixed
- allow search term api filtering in notification channel search api -
fixed
- show no data state on load aside from the empty state with filters
applied - fixed, an empty workspace gets its own screen with a create
action
- alerts list icon should be same size as the new channel button -
flipped and fixed
- the help icon should be to the right of the new channel button instead
of the left - fixed

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/598


#### Screenshots / Screen Recordings

Every story under `Pages/Alerts/Channels`, shot in dark and light before
and after: **https://claude.ai/artifact/6FQ2pawzEhK5hZhWsG1tB2**

130 pairs, 112 changed, 6 new screens. The page groups them, so you can
open just the form screens, just the list, or just the Slack sections.

One caveat on that page: it was captured before the global button font
rule was reverted, so it counts more changed screens than the branch now
has. The groups and what each one shows are still accurate.
2026-10-21 19:57:18 +00:00
Ashwin Bhatkal
fcb685bf37 feat(notification-channels): add the telegram channel form (#13119)
#### Description

Gives Telegram a form, now that the v2 API models the kind. Last in the
stack, so it is written against the v2 schemas rather than the v1 form
it would have had to use underneath.

- `Settings/Telegram.tsx`: bot token, the numeric chat id, an optional
forum topic id, the message template, an optional self-hosted Bot API
URL, and a send-silently toggle.
- Telegram joins the channel type select and `renderSettings()`.
- Stories for the new and edit screens, a prefill case covering every
field, and a round-trip case so a saved channel comes back unchanged.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/583

#### Screenshots / Screen Recordings

[Screen diff against `main`, dark and
light](https://claude.ai/artifact/SeVW4fTm88oSCJpiCq2UM8)

Four new screens: the Telegram form, editing one, saving with the
required fields empty, and one with update denied. The other **115
screens are pixel for pixel the same** as on main, in both themes. The
single remaining row is a focus outline left by a story's own click, 95
pixels, checked by cropping both shots.

#### Additional Information

`chatId` is an int64 and `messageThreadId` an int, the first numeric
fields the channel form has had. An empty or half-typed box sends
nothing rather than `NaN`, which would serialise as `null` and be
rejected. The prefill test's field tuples widen to `string | number`
because a number input reports a number.

The message template is left out of the defaults on purpose: the API
fills `DefaultTelegramMessageTemplate` when the key is absent, and
sending an empty string would overwrite it.

The kind itself was taught to the list, the filter and the JSON view
(including masking the bot token) in the base PR, which is what the
exhaustive per-kind maps require once the schema carries `telegram`.
2026-10-21 17:38:49 +00:00
Vikrant Gupta
e69d3fcc8c fix(metricreduction): type the no-kept-labels preview count so the driver can scan it (#13221)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Previewing a rule that drops every non-protected label of a metric
returned 500 "failed to estimate reduction impact". With no kept labels
the cardinality query selects a bare `1`, which ClickHouse types as
`UInt8`, and the driver refuses to scan that into the `uint64` the code
reads it into.
- The literal is now `toUInt64(1)`, matching the scan target, so such
previews report one retained series as intended.
2026-10-21 12:33:51 +00:00
Naman Verma
354214ca87 chore: drop sloglint key-naming-case (#13220)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

Dotted keys like rule.id are already the convention across the codebase.
2026-10-21 12:04:26 +00:00
Vikrant Gupta
e3488c1592 feat(authz): enable FGA for metrics explorer routes (#13150)
#### Description

- Metrics explorer routes were still on the coarse `ViewAccess` gate, so
a custom role scoped to a workspace key on metrics could list, inspect
and read stats for every metric through them while being denied on
`query_range`.
- Stats, treemap, inspect, highlights and onboarding derive the resource
from the caller's `filter.expression` or resolve to the metrics
wildcard, exactly as `query_range` does. Key-scoped custom roles get 403
unless the filter pins a granted key.
- A new `metrics-field` metaresource covers the metrics list, the
metadata and attributes reads, and the metadata update, with wildcard
grants: admin and editor may update, every managed role may list and
read. Existing organizations get the tuples through a migration.
- The alerts and dashboards lookups read the rule and dashboard stores,
so they require `rule:list` and `dashboard:list`.
- The new `metricsexplorer` integration suite is added to the CI matrix.

#### Issues closed by this PR

Part of SigNoz/keystone-pod#141
2026-10-21 11:40:56 +00:00
Swapnil Nakade
574bfa62b8 feat: enabled logs and added collection strategy for gcp integration (#13184)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- with release of 0.163.0 of collector contrib, `googlecloudpubsubpush`
receiver is published
- enabling logs services in gcp integration for manual flow

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR
Contributes to https://github.com/SigNoz/keystone-pod/issues/164

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2026-10-21 11:40:25 +00:00
78 changed files with 2765 additions and 385 deletions

View File

@@ -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.

View File

@@ -75,6 +75,7 @@ jobs:
- ttl
- clickhousecluster
- metricreduction
- metricsexplorer
sqlstore-provider:
- postgres
- sqlite

View File

@@ -41,7 +41,6 @@ linters:
no-global: all
context: all
static-msg: true
key-naming-case: snake
exclusions:
generated: lax
presets:

View File

@@ -3581,6 +3581,7 @@ components:
- meter-metrics
- logs-field
- traces-field
- metrics-field
type: string
CoretypesObject:
properties:
@@ -20637,9 +20638,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics-field:list
- tokenizer:
- VIEWER
- metrics-field:list
summary: List metric names
tags:
- metrics
@@ -20706,9 +20707,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- rule:list
- tokenizer:
- VIEWER
- rule:list
summary: Get metric alerts
tags:
- metrics
@@ -20790,9 +20791,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics-field:read
- tokenizer:
- VIEWER
- metrics-field:read
summary: Get metric attributes
tags:
- metrics
@@ -20860,9 +20861,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Get metric highlights
tags:
- metrics
@@ -20919,9 +20920,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Inspect raw metric data points
tags:
- metrics
@@ -20989,9 +20990,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics-field:read
- tokenizer:
- VIEWER
- metrics-field:read
summary: Get metric metadata
tags:
- metrics
@@ -21035,9 +21036,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- metrics-field:update
- tokenizer:
- EDITOR
- metrics-field:update
summary: Update metric metadata
tags:
- metrics
@@ -21083,9 +21084,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Check if non-SigNoz metrics have been received
tags:
- metrics
@@ -21142,9 +21143,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Get metrics statistics
tags:
- metrics
@@ -21201,9 +21202,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- metrics:read
- tokenizer:
- VIEWER
- metrics:read
summary: Get metrics treemap
tags:
- metrics
@@ -28342,9 +28343,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- dashboard:list
- tokenizer:
- VIEWER
- dashboard:list
summary: Get metric dashboards (v2)
tags:
- metrics

View File

@@ -116,7 +116,7 @@ func (c *clickhouse) EstimateCardinality(ctx context.Context, metricName string,
sb := sqlbuilder.NewSelectBuilder()
reducedExpr := "1"
reducedExpr := "toUInt64(1)"
if len(keptLabels) > 0 {
reducedExpr = "uniq(("
for i, label := range keptLabels {

View File

@@ -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,6 +187,49 @@
"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.",
"telegram_tip_link": "Learn how",
"field_telegram_bot_token": "Bot token",
"tooltip_telegram_bot_token": "The token BotFather returns, in the form 123456789:AA...",
"field_telegram_chat_id": "Chat ID",
"tooltip_telegram_chat_id": "The numeric chat the bot posts to, negative for groups and channels. Telegram's @username form is not accepted.",
"placeholder_telegram_chat_id": "e.g. -1001234567890",
"field_telegram_message_thread_id": "Message thread ID",
"tooltip_telegram_message_thread_id": "Posts into one message thread of a forum group. Leave empty to post in the group itself.",
"field_telegram_title": "Title",
"tooltip_telegram_title": "Sent in bold above the message. Telegram HTML, so interpolated values are escaped.",
"field_telegram_message": "Message",
"tooltip_telegram_message": "Template for the message body. Telegram HTML, so interpolated values are escaped.",
"field_telegram_api_url": "Bot API URL",
"tooltip_telegram_api_url": "Point at a self-hosted Bot API server. Leave empty to use Telegram's.",
"field_telegram_disable_notifications": "Send silently",
"tooltip_telegram_disable_notifications": "Delivers without a sound or vibration on the recipients' devices."
}

View File

@@ -3291,6 +3291,7 @@ export enum CoretypesKindDTO {
'meter-metrics' = 'meter-metrics',
'logs-field' = 'logs-field',
'traces-field' = 'traces-field',
'metrics-field' = 'metrics-field',
}
export enum CoretypesTypeDTO {
user = 'user',

View File

@@ -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;
}

View File

@@ -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;
}

View 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;
}

View 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;

View File

@@ -2,8 +2,11 @@ import { useCallback, useEffect, useState } from 'react';
import { Button, InputNumber, Popover, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import { LogViewMode } from 'container/OptionsMenu/types';
import { FontSize, OptionsMenuConfig } from 'container/OptionsMenu/types';
import {
LogViewMode,
FontSize,
OptionsMenuConfig,
} from 'container/OptionsMenu/types';
import {
Check,
ChevronLeft,

View File

@@ -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;

View File

@@ -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';

View File

@@ -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', () => {

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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')}

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,174 @@
import { useTranslation } from 'react-i18next';
import { Controller, useFormContext } from 'react-hook-form';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { Input as AntdInput } from 'antd';
import { ChannelSpecFormValues } from '../../CreateAlertChannels/types';
import FormCard from '../FormCard';
import FormField from '../FormField';
const { TextArea } = AntdInput;
/**
* The API models the chat and the topic as integers. An empty or half-typed box
* has to become `undefined`, not `NaN`: `omitBlank` keeps `NaN`, which
* serialises as `null` and the API rejects it. react-hook-form also runs this
* over the value already in the form, which arrives as a number, not a string.
*/
const asOptionalNumber = (value: unknown): number | undefined => {
if (typeof value === 'number') {
return Number.isNaN(value) ? undefined : value;
}
if (typeof value !== 'string' || value.trim() === '') {
return undefined;
}
const parsed = Number(value);
return Number.isNaN(parsed) ? undefined : parsed;
};
function TelegramSettings(): JSX.Element {
const { t } = useTranslation('channels');
const {
register,
control,
formState: { errors },
} = useFormContext<ChannelSpecFormValues>();
return (
<>
<FormCard title={t('section_connection')}>
<Typography.Text
color="muted"
size="sm"
testId="telegram-tip"
style={{ display: 'block', marginBottom: 16 }}
>
{t('telegram_tip')}{' '}
<Typography.Link
href="https://core.telegram.org/bots#how-do-i-create-a-bot"
target="_blank"
rel="noopener noreferrer"
>
{t('telegram_tip_link')}
</Typography.Link>
</Typography.Text>
<FormField
label={t('field_telegram_bot_token')}
tooltip={t('tooltip_telegram_bot_token')}
error={errors.botToken?.message}
required
>
<Input
{...register('botToken')}
aria-required
type="password"
data-testid="telegram-bot-token-textbox"
/>
</FormField>
<FormField
label={t('field_telegram_chat_id')}
tooltip={t('tooltip_telegram_chat_id')}
error={errors.chatId?.message}
required
>
<Input
{...register('chatId', { setValueAs: asOptionalNumber })}
aria-required
type="number"
placeholder={t('placeholder_telegram_chat_id')}
data-testid="telegram-chat-id-textbox"
/>
</FormField>
<FormField
label={t('field_telegram_message_thread_id')}
tooltip={t('tooltip_telegram_message_thread_id')}
error={errors.messageThreadId?.message}
>
<Input
{...register('messageThreadId', { setValueAs: asOptionalNumber })}
type="number"
data-testid="telegram-message-thread-id-textbox"
/>
</FormField>
</FormCard>
<FormCard title={t('section_message')}>
<FormField
label={t('field_telegram_title')}
tooltip={t('tooltip_telegram_title')}
error={errors.title?.message}
>
<Controller
control={control}
name="title"
render={({ field }): JSX.Element => (
<TextArea
rows={2}
data-testid="telegram-title-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
<FormField
label={t('field_telegram_message')}
tooltip={t('tooltip_telegram_message')}
error={errors.message?.message}
>
<Controller
control={control}
name="message"
render={({ field }): JSX.Element => (
<TextArea
rows={6}
data-testid="telegram-message-textarea"
value={(field.value as string) ?? ''}
onChange={field.onChange}
/>
)}
/>
</FormField>
</FormCard>
<FormCard title={t('section_advanced')}>
<FormField
label={t('field_telegram_api_url')}
tooltip={t('tooltip_telegram_api_url')}
error={errors.apiUrl?.message}
>
<Input {...register('apiUrl')} data-testid="telegram-api-url-textbox" />
</FormField>
<FormField
label={t('field_telegram_disable_notifications')}
tooltip={t('tooltip_telegram_disable_notifications')}
>
<Controller
control={control}
name="disableNotifications"
render={({ field }): JSX.Element => (
<Switch
color="primary"
textPlacement="right"
testId="telegram-disable-notifications-switch"
value={!!field.value}
onChange={field.onChange}
/>
)}
/>
</FormField>
</FormCard>
</>
);
}
export default TelegramSettings;

View File

@@ -24,6 +24,7 @@ describe('FormAlertChannels', () => {
onTypeChangeHandler={jest.fn()}
onSaveHandler={jest.fn()}
onTestHandler={jest.fn()}
onDiscardHandler={jest.fn()}
testingState={false}
savingState={false}
editing

View File

@@ -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';
@@ -20,6 +21,7 @@ import MsTeamsSettings from './Settings/MsTeams';
import OpsgenieSettings from './Settings/Opsgenie';
import PagerSettings from './Settings/Pager';
import SlackSettings from './Settings/Slack';
import TelegramSettings from './Settings/Telegram';
import WebhookSettings from './Settings/Webhook';
import { Button } from '@signozhq/ui/button';
@@ -38,6 +40,7 @@ const CHANNEL_TYPE_OPTIONS = [
{ value: ChannelKind.jira, label: 'Jira' },
{ value: ChannelKind.jsmops, label: 'Jira Service Management Ops' },
{ value: ChannelKind.incidentio, label: 'incident.io' },
{ value: ChannelKind.telegram, label: 'Telegram' },
];
function FormAlertChannels({
@@ -46,10 +49,12 @@ function FormAlertChannels({
onTypeChangeHandler,
onTestHandler,
onSaveHandler,
onDiscardHandler,
savingState,
testingState,
editing = false,
readOnly = false,
hasChanges = true,
saveChecks = [],
testChecks = [],
}: FormAlertChannelsProps): JSX.Element {
@@ -80,6 +85,8 @@ function FormAlertChannels({
return <IncidentIOSettings />;
case ChannelKind.opsgenie:
return <OpsgenieSettings />;
case ChannelKind.telegram:
return <TelegramSettings />;
case ChannelKind.email:
return <EmailSettings />;
default:
@@ -111,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"
@@ -127,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}
@@ -144,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>
@@ -180,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`. */
@@ -195,6 +227,7 @@ interface FormAlertChannelsProps {
FormAlertChannels.defaultProps = {
editing: undefined,
readOnly: false,
hasChanges: true,
saveChecks: [],
testChecks: [],
};

View File

@@ -1,2 +1 @@
export { default as ActionsMenu } from './ActionsMenu';
export { default as ColumnSelector } from './ColumnSelector';

View File

@@ -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"

View File

@@ -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> = {};

View File

@@ -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);
},

View File

@@ -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');
},
};
@@ -178,3 +181,8 @@ export const JiraServiceManagementOps: Story = {
export const IncidentIO: Story = {
args: { channelType: 'incidentio' },
};
/** A Telegram channel, with its bot token masked behind the password field. */
export const Telegram: Story = {
args: { channelType: 'telegram' },
};

View File

@@ -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';
@@ -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,
@@ -106,6 +112,39 @@ export const ReadOnly: Story = {
},
};
/**
* The same denial on a Telegram channel. Its fields are the kind the form gained
* last, including the only toggle a settings panel renders, so this is where a
* gap in the gating would show first.
*
* The switch is checked by behaviour, not by an attribute: it is a button inside
* the disabled fieldset, so it carries no `disabled` of its own and an
* attribute check would pass whether or not the browser actually turned it off.
*/
export const TelegramReadOnly: Story = {
args: { channelType: 'telegram', revoked: [UPDATE, CREATE, DELETE] },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expectDisabled(
await canvas.findByTestId(
'telegram-bot-token-textbox',
undefined,
PAGE_LOAD,
),
);
await expectDisabled(await canvas.findByTestId('telegram-chat-id-textbox'));
await expectDisabled(await canvas.findByTestId('telegram-message-textarea'));
const toggle = await canvas.findByTestId(
'telegram-disable-notifications-switch',
);
const before = toggle.getAttribute('aria-checked');
await userEvent.click(toggle);
await expect(toggle.getAttribute('aria-checked')).toBe(before);
},
};
/** No channel permission at all: the page is denied before the form exists. */
export const NoChannelAccess: Story = {
args: { revoked: NOTIFICATION_CHANNEL },
@@ -135,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);
},
};

View File

@@ -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}`,
}),
});

View File

@@ -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');
},
};
@@ -178,6 +185,38 @@ export const IncidentIO: Story = {
},
};
/** The Telegram form: the bot token, the chat it posts to and the message. */
export const Telegram: Story = {
play: async ({ canvasElement }): Promise<void> => {
await selectChannelType(canvasElement, /^Telegram$/);
},
};
/**
* Telegram's own required fields. The chat id is the form's first numeric field,
* and the API treats 0 as missing, so an empty box has to report the same error
* as a blank one rather than sending a zero.
*/
export const TelegramRequiredFields: Story = {
play: async ({ canvasElement }): Promise<void> => {
const canvas = within(canvasElement);
await selectChannelType(canvasElement, /^Telegram$/);
await userEvent.type(
await canvas.findByTestId('channel-name-textbox'),
'alerts-telegram',
);
await userEvent.click(await canvas.findByTestId('save-channel-button'));
await screen.findByText(
'Bot token is mandatory for creating a telegram channel',
);
await screen.findByText(
'Chat ID is mandatory for creating a telegram channel',
);
},
};
/**
* The fields the API rejects a save without are marked before anything is
* submitted, so a long form does not have to be guessed at.
@@ -199,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$/);
},
};

View File

@@ -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);
},
};

View File

@@ -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 {

View File

@@ -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}

View File

@@ -10,7 +10,7 @@ const FIND = { timeout: 5000 };
interface PrefillCase {
kind: string;
spec: Record<string, unknown>;
fields: [string, string][];
fields: [string, string | number][];
}
const CASES: PrefillCase[] = [
@@ -186,6 +186,25 @@ const CASES: PrefillCase[] = [
['incidentio-description-textarea', 'incident description'],
],
},
{
kind: 'telegram',
spec: {
botToken: '123456789:AA-token',
chatId: -1001234567890,
messageThreadId: 42,
title: 'telegram title',
message: 'telegram message',
apiUrl: 'https://api.telegram.org',
},
fields: [
['telegram-bot-token-textbox', '123456789:AA-token'],
['telegram-chat-id-textbox', -1001234567890],
['telegram-message-thread-id-textbox', 42],
['telegram-title-textarea', 'telegram title'],
['telegram-message-textarea', 'telegram message'],
['telegram-api-url-textbox', 'https://api.telegram.org'],
],
},
];
describe('ChannelForm prefill', () => {

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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}
</>
);
}

View File

@@ -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],
},
},
);

View File

@@ -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),

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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,
};
}

View File

@@ -81,6 +81,7 @@ export const CHANNEL_TYPES = [
'jira',
'jsmops',
'incidentio',
'telegram',
] as const;
export type ChannelType = (typeof CHANNEL_TYPES)[number];
@@ -790,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: {
@@ -847,6 +864,13 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
title: '{{ .CommonLabels.alertname }}',
sendResolved: true,
},
telegram: {
botToken: '123456789:AA-story-telegram-token',
chatId: -1001234567890,
title: '{{ .CommonLabels.alertname }}',
message: '{{ range .Alerts -}}{{ .Annotations.description }}\n{{ end }}',
sendResolved: true,
},
};
/** The v2 fetch by id, which is what the redesigned screens read. */

View File

@@ -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);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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);
}

View File

@@ -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>
);
}

View File

@@ -73,7 +73,8 @@
"audit-logs",
"meter-metrics",
"logs-field",
"traces-field"
"traces-field",
"metrics-field"
],
"type": "string"
},

View File

@@ -4,14 +4,16 @@ import (
"net/http"
"github.com/SigNoz/signoz/pkg/http/handler"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/metricsexplorertypes"
"github.com/gorilla/mux"
)
func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/metrics", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.ListMetrics),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.ListMetrics, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListMetrics",
Tags: []string{"metrics"},
@@ -25,13 +27,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceMetricsField.Scope(coretypes.VerbList)}),
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceMetricsField,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/stats", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetStats),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.GetStats, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetMetricsStats",
Tags: []string{"metrics"},
@@ -44,13 +51,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/treemap", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetTreemap),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.GetTreemap, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetMetricsTreemap",
Tags: []string{"metrics"},
@@ -63,13 +75,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/attributes", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricAttributes),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.GetMetricAttributes, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetMetricAttributes",
Tags: []string{"metrics"},
@@ -83,13 +100,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceMetricsField.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceMetricsField,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/metadata", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricMetadata),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.GetMetricMetadata, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetMetricMetadata",
Tags: []string{"metrics"},
@@ -103,13 +125,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceMetricsField.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceMetricsField,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/metadata", handler.New(
provider.authzMiddleware.EditAccess(provider.metricsExplorerHandler.UpdateMetricMetadata),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.UpdateMetricMetadata, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "UpdateMetricMetadata",
Tags: []string{"metrics"},
@@ -122,13 +149,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceMetricsField.Scope(coretypes.VerbUpdate)}),
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceMetricsField,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/highlights", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricHighlights),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.GetMetricHighlights, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetMetricHighlights",
Tags: []string{"metrics"},
@@ -142,13 +174,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWildcardResources(coretypes.ResourceTelemetryResourceMetrics),
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/alerts", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricAlerts),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.GetMetricAlerts, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetMetricAlerts",
Tags: []string{"metrics"},
@@ -162,13 +199,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceRule.Scope(coretypes.VerbList)}),
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceRule,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v3/metrics/dashboards", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetMetricDashboardsV2),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.GetMetricDashboardsV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetMetricDashboardsV2",
Tags: []string{"metrics"},
@@ -182,13 +224,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusNotFound, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbList)}),
}, handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceDashboard,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/inspect", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.InspectMetrics),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.InspectMetrics, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "InspectMetrics",
Tags: []string{"metrics"},
@@ -201,13 +248,18 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWithFilterExpressionResources(coretypes.ResourceTelemetryResourceMetrics, "filter.expression"),
}))).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/metrics/onboarding", handler.New(
provider.authzMiddleware.ViewAccess(provider.metricsExplorerHandler.GetOnboardingStatus),
provider.authzMiddleware.CheckResources(provider.metricsExplorerHandler.GetOnboardingStatus, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetMetricsOnboardingStatus",
Tags: []string{"metrics"},
@@ -220,8 +272,13 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusUnauthorized, http.StatusInternalServerError},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}),
}, handler.WithResourceDefs(handler.TelemetryResourceDef{
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
Selector: querybuilder.TelemetrySelector,
Resources: querybuilder.BuilderQueryWildcardResources(coretypes.ResourceTelemetryResourceMetrics),
}))).Methods(http.MethodGet).GetError(); err != nil {
return err
}

View File

@@ -118,10 +118,47 @@
"description": ""
}
],
"logs": []
"logs": [
{
"name": "Project",
"path": "gcp.project",
"type": "string"
},
{
"name": "Resource Type",
"path": "gcp.resource_type",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"gcp": {}
"gcp": {
"metrics": {
"metricNames": [
"cloudsql.googleapis.com/database/up",
"cloudsql.googleapis.com/database/instance_state",
"cloudsql.googleapis.com/database/replication/replica_lag",
"cloudsql.googleapis.com/database/cpu/utilization",
"cloudsql.googleapis.com/database/memory/utilization",
"cloudsql.googleapis.com/database/disk/utilization",
"cloudsql.googleapis.com/database/network/connections",
"cloudsql.googleapis.com/database/mysql/queries",
"cloudsql.googleapis.com/database/mysql/dml_operations_count",
"cloudsql.googleapis.com/database/mysql/threads",
"cloudsql.googleapis.com/database/mysql/innodb/buffer_pool_reads_count",
"cloudsql.googleapis.com/database/mysql/innodb/buffer_pool_read_requests_count",
"cloudsql.googleapis.com/database/mysql/slow_queries_count",
"cloudsql.googleapis.com/database/mysql/aborted_connects_count",
"cloudsql.googleapis.com/database/mysql/innodb/deadlocks_count",
"cloudsql.googleapis.com/database/mysql/innodb/row_lock_waits_count",
"cloudsql.googleapis.com/database/disk/read_ops_count",
"cloudsql.googleapis.com/database/disk/write_ops_count"
]
},
"logs": {
"sinkFilter": "resource.type=\"cloudsql_database\" AND (logName:\"/logs/cloudsql.googleapis.com%2Fmysql\" OR (log_id(\"cloudaudit.googleapis.com/data_access\") AND protoPayload.request.@type=\"type.googleapis.com/google.cloud.sql.audit.v1.MysqlAuditEntry\"))"
}
}
},
"assets": {
"dashboards": [

View File

@@ -5,7 +5,7 @@
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": false
"logs": true
},
"dataCollected": {
"metrics": [
@@ -75,6 +75,12 @@
"type": "Gauge",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/insights/aggregate/io_time",
"unit": "Microseconds",
"type": "Sum",
"description": ""
},
{
"name": "cloudsql.googleapis.com/database/postgresql/insights/perquery/execution_time",
"unit": "Microseconds",
@@ -88,10 +94,43 @@
"description": ""
}
],
"logs": []
"logs": [
{
"name": "Project",
"path": "gcp.project",
"type": "string"
},
{
"name": "Resource Type",
"path": "gcp.resource_type",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"gcp": {}
"gcp": {
"metrics": {
"metricNames": [
"cloudsql.googleapis.com/database/up",
"cloudsql.googleapis.com/database/cpu/utilization",
"cloudsql.googleapis.com/database/memory/utilization",
"cloudsql.googleapis.com/database/memory/usage",
"cloudsql.googleapis.com/database/disk/bytes_used",
"cloudsql.googleapis.com/database/postgresql/num_backends",
"cloudsql.googleapis.com/database/postgresql/num_backends_by_state",
"cloudsql.googleapis.com/database/postgresql/transaction_count",
"cloudsql.googleapis.com/database/postgresql/deadlock_count",
"cloudsql.googleapis.com/database/postgresql/vacuum/oldest_transaction_age",
"cloudsql.googleapis.com/database/postgresql/insights/aggregate/execution_time",
"cloudsql.googleapis.com/database/postgresql/insights/aggregate/io_time",
"cloudsql.googleapis.com/database/postgresql/insights/perquery/execution_time",
"cloudsql.googleapis.com/database/postgresql/replication/replica_byte_lag"
]
},
"logs": {
"sinkFilter": "resource.type=\"cloudsql_database\" AND (logName:\"/logs/cloudsql.googleapis.com%2Fpostgres\" OR (log_id(\"cloudaudit.googleapis.com/data_access\") AND protoPayload.request.@type=\"type.googleapis.com/google.cloud.sql.audit.v1.PgAuditEntry\"))"
}
}
},
"assets": {
"dashboards": [

View File

@@ -5,7 +5,7 @@
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": false
"logs": true
},
"dataCollected": {
"metrics": [
@@ -52,10 +52,36 @@
"description": ""
}
],
"logs": []
"logs": [
{
"name": "Project",
"path": "gcp.project",
"type": "string"
},
{
"name": "Resource Type",
"path": "gcp.resource_type",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"gcp": {}
"gcp": {
"metrics": {
"metricNames": [
"storage.googleapis.com/storage/v2/total_bytes",
"storage.googleapis.com/storage/v2/total_count",
"storage.googleapis.com/storage/v2/deleted_bytes",
"storage.googleapis.com/api/request_count",
"storage.googleapis.com/network/received_bytes_count",
"storage.googleapis.com/network/sent_bytes_count",
"storage.googleapis.com/authn/authentication_count"
]
},
"logs": {
"sinkFilter": "resource.type=\"gcs_bucket\""
}
}
},
"assets": {
"dashboards": [

View File

@@ -5,7 +5,7 @@
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": false
"logs": true
},
"dataCollected": {
"metrics": [
@@ -106,10 +106,45 @@
"description": ""
}
],
"logs": []
"logs": [
{
"name": "Project",
"path": "gcp.project",
"type": "string"
},
{
"name": "Resource Type",
"path": "gcp.resource_type",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"gcp": {}
"gcp": {
"metrics": {
"metricNames": [
"compute.googleapis.com/instance/uptime_total",
"compute.googleapis.com/instance/cpu/utilization",
"compute.googleapis.com/guest/memory/bytes_used",
"compute.googleapis.com/guest/memory/percent_used",
"agent.googleapis.com/memory/percent_used",
"compute.googleapis.com/instance/disk/average_io_latency",
"compute.googleapis.com/instance/disk/read_bytes_count",
"compute.googleapis.com/instance/disk/write_bytes_count",
"compute.googleapis.com/instance/disk/read_ops_count",
"compute.googleapis.com/instance/disk/write_ops_count",
"compute.googleapis.com/instance/disk/performance_status",
"compute.googleapis.com/instance/network/received_bytes_count",
"compute.googleapis.com/instance/network/sent_bytes_count",
"compute.googleapis.com/instance/network/received_packets_count",
"compute.googleapis.com/instance/network/sent_packets_count",
"compute.googleapis.com/firewall/dropped_packets_count"
]
},
"logs": {
"sinkFilter": "resource.type=\"gce_instance\" OR protoPayload.serviceName=\"compute.googleapis.com\""
}
}
},
"assets": {
"dashboards": [

View File

@@ -5,7 +5,7 @@
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": false
"logs": true
},
"dataCollected": {
"metrics": [
@@ -106,10 +106,45 @@
"description": ""
}
],
"logs": []
"logs": [
{
"name": "Project",
"path": "gcp.project",
"type": "string"
},
{
"name": "Resource Type",
"path": "gcp.resource_type",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"gcp": {}
"gcp": {
"metrics": {
"metricNames": [
"kubernetes.io/container/cpu/limit_utilization",
"kubernetes.io/container/cpu/request_utilization",
"kubernetes.io/container/memory/limit_utilization",
"kubernetes.io/container/memory/request_utilization",
"kubernetes.io/container/restart_count",
"kubernetes.io/pod/latencies/pod_first_ready",
"kubernetes.io/pod/network/received_bytes_count",
"kubernetes.io/pod/network/sent_bytes_count",
"kubernetes.io/pod/volume/utilization",
"kubernetes.io/node/cpu/allocatable_utilization",
"kubernetes.io/node/memory/allocatable_utilization",
"kubernetes.io/node/ephemeral_storage/used_bytes",
"kubernetes.io/node/ephemeral_storage/allocatable_bytes",
"kubernetes.io/node/network/received_bytes_count",
"kubernetes.io/node/network/sent_bytes_count",
"kubernetes.io/node/status_condition"
]
},
"logs": {
"sinkFilter": "resource.type=(\"k8s_container\" OR \"k8s_pod\" OR \"k8s_node\" OR \"k8s_cluster\" OR \"k8s_control_plane_component\" OR \"gke_cluster\" OR \"gke_nodepool\")"
}
}
},
"assets": {
"dashboards": [

View File

@@ -5,7 +5,7 @@
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": false
"logs": true
},
"dataCollected": {
"metrics": [
@@ -76,10 +76,40 @@
"description": ""
}
],
"logs": []
"logs": [
{
"name": "Project",
"path": "gcp.project",
"type": "string"
},
{
"name": "Resource Type",
"path": "gcp.resource_type",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"gcp": {}
"gcp": {
"metrics": {
"metricNames": [
"redis.googleapis.com/server/uptime",
"redis.googleapis.com/stats/memory/usage_ratio",
"redis.googleapis.com/stats/memory/system_memory_usage_ratio",
"redis.googleapis.com/stats/cpu_utilization_main_thread",
"redis.googleapis.com/clients/connected",
"redis.googleapis.com/stats/cache_hit_ratio",
"redis.googleapis.com/keyspace/keys",
"redis.googleapis.com/commands/usec_per_call",
"redis.googleapis.com/commands/calls",
"redis.googleapis.com/clients/blocked",
"redis.googleapis.com/stats/reject_connections_count"
]
},
"logs": {
"sinkFilter": "resource.type=\"redis_instance\" OR (protoPayload.serviceName=\"redis.googleapis.com\" AND protoPayload.methodName:\".CloudRedis.\") OR (log_id(\"maintenance.googleapis.com/activity_log\") AND labels.activity_type_name:\"CloudRedisRollout\")"
}
}
},
"assets": {
"dashboards": [

View File

@@ -263,6 +263,7 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddFieldTuplesFactory(sqlstore),
sqlmigration.NewKeepRelevantLLMPricingRulesFactory(),
sqlmigration.NewBackfillTelegramChannelSpecFactory(),
sqlmigration.NewAddMetricsFieldTuplesFactory(sqlstore),
)
}

View File

@@ -0,0 +1,162 @@
package sqlmigration
import (
"context"
"database/sql"
"encoding/json"
"time"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/oklog/ulid/v2"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
"github.com/uptrace/bun/migrate"
)
type addMetricsFieldTuples struct {
sqlstore sqlstore.SQLStore
}
func NewAddMetricsFieldTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_metrics_field_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addMetricsFieldTuples{sqlstore: sqlstore}, nil
})
}
func (migration *addMetricsFieldTuples) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addMetricsFieldTuples) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
var storeID string
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
if err != nil {
return err
}
var orgIDs []string
err = tx.NewSelect().
Table("organizations").
Column("id").
Scan(ctx, &orgIDs)
if err != nil && err != sql.ErrNoRows {
return err
}
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
tuples := []migrationTuple{
{authtypes.SigNozAdminRoleName, "metaresource", "metrics-field", "read"},
{authtypes.SigNozAdminRoleName, "metaresource", "metrics-field", "update"},
{authtypes.SigNozAdminRoleName, "metaresource", "metrics-field", "list"},
{authtypes.SigNozEditorRoleName, "metaresource", "metrics-field", "read"},
{authtypes.SigNozEditorRoleName, "metaresource", "metrics-field", "update"},
{authtypes.SigNozEditorRoleName, "metaresource", "metrics-field", "list"},
{authtypes.SigNozViewerRoleName, "metaresource", "metrics-field", "read"},
{authtypes.SigNozViewerRoleName, "metaresource", "metrics-field", "list"},
}
for _, orgID := range orgIDs {
for _, tuple := range tuples {
entropy := ulid.DefaultEntropy()
now := time.Now().UTC()
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
if isPG {
user := "role:" + roleSubject + "#assignee"
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
)
if err != nil {
return err
}
} else {
result, err := tx.ExecContext(ctx, `
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
continue
}
_, err = tx.ExecContext(ctx, `
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
)
if err != nil {
return err
}
}
}
}
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
if err != nil {
return err
}
managedRoleGroups[roleName] = string(data)
}
for _, orgID := range orgIDs {
for roleName, data := range managedRoleGroups {
if _, err := tx.NewUpdate().
Model(new(roles)).
Set("transaction_groups = ?", data).
Where("org_id = ?", orgID).
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
Where("name = ?", roleName).
Exec(ctx); err != nil {
return err
}
}
}
return tx.Commit()
}
func (migration *addMetricsFieldTuples) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -24,7 +24,19 @@ type GCPConnectionArtifact struct{}
type GCPIntegrationConfig struct{}
type GCPTelemetryCollectionStrategy struct{}
type GCPTelemetryCollectionStrategy struct {
Metrics *GCPMetricsCollectionStrategy `json:"metrics" required:"false" nullable:"false"`
Logs *GCPLogsCollectionStrategy `json:"logs" required:"false" nullable:"false"`
}
type GCPMetricsCollectionStrategy struct {
MetricNames []string `json:"metricNames" required:"true" nullable:"false"`
}
type GCPLogsCollectionStrategy struct {
// Cloud Logging filter for the log sink, https://cloud.google.com/logging/docs/view/logging-query-language
SinkFilter string `json:"sinkFilter" required:"true"`
}
type GCPServiceConfig struct {
Logs *GCPServiceLogsConfig `json:"logs,omitempty" required:"false"`

View File

@@ -79,6 +79,7 @@ func (name Kind) Enum() []any {
KindMeterMetrics,
KindLogsField,
KindTracesField,
KindMetricsField,
}
}

View File

@@ -39,6 +39,7 @@ var Kinds = []Kind{
KindMeterMetrics,
KindLogsField,
KindTracesField,
KindMetricsField,
}
var (
@@ -80,4 +81,5 @@ var (
KindMeterMetrics = MustNewKind("meter-metrics")
KindLogsField = MustNewKind("logs-field")
KindTracesField = MustNewKind("traces-field")
KindMetricsField = MustNewKind("metrics-field")
)

View File

@@ -214,6 +214,10 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
// metrics-field — editor+admin update, viewer reads
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
},
SigNozEditorRoleName: {
// cloud-integration — editor can read accounts
@@ -309,6 +313,10 @@ var ManagedRoleToTransactions = map[string][]Transaction{
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
// metrics-field — editor reads+updates
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
{Verb: VerbUpdate, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
},
SigNozViewerRoleName: {
// cloud-integration — viewer can read accounts
@@ -370,6 +378,9 @@ var ManagedRoleToTransactions = map[string][]Transaction{
// traces-field — viewer reads
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindTracesField}, WildCardSelectorString)},
// metrics-field — viewer reads
{Verb: VerbRead, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
{Verb: VerbList, Object: *MustNewObject(ResourceRef{Type: TypeMetaResource, Kind: KindMetricsField}, WildCardSelectorString)},
},
SigNozAnonymousRoleName: {
// public-dashboard — anonymous read

View File

@@ -39,6 +39,7 @@ var Resources = []Resource{
ResourceTelemetryResourceMeterMetrics,
ResourceMetaResourceLogsField,
ResourceMetaResourceTracesField,
ResourceMetaResourceMetricsField,
}
var (
@@ -80,4 +81,5 @@ var (
ResourceTelemetryResourceMeterMetrics = NewResourceTelemetryResource(KindMeterMetrics)
ResourceMetaResourceLogsField = NewResourceMetaResource(KindLogsField)
ResourceMetaResourceTracesField = NewResourceMetaResource(KindTracesField)
ResourceMetaResourceMetricsField = NewResourceMetaResource(KindMetricsField, VerbList, VerbRead, VerbUpdate)
)

369
tests/e2e/drivers/channels.ts generated Normal file
View 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';

View 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);
}
});
});
});

View 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();
});
});

View 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);
}
});
});

View 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);
}
});
});
});

View 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();
});
});

View File

@@ -49,9 +49,6 @@ class ProviderAccountSpec:
class ProviderServiceSpec:
provider: str
service_id: str
# GCP ships every service with supportedSignals.logs false, so a logs block
# is neither required on write nor persisted.
supports_logs: bool
account_config: dict
# id shown in parametrized test names; defaults to the provider slug.
id: str = field(default="")
@@ -61,10 +58,12 @@ class ProviderServiceSpec:
object.__setattr__(self, "id", self.provider)
def build_service_config(self, metrics_enabled: bool, logs_enabled: bool | None = None) -> dict:
config: dict = {"metrics": {"enabled": metrics_enabled}}
if self.supports_logs:
config["logs"] = {"enabled": metrics_enabled if logs_enabled is None else logs_enabled}
return {self.provider: config}
return {
self.provider: {
"metrics": {"enabled": metrics_enabled},
"logs": {"enabled": metrics_enabled if logs_enabled is None else logs_enabled},
}
}
@pytest.fixture(scope="function")

View File

@@ -288,6 +288,15 @@
"list"
]
},
{
"type": "metaresource",
"kind": "metrics-field",
"verbs": [
"read",
"update",
"list"
]
},
{
"type": "metaresource",
"kind": "ttl-setting",
@@ -510,6 +519,15 @@
"list"
]
},
{
"type": "metaresource",
"kind": "metrics-field",
"verbs": [
"read",
"update",
"list"
]
},
{
"type": "metaresource",
"kind": "ttl-setting",
@@ -681,6 +699,14 @@
"list"
]
},
{
"type": "metaresource",
"kind": "metrics-field",
"verbs": [
"read",
"list"
]
},
{
"type": "metaresource",
"kind": "ttl-setting",

View File

@@ -19,14 +19,12 @@ logger = setup_logger(__name__)
AWS_SERVICE_SPEC = ProviderServiceSpec(
provider="aws",
service_id="rds",
supports_logs=True,
account_config={"aws": {"deploymentRegion": "us-east-1", "regions": ["us-east-1"]}},
)
GCP_SERVICE_SPEC = ProviderServiceSpec(
provider="gcp",
service_id="cloudsql_postgres",
supports_logs=False,
account_config={
"gcp": {
"deploymentProjectId": "signoz-test-project",
@@ -162,7 +160,7 @@ def test_get_service_details_without_account(
assert data["cloudIntegrationService"] is None, "cloudIntegrationService should be null without account context"
assert data["supportedSignals"]["metrics"] is True, "metrics should be a supported signal"
assert data["supportedSignals"]["logs"] is spec.supports_logs, f"logs support should be {spec.supports_logs} for {spec.provider}"
assert data["supportedSignals"]["logs"] is True, "logs should be a supported signal"
@provider_spec
@@ -253,10 +251,7 @@ def test_update_service_config(
assert svc["config"][spec.provider]["metrics"]["enabled"] is True, "metrics should be enabled"
assert svc["cloudIntegrationId"] == account_id, "cloudIntegrationId should match the account"
if spec.supports_logs:
assert svc["config"][spec.provider]["logs"]["enabled"] is True, "logs should be enabled"
else:
assert svc["config"][spec.provider].get("logs") is None, f"logs should not be stored for {spec.provider}, got: {svc['config'][spec.provider]}"
assert svc["config"][spec.provider]["logs"]["enabled"] is True, "logs should be enabled"
@provider_spec
@@ -307,8 +302,7 @@ def test_update_service_config_disable(
assert svc is not None, "cloudIntegrationService should still be present after disable"
assert svc["config"][spec.provider]["metrics"]["enabled"] is False, "metrics should be disabled"
if spec.supports_logs:
assert svc["config"][spec.provider]["logs"]["enabled"] is False, "logs should be disabled"
assert svc["config"][spec.provider]["logs"]["enabled"] is False, "logs should be disabled"
@provider_spec
@@ -337,7 +331,7 @@ def test_update_gcp_service_without_metrics_config(
get_token: Callable[[str, str], str],
create_cloud_integration_account: Callable,
) -> None:
"""GCP services support metrics only, so a config omitting metrics is rejected."""
"""A GCP config omitting metrics is rejected."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
account = create_cloud_integration_account(admin_token, "gcp", config=GCP_SERVICE_SPEC.account_config)

View File

@@ -0,0 +1,286 @@
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
import pytest
import requests
from wiremock.resources.mappings import Mapping
from fixtures import types
from fixtures.auth import (
USER_ADMIN_EMAIL,
USER_ADMIN_PASSWORD,
add_license,
change_user_role,
create_active_user,
)
from fixtures.metrics import Metrics
from fixtures.role import transaction_group
user_password = "password123Z$"
scoped_role = "metrics-explorer-scope-key-a"
scoped_email = "metrics-explorer-scope-key-a@telemetry.test"
wildcard_role = "metrics-explorer-wildcard"
wildcard_email = "metrics-explorer-wildcard@telemetry.test"
metadata_role = "metrics-explorer-metadata-reader"
metadata_email = "metrics-explorer-metadata-reader@telemetry.test"
viewer_email = "metrics-explorer-viewer@telemetry.test"
editor_email = "metrics-explorer-editor@telemetry.test"
def test_setup(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
get_token: Callable[[str, str], str],
create_role: Callable[..., str],
) -> None:
add_license(signoz, make_http_mocks, get_token)
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
create_role(admin_token, scoped_role, [transaction_group("read", "telemetryresource", "metrics", ["builder_query/signoz.workspace.key.id/key-a"])])
scoped_user = create_active_user(signoz, admin_token, email=scoped_email, role="signoz-viewer", password=user_password)
change_user_role(signoz, admin_token, scoped_user, "signoz-viewer", scoped_role)
create_role(admin_token, wildcard_role, [transaction_group("read", "telemetryresource", "metrics", ["builder_query/*"])])
wildcard_user = create_active_user(signoz, admin_token, email=wildcard_email, role="signoz-viewer", password=user_password)
change_user_role(signoz, admin_token, wildcard_user, "signoz-viewer", wildcard_role)
create_role(
admin_token,
metadata_role,
[transaction_group("read", "metaresource", "metrics-field", ["*"]), transaction_group("list", "metaresource", "metrics-field", ["*"])],
)
metadata_user = create_active_user(signoz, admin_token, email=metadata_email, role="signoz-viewer", password=user_password)
change_user_role(signoz, admin_token, metadata_user, "signoz-viewer", metadata_role)
create_active_user(signoz, admin_token, email=viewer_email, role="signoz-viewer", password=user_password)
create_active_user(signoz, admin_token, email=editor_email, role="signoz-editor", password=user_password)
@pytest.mark.parametrize(
("method", "path", "query", "body"),
[
("POST", "/api/v2/metrics/stats", None, {"limit": 10}),
("POST", "/api/v2/metrics/treemap", None, {"limit": 10, "mode": "samples"}),
("GET", "/api/v2/metrics/highlights", {"metricName": "explorer.requests"}, None),
("POST", "/api/v2/metrics/inspect", None, {"metricName": "explorer.requests"}),
("GET", "/api/v2/metrics/onboarding", {}, None),
],
ids=["stats", "treemap", "highlights", "inspect", "onboarding"],
)
@pytest.mark.parametrize(
("email", "expected"),
[(scoped_email, HTTPStatus.FORBIDDEN), (metadata_email, HTTPStatus.FORBIDDEN), (wildcard_email, HTTPStatus.OK), (USER_ADMIN_EMAIL, HTTPStatus.OK)],
ids=["scoped", "metadata_reader", "wildcard", "admin"],
)
def test_telemetry_routes_without_filter(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
method: str,
path: str,
query: dict | None,
body: dict | None,
email: str,
expected: HTTPStatus,
) -> None:
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics([Metrics(metric_name="explorer.requests", labels={"signoz.workspace.key.id": "key-a"}, timestamp=now - timedelta(minutes=i + 1), value=float(i)) for i in range(3)])
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
token = get_token(email, USER_ADMIN_PASSWORD if email == USER_ADMIN_EMAIL else user_password)
response = requests.request(
method,
signoz.self.host_configs["8080"].get(path),
params={"start": start, "end": end, **query} if query is not None else None,
json={"start": start, "end": end, **body} if body is not None else None,
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
assert response.status_code == expected, response.text
@pytest.mark.parametrize(
("path", "query"),
[
("/api/v2/metrics", {"limit": 10}),
("/api/v2/metrics/attributes", {"metricName": "explorer.requests"}),
("/api/v2/metrics/metadata", {"metricName": "explorer.requests"}),
],
ids=["list", "attributes", "metadata"],
)
@pytest.mark.parametrize(
("email", "expected"),
[
(scoped_email, HTTPStatus.FORBIDDEN),
(wildcard_email, HTTPStatus.FORBIDDEN),
(metadata_email, HTTPStatus.OK),
(viewer_email, HTTPStatus.OK),
(USER_ADMIN_EMAIL, HTTPStatus.OK),
],
ids=["metrics_scoped", "metrics_wildcard", "metadata_reader", "viewer", "admin"],
)
def test_metadata_reads_need_metadata_grant(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
path: str,
query: dict,
email: str,
expected: HTTPStatus,
) -> None:
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics([Metrics(metric_name="explorer.requests", labels={"signoz.workspace.key.id": "key-a"}, timestamp=now - timedelta(minutes=i + 1), value=float(i)) for i in range(3)])
token = get_token(email, USER_ADMIN_PASSWORD if email == USER_ADMIN_EMAIL else user_password)
response = requests.get(
signoz.self.host_configs["8080"].get(path),
params={"start": int((now - timedelta(minutes=10)).timestamp() * 1000), "end": int(now.timestamp() * 1000), **query},
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
assert response.status_code == expected, response.text
if expected == HTTPStatus.FORBIDDEN:
assert "metrics-field" in response.text
@pytest.mark.parametrize(
("email", "expected"),
[
(scoped_email, HTTPStatus.FORBIDDEN),
(metadata_email, HTTPStatus.FORBIDDEN),
(viewer_email, HTTPStatus.FORBIDDEN),
(editor_email, HTTPStatus.OK),
(USER_ADMIN_EMAIL, HTTPStatus.OK),
],
ids=["metrics_scoped", "metadata_reader", "viewer", "editor", "admin"],
)
def test_metadata_update_needs_update_grant(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
email: str,
expected: HTTPStatus,
) -> None:
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics([Metrics(metric_name="explorer.requests", labels={"signoz.workspace.key.id": "key-a"}, timestamp=now - timedelta(minutes=i + 1), value=float(i)) for i in range(3)])
token = get_token(email, USER_ADMIN_PASSWORD if email == USER_ADMIN_EMAIL else user_password)
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/metrics/metadata"),
json={"metricName": "explorer.requests", "type": "sum", "description": "request count", "unit": "1", "temporality": "cumulative", "isMonotonic": True},
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
assert response.status_code == expected, response.text
@pytest.mark.parametrize(
("email", "expression", "expected", "expected_metrics"),
[
(scoped_email, "signoz.workspace.key.id = 'key-a'", HTTPStatus.OK, {"explorer.key.a"}),
(scoped_email, "signoz.workspace.key.id = 'key-b'", HTTPStatus.FORBIDDEN, set()),
(scoped_email, "signoz.workspace.key.id IN ('key-a', 'key-b')", HTTPStatus.FORBIDDEN, set()),
(wildcard_email, None, HTTPStatus.OK, {"explorer.key.a", "explorer.key.b"}),
],
ids=["scoped_granted_key", "scoped_other_key", "scoped_both_keys", "wildcard_unfiltered"],
)
def test_stats_scoped_read(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
email: str,
expression: str | None,
expected: HTTPStatus,
expected_metrics: set[str],
) -> None:
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics(
[Metrics(metric_name="explorer.key.a", labels={"signoz.workspace.key.id": "key-a"}, timestamp=now - timedelta(minutes=i + 1), value=float(i)) for i in range(3)]
+ [Metrics(metric_name="explorer.key.b", labels={"signoz.workspace.key.id": "key-b"}, timestamp=now - timedelta(minutes=i + 1), value=float(i)) for i in range(3)]
)
body: dict = {"start": int((now - timedelta(minutes=10)).timestamp() * 1000), "end": int(now.timestamp() * 1000), "limit": 50}
if expression:
body["filter"] = {"expression": expression}
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/metrics/stats"),
json=body,
headers={"Authorization": f"Bearer {get_token(email, user_password)}"},
timeout=10,
)
assert response.status_code == expected, response.text
if expected == HTTPStatus.OK:
assert {stat["metricName"] for stat in response.json()["data"]["metrics"]} == expected_metrics
@pytest.mark.parametrize(
("path", "body"),
[
("/api/v2/metrics/treemap", {"limit": 10, "mode": "samples"}),
("/api/v2/metrics/inspect", {"metricName": "explorer.key.a"}),
],
ids=["treemap", "inspect"],
)
def test_filtered_routes_allow_granted_key(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
path: str,
body: dict,
) -> None:
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics([Metrics(metric_name="explorer.key.a", labels={"signoz.workspace.key.id": "key-a"}, timestamp=now - timedelta(minutes=i + 1), value=float(i)) for i in range(3)])
start, end = int((now - timedelta(minutes=10)).timestamp() * 1000), int(now.timestamp() * 1000)
token = get_token(scoped_email, user_password)
allowed = requests.post(
signoz.self.host_configs["8080"].get(path),
json={"start": start, "end": end, "filter": {"expression": "signoz.workspace.key.id = 'key-a'"}, **body},
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
assert allowed.status_code == HTTPStatus.OK, allowed.text
denied = requests.post(
signoz.self.host_configs["8080"].get(path),
json={"start": start, "end": end, "filter": {"expression": "signoz.workspace.key.id = 'key-b'"}, **body},
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
assert denied.status_code == HTTPStatus.FORBIDDEN, denied.text
assert "builder_query/signoz.workspace.key.id/key-b" in denied.text
@pytest.mark.parametrize(
("path", "scope"),
[("/api/v2/metrics/alerts", "rule:list"), ("/api/v3/metrics/dashboards", "dashboard:list")],
ids=["alerts", "dashboards"],
)
@pytest.mark.parametrize(
("email", "expected"),
[(scoped_email, HTTPStatus.FORBIDDEN), (wildcard_email, HTTPStatus.FORBIDDEN), (USER_ADMIN_EMAIL, HTTPStatus.OK)],
ids=["metrics_scoped", "metrics_wildcard", "admin"],
)
def test_metaresource_lookups_need_list_grant(
signoz: types.SigNoz,
get_token: Callable[[str, str], str],
insert_metrics: Callable[[list[Metrics]], None],
path: str,
scope: str,
email: str,
expected: HTTPStatus,
) -> None:
now = datetime.now(tz=UTC).replace(microsecond=0)
insert_metrics([Metrics(metric_name="explorer.requests", labels={"signoz.workspace.key.id": "key-a"}, timestamp=now - timedelta(minutes=i + 1), value=float(i)) for i in range(3)])
token = get_token(email, USER_ADMIN_PASSWORD if email == USER_ADMIN_EMAIL else user_password)
response = requests.get(
signoz.self.host_configs["8080"].get(path),
params={"metricName": "explorer.requests"},
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
assert response.status_code == expected, response.text
if expected == HTTPStatus.FORBIDDEN:
assert scope.split(":", maxsplit=1)[0] in response.text