mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-22 18:10:42 +01:00
Compare commits
9 Commits
v0.147.0
...
feat/scatt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75cf276320 | ||
|
|
ed489f9ec0 | ||
|
|
afea1cabf6 | ||
|
|
1bbdcfb517 | ||
|
|
f823217030 | ||
|
|
d1a7f4a254 | ||
|
|
dca6628c94 | ||
|
|
f754129103 | ||
|
|
781294fb27 |
@@ -105,13 +105,6 @@ 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`
|
||||
@@ -145,15 +138,6 @@ 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.
|
||||
|
||||
@@ -3783,6 +3783,13 @@ components:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesAxisScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
type: string
|
||||
DashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
@@ -4525,6 +4532,7 @@ components:
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
|
||||
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
@@ -4540,6 +4548,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -4553,6 +4562,7 @@ components:
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -4638,6 +4648,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -4717,6 +4739,16 @@ components:
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesPointDiameter:
|
||||
maximum: 40
|
||||
minimum: 2
|
||||
nullable: true
|
||||
type: integer
|
||||
DashboardtypesPointOpacity:
|
||||
maximum: 1
|
||||
minimum: 0.1
|
||||
nullable: true
|
||||
type: number
|
||||
DashboardtypesPostableDashboardV2:
|
||||
properties:
|
||||
generateName:
|
||||
@@ -4905,6 +4937,104 @@ components:
|
||||
required:
|
||||
- queryValue
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxes:
|
||||
properties:
|
||||
x:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
"y":
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxis:
|
||||
properties:
|
||||
label:
|
||||
description: Axis title. Empty draws none.
|
||||
type: string
|
||||
scale:
|
||||
$ref: '#/components/schemas/DashboardtypesAxisScale'
|
||||
softMax:
|
||||
nullable: true
|
||||
type: number
|
||||
softMin:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesScatterPlotChartAppearance:
|
||||
properties:
|
||||
points:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
|
||||
type: object
|
||||
DashboardtypesScatterPlotDimensions:
|
||||
properties:
|
||||
colorBy:
|
||||
description: Group-by label names (e.g. k8s.namespace.name) whose combined
|
||||
values colour dots and drive the legend. Empty colours by every group-by
|
||||
label.
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
sizeBy:
|
||||
description: Value column key that scales dot size. Empty draws every dot
|
||||
at the default size.
|
||||
type: string
|
||||
x:
|
||||
description: Value column key (queryName, or queryName.expression for a
|
||||
multi-aggregation query) plotted on the x axis. Empty uses the first value
|
||||
column.
|
||||
type: string
|
||||
"y":
|
||||
description: Value column key plotted on the y axis. Empty uses the second
|
||||
value column.
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
|
||||
dimensions:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesTableFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
thresholds:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotThreshold'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesScatterPlotPoints:
|
||||
properties:
|
||||
maxSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
minSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
opacity:
|
||||
$ref: '#/components/schemas/DashboardtypesPointOpacity'
|
||||
size:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
type: object
|
||||
DashboardtypesScatterPlotThreshold:
|
||||
properties:
|
||||
axis:
|
||||
$ref: '#/components/schemas/DashboardtypesThresholdAxis'
|
||||
color:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
- color
|
||||
type: object
|
||||
DashboardtypesSource:
|
||||
enum:
|
||||
- user
|
||||
@@ -5018,6 +5148,11 @@ components:
|
||||
- value
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesThresholdAxis:
|
||||
enum:
|
||||
- x
|
||||
- "y"
|
||||
type: string
|
||||
DashboardtypesThresholdFormat:
|
||||
enum:
|
||||
- text
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"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.",
|
||||
@@ -187,32 +186,8 @@
|
||||
"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",
|
||||
"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.",
|
||||
"to_required": "To field is mandatory",
|
||||
"channel_name_required": "Channel name is mandatory",
|
||||
"bot_token_required": "Bot token is mandatory for creating a telegram channel",
|
||||
"chat_id_required": "Chat ID is mandatory for creating a telegram channel",
|
||||
"telegram_tip": "Create a bot with @BotFather, add it to the chat, then paste its token and the chat's numeric id below.",
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--l2-foreground);
|
||||
color: var(--text-vanilla-400);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,18 +9,18 @@
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--l2-foreground);
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--l2-foreground);
|
||||
color: var(--text-vanilla-400);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
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;
|
||||
@@ -2,7 +2,6 @@ import { TanStackHoverTooltip } from './TanStackHoverTooltip';
|
||||
import { TanStackTableBase } from './TanStackTable';
|
||||
import TanStackTableText from './TanStackTableText';
|
||||
|
||||
export { default as ColumnSelector } from './ColumnSelector';
|
||||
export * from './TanStackHoverTooltip';
|
||||
export * from './TanStackTableStateContext';
|
||||
export * from './types';
|
||||
|
||||
@@ -101,16 +101,6 @@ const SPECS: Record<string, Record<string, unknown>> = {
|
||||
metadata: { team: 'ops' },
|
||||
sendResolved: true,
|
||||
},
|
||||
telegram: {
|
||||
apiUrl: 'https://api.telegram.org',
|
||||
botToken: '123456789:AA-token',
|
||||
chatId: -1001234567890,
|
||||
messageThreadId: 42,
|
||||
title: 't',
|
||||
message: 'm',
|
||||
disableNotifications: true,
|
||||
sendResolved: true,
|
||||
},
|
||||
};
|
||||
|
||||
describe('load then save round trip', () => {
|
||||
|
||||
@@ -22,26 +22,13 @@
|
||||
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: space-between;
|
||||
justify-content: flex-start;
|
||||
gap: var(--spacing-3);
|
||||
padding: var(--spacing-4) 0;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.actionGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
// TODO: @AshwinBhatkal to be removed once textarea is in signoz ui components
|
||||
@@ -49,21 +36,3 @@
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* The @signozhq inputs leave their surface transparent and take the card's
|
||||
colour. antd paints its textareas instead, so beside them they read as a
|
||||
different control, most visibly in light mode. */
|
||||
// TODO: @AshwinBhatkal to be removed once textarea is in signoz ui components
|
||||
.fields :global(textarea.ant-input),
|
||||
.fields :global(textarea.ant-input:hover),
|
||||
.fields :global(textarea.ant-input:focus),
|
||||
.fields :global(textarea.ant-input-focused) {
|
||||
background: var(--input-background, transparent);
|
||||
border-width: var(--input-border-width, 1px);
|
||||
border-style: var(--input-border-style, solid);
|
||||
border-color: var(--input-border-color, var(--border));
|
||||
border-radius: var(--input-border-radius, calc(var(--radius) - 2px));
|
||||
font-size: var(--input-font-size, var(--periscope-font-size-base, 13px));
|
||||
box-shadow: var(--input-box-shadow, none);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
margin-bottom: var(--spacing-6);
|
||||
gap: var(--spacing-1);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.label {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Minus, Plus } from '@signozhq/icons';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input as AntdInput } from 'antd';
|
||||
@@ -10,7 +10,6 @@ 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;
|
||||
@@ -140,19 +139,10 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
|
||||
help={t('help_incidentio_metadata')}
|
||||
>
|
||||
{metadataRows.map((row, index) => (
|
||||
<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')}>
|
||||
// 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}>
|
||||
<Input
|
||||
placeholder={t('placeholder_incidentio_metadata_key')}
|
||||
value={row.key}
|
||||
@@ -165,9 +155,8 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
|
||||
}
|
||||
data-testid={`incidentio-metadata-key-${index}`}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label={t('label_incidentio_metadata_value')}>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<Input
|
||||
placeholder={t('placeholder_incidentio_metadata_value')}
|
||||
value={row.value}
|
||||
@@ -180,10 +169,22 @@ function IncidentIOSettings({ initialMetadata }: IncidentIOProps): JSX.Element {
|
||||
}
|
||||
data-testid={`incidentio-metadata-value-${index}`}
|
||||
/>
|
||||
</FormField>
|
||||
</RowEntry>
|
||||
</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>
|
||||
))}
|
||||
|
||||
<div className={styles.add}>
|
||||
<Button
|
||||
variant="dashed"
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
/* 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;
|
||||
@@ -30,9 +23,7 @@
|
||||
}
|
||||
|
||||
.add {
|
||||
/* 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);
|
||||
margin-top: var(--spacing-1);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
@@ -44,52 +35,3 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Minus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './RowEditor.module.scss';
|
||||
|
||||
interface RowEntryProps {
|
||||
/** Zero-based; shown to the reader as a count from one. */
|
||||
index: number;
|
||||
/** Singular noun for one entry, such as "Field" or "Action". */
|
||||
noun: string;
|
||||
removeLabel: string;
|
||||
removeTestId: string;
|
||||
onRemove: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function RowEntry({
|
||||
index,
|
||||
noun,
|
||||
removeLabel,
|
||||
removeTestId,
|
||||
onRemove,
|
||||
children,
|
||||
}: RowEntryProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.entry}>
|
||||
<div className={styles.entryTop}>
|
||||
<Typography.Text size="sm" className={styles.entryIndex}>
|
||||
{noun} {index + 1}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
icon
|
||||
aria-label={removeLabel}
|
||||
onClick={onRemove}
|
||||
testId={removeTestId}
|
||||
>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.entryGrid}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RowEntry;
|
||||
@@ -42,9 +42,7 @@ 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}
|
||||
@@ -84,12 +82,7 @@ function Slack(): JSX.Element {
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormCard>
|
||||
|
||||
<FormCard
|
||||
title={t('section_advanced')}
|
||||
description={t('description_slack_advanced')}
|
||||
>
|
||||
<FormField
|
||||
label={t('field_slack_color')}
|
||||
help={t('help_slack_color')}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Minus, 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,37 +29,49 @@ function SlackActions(): JSX.Element {
|
||||
const { control, register } = useFormContext<ChannelSpecFormValues>();
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'actions' });
|
||||
|
||||
const keyFor = (column: ActionColumn): string => column.replace('.text', '');
|
||||
const labelFor = (column: ActionColumn): string =>
|
||||
t(`label_slack_action_${column.replace('.text', '')}`);
|
||||
|
||||
return (
|
||||
<FormField
|
||||
label={t('field_slack_actions')}
|
||||
tooltip={t('tooltip_slack_actions')}
|
||||
>
|
||||
<FormField label={t('field_slack_actions')} help={t('help_slack_actions')}>
|
||||
<div>
|
||||
{fields.map((row, index) => (
|
||||
<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)}
|
||||
>
|
||||
{fields.length > 0 && (
|
||||
<div className={styles.row}>
|
||||
{COLUMNS.map((column) => (
|
||||
<FormField
|
||||
key={column}
|
||||
label={t(`label_slack_action_${keyFor(column)}`)}
|
||||
tooltip={t(`tooltip_slack_action_${keyFor(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}>
|
||||
{COLUMNS.map((column) => (
|
||||
<div key={column} className={styles.field}>
|
||||
<Input
|
||||
{...register(`actions.${index}.${column}` as const)}
|
||||
placeholder={t(`placeholder_slack_action_${keyFor(column)}`)}
|
||||
testId={`slack-action-${keyFor(column)}-${index}`}
|
||||
placeholder={t(
|
||||
`placeholder_slack_action_${column.replace('.text', '')}`,
|
||||
)}
|
||||
testId={`slack-action-${column.replace('.text', '')}-${index}`}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
))}
|
||||
</RowEntry>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Minus, Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
@@ -8,7 +8,6 @@ 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.
|
||||
@@ -18,61 +17,52 @@ function SlackFields(): JSX.Element {
|
||||
const { fields, append, remove } = useFieldArray({ control, name: 'fields' });
|
||||
|
||||
return (
|
||||
<FormField
|
||||
label={t('field_slack_fields')}
|
||||
tooltip={t('tooltip_slack_fields')}
|
||||
>
|
||||
<FormField label={t('field_slack_fields')} help={t('help_slack_fields')}>
|
||||
<div>
|
||||
{fields.map((row, index) => (
|
||||
<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')}
|
||||
>
|
||||
<div key={row.id} className={styles.row}>
|
||||
<div className={styles.field}>
|
||||
<Input
|
||||
{...register(`fields.${index}.title`)}
|
||||
placeholder={t('placeholder_slack_field_title')}
|
||||
testId={`slack-field-title-${index}`}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t('label_slack_field_value')}
|
||||
tooltip={t('tooltip_slack_field_value')}
|
||||
>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<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>
|
||||
</RowEntry>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ describe('FormAlertChannels', () => {
|
||||
onTypeChangeHandler={jest.fn()}
|
||||
onSaveHandler={jest.fn()}
|
||||
onTestHandler={jest.fn()}
|
||||
onDiscardHandler={jest.fn()}
|
||||
testingState={false}
|
||||
savingState={false}
|
||||
editing
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -49,12 +48,10 @@ function FormAlertChannels({
|
||||
onTypeChangeHandler,
|
||||
onTestHandler,
|
||||
onSaveHandler,
|
||||
onDiscardHandler,
|
||||
savingState,
|
||||
testingState,
|
||||
editing = false,
|
||||
readOnly = false,
|
||||
hasChanges = true,
|
||||
saveChecks = [],
|
||||
testChecks = [],
|
||||
}: FormAlertChannelsProps): JSX.Element {
|
||||
@@ -118,10 +115,7 @@ function FormAlertChannels({
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t('field_send_resolved')}
|
||||
tooltip={t('tooltip_send_resolved')}
|
||||
>
|
||||
<FormField label={t('field_send_resolved')}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="sendResolved"
|
||||
@@ -137,11 +131,7 @@ function FormAlertChannels({
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t('field_channel_type')}
|
||||
tooltip={t('tooltip_channel_type')}
|
||||
htmlFor="channel-type"
|
||||
>
|
||||
<FormField label={t('field_channel_type')} htmlFor="channel-type">
|
||||
<SelectSimple
|
||||
id="channel-type"
|
||||
items={CHANNEL_TYPE_OPTIONS}
|
||||
@@ -158,45 +148,30 @@ function FormAlertChannels({
|
||||
</fieldset>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
@@ -209,15 +184,12 @@ 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`. */
|
||||
@@ -227,7 +199,6 @@ interface FormAlertChannelsProps {
|
||||
FormAlertChannels.defaultProps = {
|
||||
editing: undefined,
|
||||
readOnly: false,
|
||||
hasChanges: true,
|
||||
saveChecks: [],
|
||||
testChecks: [],
|
||||
};
|
||||
|
||||
@@ -3,21 +3,23 @@ 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);
|
||||
|
||||
@@ -45,7 +47,7 @@ function ColumnSelector<TData>({
|
||||
size="sm"
|
||||
color="secondary"
|
||||
prefix={<Columns3 size={14} />}
|
||||
testId={testId}
|
||||
testId="alert-columns-button"
|
||||
>
|
||||
Columns
|
||||
</Button>
|
||||
@@ -75,8 +77,4 @@ function ColumnSelector<TData>({
|
||||
);
|
||||
}
|
||||
|
||||
ColumnSelector.defaultProps = {
|
||||
testId: 'column-selector-button',
|
||||
};
|
||||
|
||||
export default ColumnSelector;
|
||||
@@ -1 +1,2 @@
|
||||
export { default as ActionsMenu } from './ActionsMenu';
|
||||
export { default as ColumnSelector } from './ColumnSelector';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
|
||||
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
|
||||
import TanStackTable, { ColumnSelector } from 'components/TanStackTableView';
|
||||
import TanStackTable 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 } from './components';
|
||||
import { ActionsMenu, ColumnSelector } from './components';
|
||||
import { ALERT_RULES_PARAMS, useAlertRulesFilters } from './hooks';
|
||||
import styles from './ListAlertRules.module.scss';
|
||||
import { getAlertRuleColumns } from './table.config';
|
||||
@@ -134,11 +134,7 @@ function ListAlertRules(): JSX.Element {
|
||||
{!isEmptyNoRules && (
|
||||
<div className={styles.header}>
|
||||
<div className={styles.refreshRow}>
|
||||
<ColumnSelector
|
||||
columns={columns}
|
||||
storageKey="alert-rules-columns"
|
||||
testId="alert-columns-button"
|
||||
/>
|
||||
<ColumnSelector columns={columns} storageKey="alert-rules-columns" />
|
||||
{addNewAlert && (
|
||||
<Button
|
||||
variant="solid"
|
||||
|
||||
@@ -22,17 +22,12 @@ 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 (isDark(background)) {
|
||||
if (keyColor) {
|
||||
rules.push({ token: 'string.key.json', foreground: keyColor });
|
||||
}
|
||||
if (valueColor) {
|
||||
rules.push({ token: 'string.value.json', foreground: valueColor });
|
||||
}
|
||||
if (keyColor) {
|
||||
rules.push({ token: 'string.key.json', foreground: keyColor });
|
||||
}
|
||||
if (valueColor) {
|
||||
rules.push({ token: 'string.value.json', foreground: valueColor });
|
||||
}
|
||||
|
||||
const colors: Record<string, string> = {};
|
||||
|
||||
@@ -58,12 +58,6 @@ 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. */
|
||||
@@ -111,24 +105,10 @@ 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: 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. */
|
||||
/** Interaction: a search matching nothing offers to clear itself. */
|
||||
export const NoResults: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -140,21 +120,6 @@ export const NoResults: Story = {
|
||||
);
|
||||
|
||||
await canvas.findByText('No matching channels', undefined, UNTIL_LOADED);
|
||||
},
|
||||
};
|
||||
|
||||
/** Interaction: clearing the search from the empty state brings the list back. */
|
||||
export const ClearSearch: Story = {
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
|
||||
await userEvent.type(
|
||||
canvas.getByTestId('channels-search'),
|
||||
'no-such-channel',
|
||||
);
|
||||
await canvas.findByText('No matching channels', undefined, UNTIL_LOADED);
|
||||
|
||||
await userEvent.click(canvas.getByRole('button', { name: 'Clear Search' }));
|
||||
await canvas.findByText('ops-slack', undefined, UNTIL_LOADED);
|
||||
},
|
||||
|
||||
@@ -90,16 +90,13 @@ export const SaveFailure: Story = {
|
||||
// the point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
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(
|
||||
await within(canvasElement).findByTestId(
|
||||
'save-channel-button',
|
||||
undefined,
|
||||
untilLoaded,
|
||||
),
|
||||
);
|
||||
await userEvent.click(canvas.getByTestId('save-channel-button'));
|
||||
|
||||
await screen.findByText('Storybook forced channel failure');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -82,12 +82,6 @@ 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,
|
||||
@@ -174,13 +168,12 @@ export const CheckFailed: Story = {
|
||||
// point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.type(
|
||||
await canvas.findByTestId('slack-channel-textbox', undefined, PAGE_LOAD),
|
||||
'-edited',
|
||||
const save = await within(canvasElement).findByTestId(
|
||||
'save-channel-button',
|
||||
undefined,
|
||||
PAGE_LOAD,
|
||||
);
|
||||
|
||||
await expectDisabled(canvas.getByTestId('save-channel-button'), false);
|
||||
await expectDisabled(save, false);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,11 +16,6 @@ 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 }));
|
||||
@@ -34,8 +29,7 @@ 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. 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.
|
||||
* testing the channel are.
|
||||
*/
|
||||
export const channelsNewMocks = defineStoryMocks({
|
||||
controls: {
|
||||
@@ -49,11 +43,6 @@ 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(
|
||||
@@ -66,10 +55,5 @@ export const channelsNewMocks = defineStoryMocks({
|
||||
values.testOutcome === 'fails' ? rejectChannelAction : resolveChannelTested,
|
||||
),
|
||||
],
|
||||
config: (values) => ({
|
||||
route:
|
||||
values.kindInUrl === 'none'
|
||||
? '/alerts/channels/new'
|
||||
: `/alerts/channels/new?kind=${values.kindInUrl}`,
|
||||
}),
|
||||
config: () => ({ route: '/alerts/channels/new' }),
|
||||
});
|
||||
|
||||
@@ -58,16 +58,9 @@ 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> => {
|
||||
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(
|
||||
await within(canvasElement).findByTestId('save-channel-button'),
|
||||
);
|
||||
await userEvent.click(canvas.getByTestId('save-channel-button'));
|
||||
|
||||
await screen.findByText('Channel name is mandatory');
|
||||
},
|
||||
};
|
||||
@@ -238,38 +231,3 @@ export const RequiredFields: Story = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The type carried in the URL. Filtering the list by a type and pressing its
|
||||
* create button lands here with `?kind=`, so the form opens on that type with
|
||||
* nothing to pick.
|
||||
*/
|
||||
export const KindFromUrl: Story = {
|
||||
args: { kindInUrl: 'jira' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const trigger = await within(canvasElement).findByTestId(
|
||||
'channel-type-select',
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await within(trigger).findByText(/^Jira$/);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A `?kind=` the app does not have. The form opens on its own default rather
|
||||
* than on an empty type, so a stale or hand-edited link still works.
|
||||
*/
|
||||
export const UnknownKindInUrl: Story = {
|
||||
args: { kindInUrl: 'not-a-kind' },
|
||||
play: async ({ canvasElement }): Promise<void> => {
|
||||
const trigger = await within(canvasElement).findByTestId(
|
||||
'channel-type-select',
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
await within(trigger).findByText(/^Slack$/);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, userEvent, within } from 'storybook/test';
|
||||
import { expect, within } from 'storybook/test';
|
||||
|
||||
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
|
||||
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
|
||||
@@ -81,13 +81,12 @@ export const CheckFailed: Story = {
|
||||
// point of the story, not a regression.
|
||||
parameters: { allowConsoleErrors: true },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await userEvent.type(
|
||||
await canvas.findByTestId('channel-name-textbox', undefined, PAGE_LOAD),
|
||||
'check-failed',
|
||||
const save = await within(canvasElement).findByTestId(
|
||||
'save-channel-button',
|
||||
undefined,
|
||||
PAGE_LOAD,
|
||||
);
|
||||
|
||||
await expectDisabled(canvas.getByTestId('save-channel-button'), false);
|
||||
await expectDisabled(save, false);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,12 +11,13 @@
|
||||
--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: 0 var(--spacing-8) var(--spacing-6) var(--spacing-8);
|
||||
padding: var(--spacing-6) var(--spacing-8);
|
||||
}
|
||||
|
||||
/* Title and count on the left, the create action at the far end, the way the
|
||||
@@ -24,7 +25,7 @@
|
||||
.commandHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.headingBlock {
|
||||
|
||||
@@ -7,11 +7,10 @@ import { Skeleton } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import ErrorEmptyState from 'components/Alerts/ErrorEmptyState';
|
||||
import NoResultsEmptyState from 'components/Alerts/NoResultsEmptyState';
|
||||
import TanStackTable, { ColumnSelector } from 'components/TanStackTableView';
|
||||
import TanStackTable 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';
|
||||
@@ -26,9 +25,7 @@ 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,
|
||||
@@ -159,10 +156,6 @@ 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 => {
|
||||
@@ -176,38 +169,27 @@ function NotificationChannels(): JSX.Element {
|
||||
<div className={styles.commandHeader}>
|
||||
<div className={styles.headingBlock}>
|
||||
<Typography.Title className={styles.title}>
|
||||
{filteredKind
|
||||
? `All ${CHANNEL_KIND_LABEL[filteredKind]} channels`
|
||||
: 'All notification 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}>
|
||||
@@ -233,8 +215,12 @@ 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}
|
||||
@@ -259,10 +245,6 @@ function NotificationChannels(): JSX.Element {
|
||||
onClear={handleClearFilters}
|
||||
clearButtonText="Clear Search"
|
||||
/>
|
||||
) : isEmpty ? (
|
||||
<ChannelsEmptyState
|
||||
onCreate={(): void => formView.openCreate(filteredKind)}
|
||||
/>
|
||||
) : (
|
||||
<TanStackTable<Channel>
|
||||
data={channels}
|
||||
|
||||
@@ -72,52 +72,6 @@ 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 () => {
|
||||
@@ -208,20 +162,15 @@ describe('ChannelForm save', () => {
|
||||
|
||||
render(<ChannelForm channelId="3" onDone={jest.fn()} />);
|
||||
|
||||
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');
|
||||
// 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);
|
||||
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();
|
||||
@@ -229,69 +178,6 @@ describe('ChannelForm save', () => {
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'keeps save off until something is edited',
|
||||
async () => {
|
||||
render(<ChannelForm channelId="3" onDone={jest.fn()} />);
|
||||
|
||||
// Save is also off while the permission checks are in flight. Test is
|
||||
// gated on those checks alone, so it settling means Save is only off
|
||||
// because nothing has changed.
|
||||
const testButton = await screen.findByTestId(
|
||||
'test-channel-button',
|
||||
{},
|
||||
FIND,
|
||||
);
|
||||
await waitFor(
|
||||
() => expect(testButton).toHaveAttribute('aria-disabled', 'false'),
|
||||
FIND,
|
||||
);
|
||||
|
||||
const save = screen.getByTestId('save-channel-button');
|
||||
expect(save).toHaveAttribute('aria-disabled', 'true');
|
||||
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
await user.type(screen.getByTestId('slack-channel-textbox'), '-edited');
|
||||
|
||||
await waitFor(
|
||||
() => expect(save).toHaveAttribute('aria-disabled', 'false'),
|
||||
FIND,
|
||||
);
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'asks before discarding an edited form, and leaves straight away otherwise',
|
||||
async () => {
|
||||
const onDone = jest.fn();
|
||||
render(<ChannelForm channelId="3" onDone={onDone} />);
|
||||
|
||||
const user = userEvent.setup({ delay: null, pointerEventsCheck: 0 });
|
||||
const discard = await screen.findByTestId(
|
||||
'discard-channel-button',
|
||||
{},
|
||||
FIND,
|
||||
);
|
||||
|
||||
await user.click(discard);
|
||||
expect(onDone).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.type(screen.getByTestId('slack-channel-textbox'), '-edited');
|
||||
await user.click(discard);
|
||||
|
||||
// the test harness stubs `t`, so the modal renders the keys themselves
|
||||
await screen.findByText('discard_changes_title', {}, FIND);
|
||||
expect(onDone).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'button_discard_confirm' }),
|
||||
);
|
||||
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(2));
|
||||
},
|
||||
TEST_TIMEOUT,
|
||||
);
|
||||
|
||||
it(
|
||||
'explains a channel the v2 API cannot model instead of showing a blank form',
|
||||
async () => {
|
||||
|
||||
@@ -6,27 +6,16 @@ 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,
|
||||
initialKind,
|
||||
onDone,
|
||||
}: ChannelFormProps): JSX.Element {
|
||||
function ChannelForm({ channelId, onDone }: ChannelFormProps): JSX.Element {
|
||||
const { t } = useTranslation('channels');
|
||||
const { draft, isLoading, loadError } = useChannelDraft(
|
||||
channelId,
|
||||
initialKind,
|
||||
);
|
||||
const { draft, isLoading, loadError } = useChannelDraft(channelId);
|
||||
|
||||
const {
|
||||
canEdit,
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
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';
|
||||
@@ -27,54 +24,23 @@ function ChannelFormBody({
|
||||
saveChecks,
|
||||
testChecks,
|
||||
}: ChannelFormBodyProps): JSX.Element {
|
||||
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,
|
||||
});
|
||||
};
|
||||
const { form, type, onTypeChange, onSave, onTest, isSaving, isTesting } =
|
||||
useChannelFormState({ draft, target, onDone });
|
||||
|
||||
return (
|
||||
<>
|
||||
<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}
|
||||
</>
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
ChannelInitialConfig,
|
||||
DEFAULT_CHANNEL_KIND,
|
||||
} from 'container/CreateAlertChannels/defaults';
|
||||
import { ChannelKind } from 'container/CreateAlertChannels/types';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
export interface ChannelDraftResult {
|
||||
@@ -23,10 +22,7 @@ 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,
|
||||
initialKind?: ChannelKind,
|
||||
): ChannelDraftResult {
|
||||
export function useChannelDraft(channelId?: string): ChannelDraftResult {
|
||||
const isEditing = !!channelId;
|
||||
|
||||
const { data, error: loadFailure } = useGetNotificationChannel(
|
||||
@@ -38,10 +34,10 @@ export function useChannelDraft(
|
||||
channelId
|
||||
? null
|
||||
: {
|
||||
kind: initialKind ?? DEFAULT_CHANNEL_KIND,
|
||||
kind: DEFAULT_CHANNEL_KIND,
|
||||
values: {
|
||||
sendResolved: true,
|
||||
...ChannelInitialConfig[initialKind ?? DEFAULT_CHANNEL_KIND],
|
||||
...ChannelInitialConfig[DEFAULT_CHANNEL_KIND],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -31,8 +31,6 @@ 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;
|
||||
@@ -86,13 +84,10 @@ 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 },
|
||||
);
|
||||
});
|
||||
},
|
||||
@@ -182,7 +177,6 @@ export function useChannelFormState({
|
||||
return {
|
||||
form,
|
||||
type,
|
||||
isDirty: form.formState.isDirty,
|
||||
onTypeChange,
|
||||
onSave: form.handleSubmit(submitSave),
|
||||
onTest: form.handleSubmit(submitTest),
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { Bell, Plus } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import { NotificationChannelCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/notification-channel.permissions';
|
||||
|
||||
import styles from './ChannelsEmptyState.module.scss';
|
||||
|
||||
const CREATE_CHECKS = [NotificationChannelCreatePermission];
|
||||
const DOCS_URL = 'https://signoz.io/docs/setup-alerts-notification/';
|
||||
|
||||
interface ChannelsEmptyStateProps {
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
function ChannelsEmptyState({
|
||||
onCreate,
|
||||
}: ChannelsEmptyStateProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.emptyState} data-testid="channels-empty-state">
|
||||
<Bell className={styles.icon} size={16} />
|
||||
<Typography.Text className={styles.title}>
|
||||
No notification channels yet
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.subtitle}>
|
||||
A channel is where an alert ends up. Create one and your alert rules and
|
||||
routing policies can point at it.
|
||||
</Typography.Text>
|
||||
<div className={styles.actions}>
|
||||
<AuthZButton
|
||||
checks={CREATE_CHECKS}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={onCreate}
|
||||
testId="channels-empty-create"
|
||||
>
|
||||
New channel
|
||||
</AuthZButton>
|
||||
</div>
|
||||
<a
|
||||
className={styles.docsLink}
|
||||
href={DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="channels-empty-docs"
|
||||
>
|
||||
Read the setup guide
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChannelsEmptyState;
|
||||
@@ -4,7 +4,7 @@ import history from 'lib/history';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
export interface ChannelFormView {
|
||||
openCreate: (kind?: string) => void;
|
||||
openCreate: () => void;
|
||||
openEdit: (channelId: string, options?: { newTab?: boolean }) => void;
|
||||
}
|
||||
|
||||
@@ -22,10 +22,7 @@ export function useChannelFormView(): ChannelFormView {
|
||||
};
|
||||
|
||||
return {
|
||||
openCreate: (kind?: string): void =>
|
||||
history.push(
|
||||
kind ? `${ROUTES.CHANNELS_NEW}?kind=${kind}` : ROUTES.CHANNELS_NEW,
|
||||
),
|
||||
openCreate: (): void => history.push(ROUTES.CHANNELS_NEW),
|
||||
openEdit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -791,22 +791,6 @@ const CHANNEL_SPECS: Record<ChannelType, Record<string, unknown>> = {
|
||||
channel: '#ops-alerts',
|
||||
title: '[{{ .Status | toUpper }}] {{ .CommonLabels.alertname }}',
|
||||
text: '{{ range .Alerts -}}*Alert:* {{ .Labels.alertname }}\n{{ end }}',
|
||||
// one of each so the repeating row editors render with content
|
||||
fields: [
|
||||
{ title: 'Severity', value: '{{ .CommonLabels.severity }}', short: true },
|
||||
{
|
||||
title: 'Service',
|
||||
value: '{{ .CommonLabels.service_name }}',
|
||||
short: false,
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
type: 'button',
|
||||
text: 'Runbook',
|
||||
url: 'https://runbook.example.com/{{ .CommonLabels.alertname }}',
|
||||
},
|
||||
],
|
||||
sendResolved: true,
|
||||
},
|
||||
webhook: {
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
padding: var(--spacing-6);
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: var(--spacing-4) var(--spacing-6) var(--spacing-6);
|
||||
.content {
|
||||
padding: 0 var(--spacing-6) var(--spacing-6);
|
||||
}
|
||||
|
||||
.crumbName {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Spinner } from '@signozhq/ui/spinner';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { useGetNotificationChannel } from 'api/generated/services/channels';
|
||||
import BackHeader from 'components/BackHeader/BackHeader';
|
||||
import AlertBreadcrumb from 'components/AlertBreadcrumb';
|
||||
import ROUTES from 'constants/routes';
|
||||
import history from 'lib/history';
|
||||
import ChannelForm from 'pages/AlertList/NotificationChannels/components/ChannelForm/ChannelForm';
|
||||
@@ -14,45 +13,42 @@ interface ChannelsEditPageProps {
|
||||
}
|
||||
|
||||
function ChannelsEditPage({ channelId }: ChannelsEditPageProps): JSX.Element {
|
||||
const { data, isLoading } = useGetNotificationChannel({ id: channelId });
|
||||
const { data } = useGetNotificationChannel({ id: channelId });
|
||||
|
||||
return (
|
||||
<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'}
|
||||
<>
|
||||
<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');
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<CopyButton
|
||||
value={channelId}
|
||||
ariaLabel="Copy channel ID"
|
||||
testId="copy-channel-id"
|
||||
onCopy={(): void => {
|
||||
toast.success('Channel ID copied to clipboard');
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
),
|
||||
isLast: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ChannelForm
|
||||
channelId={channelId}
|
||||
onDone={(): void => {
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<ChannelForm
|
||||
channelId={channelId}
|
||||
onDone={(): void => {
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,46 +1,30 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Skeleton } from 'antd';
|
||||
import BackHeader from 'components/BackHeader/BackHeader';
|
||||
import AlertBreadcrumb from 'components/AlertBreadcrumb';
|
||||
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 (
|
||||
<div className={styles.page}>
|
||||
<BackHeader
|
||||
backLabel="All channels"
|
||||
backRoute={listRoute}
|
||||
title="New channel"
|
||||
testId="channel-new-header"
|
||||
<>
|
||||
<AlertBreadcrumb
|
||||
items={[
|
||||
{ title: 'Channels', route: ROUTES.ALL_CHANNELS },
|
||||
{ title: 'New channel', isLast: true },
|
||||
]}
|
||||
/>
|
||||
<ChannelForm
|
||||
initialKind={initialKind}
|
||||
onDone={(): void => {
|
||||
history.replace(listRoute);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<ChannelForm
|
||||
onDone={(): void => {
|
||||
history.replace(ROUTES.ALL_CHANNELS);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
padding: var(--spacing-6);
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: var(--spacing-4) var(--spacing-6) var(--spacing-6);
|
||||
.content {
|
||||
padding: 0 var(--spacing-6) var(--spacing-6);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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. */
|
||||
@@ -17,8 +16,6 @@ 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';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,7 +29,6 @@ function CopyButton({
|
||||
ariaLabel = 'Copy',
|
||||
testId,
|
||||
onCopy,
|
||||
tooltipSide = 'top',
|
||||
}: CopyButtonProps): JSX.Element {
|
||||
const { copyToClipboard, isCopied } = useCopyButton();
|
||||
|
||||
@@ -48,27 +44,20 @@ function CopyButton({
|
||||
const stackStyle: CSSProperties = { width: size, height: size };
|
||||
|
||||
return (
|
||||
// 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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -123,10 +123,22 @@ func (d *DashboardSpec) validatePanels() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// validatePanelQueryCount left exactly one query for a spec that reads it.
|
||||
if spec, ok := panel.Spec.Plugin.Spec.(queryBoundPanelSpec); ok {
|
||||
if err := spec.validateAgainstQuery(panel.Spec.Queries[0], path+".spec.plugin.spec"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryBoundPanelSpec is a panel spec with fields that depend on the panel's
|
||||
// query. It is checked after the query itself validates.
|
||||
type queryBoundPanelSpec interface {
|
||||
validateAgainstQuery(query Query, path string) error
|
||||
}
|
||||
|
||||
func validatePanelQueryCount(queries []Query, panelKind PanelPluginKind, path string) error {
|
||||
if queries == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: is required and must not be null; use [] for a panel that renders without a query", path)
|
||||
|
||||
@@ -1870,6 +1870,319 @@ func TestInvalidateAreaChartPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScatterPlotPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/ScatterPlotPanel",
|
||||
"spec": {}
|
||||
},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
d, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &ScatterPlotPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
|
||||
|
||||
assert.Empty(t, spec.Dimensions.X, "an omitted x stays empty so the renderer picks the first value column")
|
||||
assert.Empty(t, spec.Dimensions.Y, "an omitted y stays empty so the renderer picks the second value column")
|
||||
assert.Empty(t, spec.Dimensions.ColorBy, "an omitted colorBy stays empty so the renderer colours by every group-by label")
|
||||
assert.Equal(t, "auto", spec.Axes.X.Scale.ValueOrDefault(), "expected x scale default auto")
|
||||
assert.Equal(t, "auto", spec.Axes.Y.Scale.ValueOrDefault(), "expected y scale default auto")
|
||||
assert.Nil(t, spec.Axes.X.SoftMin, "expected x softMin unset")
|
||||
assert.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
|
||||
assert.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
|
||||
assert.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
|
||||
|
||||
output, err := json.Marshal(d)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
assert.Contains(t, string(output), `"axes":{"x":{"softMin":null,"softMax":null,"scale":"auto","label":""},"y":{"softMin":null,"softMax":null,"scale":"auto","label":""}}`, "expected default axes in stored/response JSON")
|
||||
assert.Contains(t, string(output), `"dimensions":{"x":"","y":"","sizeBy":"","colorBy":null}`, "expected empty dimensions in stored/response JSON")
|
||||
assert.Contains(t, string(output), `"chartAppearance":{"points":{"size":null,"minSize":null,"maxSize":null,"opacity":null}}`, "omitted point settings stay null so the renderer applies its defaults")
|
||||
}
|
||||
|
||||
func TestValidateScatterPlotDashboard(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/perses_scatter_plot_panel.json")
|
||||
require.NoError(t, err, "reading example file")
|
||||
|
||||
spec, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &ScatterPlotPanelSpec{}, spec.Panels["p1"].Spec.Plugin.Spec)
|
||||
panelSpec := spec.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
|
||||
softMin, softMax := 0.0, 1000.0
|
||||
size, minSize, maxSize, opacity := PointDiameter(8), PointDiameter(4), PointDiameter(24), PointOpacity(0.1)
|
||||
assert.Equal(t, &ScatterPlotPanelSpec{
|
||||
Visualization: BasicVisualization{TimePreference: TimePreferenceLast1Hr},
|
||||
Dimensions: ScatterPlotDimensions{X: "A.count()", Y: "A.p99(duration_nano)", SizeBy: "A.countIf(has_error = true)", ColorBy: []string{"k8s.namespace.name", "k8s.pod.name"}},
|
||||
Formatting: TableFormatting{ColumnUnits: map[string]string{"A.p99(duration_nano)": "ns"}, DecimalPrecision: PrecisionOption1},
|
||||
Axes: ScatterPlotAxes{
|
||||
X: ScatterPlotAxis{SoftMin: &softMin, Scale: AxisScaleLog, Label: "Throughput"},
|
||||
Y: ScatterPlotAxis{SoftMax: &softMax, Scale: AxisScaleSymlog, Label: "p99 latency"},
|
||||
},
|
||||
ChartAppearance: ScatterPlotChartAppearance{Points: ScatterPlotPoints{Size: &size, MinSize: &minSize, MaxSize: &maxSize, Opacity: &opacity}},
|
||||
Legend: Legend{Position: LegendPositionRight, Mode: LegendModeList, CustomColors: map[string]string{"checkout": "#1f77b4"}},
|
||||
Thresholds: []ScatterPlotThreshold{
|
||||
{ThresholdWithLabel: ThresholdWithLabel{Value: 300, Unit: "ms", Color: "#f00", Label: "p99 SLO"}, Axis: ThresholdAxisY},
|
||||
{ThresholdWithLabel: ThresholdWithLabel{Value: 50, Color: "#0f0"}, Axis: ThresholdAxisX},
|
||||
},
|
||||
}, panelSpec)
|
||||
|
||||
dashboard := &DashboardV2{Spec: *spec}
|
||||
request, err := dashboard.GetPanelQuery(1, 2, "p1")
|
||||
require.NoError(t, err, "building the panel's query failed")
|
||||
assert.Equal(t, qb.RequestTypeScalar, request.RequestType)
|
||||
require.NotNil(t, request.FormatOptions)
|
||||
assert.True(t, request.FormatOptions.FormatTableResultForUI, "scatter rows are joined the way a table's are")
|
||||
|
||||
require.Len(t, request.CompositeQuery.Queries, 1)
|
||||
query, ok := request.CompositeQuery.Queries[0].Spec.(qb.QueryBuilderQuery[qb.TraceAggregation])
|
||||
require.True(t, ok, "expected a traces builder query")
|
||||
assert.Len(t, query.Aggregations, 3)
|
||||
assert.Len(t, query.GroupBy, 2)
|
||||
|
||||
require.NoError(t, request.Validate(), "the request built from the panel is not a valid scalar request")
|
||||
|
||||
// the panel read back out of storage draws the same scatter plot
|
||||
stored, err := json.Marshal(spec)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
reread, err := unmarshalDashboard(stored)
|
||||
require.NoError(t, err, "the stored dashboard does not validate")
|
||||
assert.Equal(t, panelSpec, reread.Panels["p1"].Spec.Plugin.Spec)
|
||||
rereadRequest, err := (&DashboardV2{Spec: *reread}).GetPanelQuery(1, 2, "p1")
|
||||
require.NoError(t, err, "building the stored panel's query failed")
|
||||
assert.Equal(t, request, rereadRequest)
|
||||
}
|
||||
|
||||
func TestInvalidateScatterPlotPanelSpecValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
panelKind string
|
||||
panelSpec string
|
||||
expectedErrorSubstring string
|
||||
}{
|
||||
{
|
||||
scenario: "unknown axis scale",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"x": {"scale": "sqrt"}}}`,
|
||||
expectedErrorSubstring: "invalid axis scale \"sqrt\": must be `auto`, `linear`, `log`, or `symlog`",
|
||||
},
|
||||
{
|
||||
scenario: "non-string axis scale",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"y": {"scale": true}}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal bool",
|
||||
},
|
||||
{
|
||||
scenario: "isLogScale on a scatter plot axis",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"x": {"isLogScale": true}}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "single-axis axes shape on a scatter plot panel",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"softMin": 0}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "unknown threshold axis",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"thresholds": [{"value": 1, "color": "#f00", "axis": "z"}]}`,
|
||||
expectedErrorSubstring: "invalid threshold axis \"z\": must be `x` or `y`",
|
||||
},
|
||||
{
|
||||
scenario: "unknown dimension",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"dimensions": {"z": "A"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "point size below the minimum",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"size": 1}}}`,
|
||||
expectedErrorSubstring: "invalid point diameter 1: must be a whole number between 2 and 40",
|
||||
},
|
||||
{
|
||||
scenario: "max point size above the maximum",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"maxSize": 41}}}`,
|
||||
expectedErrorSubstring: "invalid point diameter 41",
|
||||
},
|
||||
{
|
||||
scenario: "fractional point size",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"minSize": 4.5}}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal number 4.5",
|
||||
},
|
||||
{
|
||||
scenario: "min point size above max",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"minSize": 20, "maxSize": 10}}}`,
|
||||
expectedErrorSubstring: "minSize 20 must not exceed maxSize 10",
|
||||
},
|
||||
{
|
||||
scenario: "point opacity below the floor",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"opacity": 0.05}}}`,
|
||||
expectedErrorSubstring: "invalid point opacity 0.05: must be between 0.1 and 1",
|
||||
},
|
||||
{
|
||||
scenario: "point opacity as a percentage",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"opacity": 70}}}`,
|
||||
expectedErrorSubstring: "invalid point opacity 70",
|
||||
},
|
||||
{
|
||||
scenario: "area fill fields on a scatter plot",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"fillOpacity": 0.5}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "a single colour key as a string",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"dimensions": {"colorBy": "service.name"}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal string",
|
||||
},
|
||||
{
|
||||
scenario: "panel-wide unit on a scatter plot panel",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"formatting": {"unit": "ms"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "threshold without a color",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"thresholds": [{"value": 300}]}`,
|
||||
expectedErrorSubstring: "Color' Error:Field validation for 'Color' failed on the 'required' tag",
|
||||
},
|
||||
{
|
||||
scenario: "scale on a time series panel",
|
||||
panelKind: "signoz/TimeSeriesPanel",
|
||||
panelSpec: `{"axes": {"scale": "log"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.scenario, func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "` + test.panelKind + `", "spec": ` + test.panelSpec + `},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected the spec to be rejected")
|
||||
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScatterPlotDimensionsBindToQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
queryPlugin string
|
||||
dimensions string
|
||||
expectedErrorSubstring string
|
||||
}{
|
||||
{
|
||||
scenario: "a single-aggregation query is one value column keyed by its name",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}}`,
|
||||
dimensions: `{"x": "A", "colorBy": ["service.name"]}`,
|
||||
},
|
||||
{
|
||||
scenario: "a multi-aggregation query keys each value column by name and expression",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}, {"expression": "p99(duration_nano)"}]}}`,
|
||||
dimensions: `{"x": "A"}`,
|
||||
expectedErrorSubstring: "dimensions.x: \"A\" is not a value column of the panel's query; value columns: `A.count()`, `A.p99(duration_nano)`",
|
||||
},
|
||||
{
|
||||
scenario: "a value column of a query the panel does not have",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}`,
|
||||
dimensions: `{"y": "B"}`,
|
||||
expectedErrorSubstring: "dimensions.y: \"B\" is not a value column of the panel's query; value columns: `A`",
|
||||
},
|
||||
{
|
||||
scenario: "a colour key the query does not group by",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}}`,
|
||||
dimensions: `{"colorBy": ["service.name", "k8s.pod.name"]}`,
|
||||
expectedErrorSubstring: "dimensions.colorBy: \"k8s.pod.name\" is not a group-by key of the panel's query; group-by keys: `service.name`",
|
||||
},
|
||||
{
|
||||
scenario: "a formula is a value column and carries the labels of the disabled queries it reads",
|
||||
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": true, "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "disabled": true, "aggregations": [{"expression": "countIf(has_error = true)"}], "groupBy": [{"name": "service.name"}]}},
|
||||
{"type": "builder_formula", "spec": {"name": "F1", "expression": "B / A"}}
|
||||
]}}`,
|
||||
dimensions: `{"x": "F1", "colorBy": ["service.name"]}`,
|
||||
},
|
||||
{
|
||||
scenario: "a disabled query yields no value column",
|
||||
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "disabled": true, "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_formula", "spec": {"name": "F1", "expression": "A * 2"}}
|
||||
]}}`,
|
||||
dimensions: `{"sizeBy": "A"}`,
|
||||
expectedErrorSubstring: "dimensions.sizeBy: \"A\" is not a value column of the panel's query; value columns: `F1`",
|
||||
},
|
||||
{
|
||||
scenario: "clickhouse columns are only known at run time, so the bindings go unchecked",
|
||||
queryPlugin: `{"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}`,
|
||||
dimensions: `{"x": "throughput", "colorBy": ["pod"]}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.scenario, func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/ScatterPlotPanel", "spec": {"dimensions": ` + test.dimensions + `}},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": ` + test.queryPlugin + `}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
if test.expectedErrorSubstring == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err, "expected the binding to be rejected")
|
||||
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumberPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
@@ -2228,7 +2541,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
switch panelKind {
|
||||
case "signoz/ListPanel":
|
||||
return "raw"
|
||||
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel":
|
||||
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel", "signoz/ScatterPlotPanel":
|
||||
return "scalar"
|
||||
default:
|
||||
return "time_series"
|
||||
@@ -2272,14 +2585,17 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
{"List+Builder", mkQuery("signoz/ListPanel", "signoz/BuilderQuery", `{"name":"A","signal":"logs"}`), false},
|
||||
{"TimeSeries+AIBuilder", mkQuery("signoz/TimeSeriesPanel", "signoz/AIBuilderQuery", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
|
||||
{"List+AIBuilder", mkQuery("signoz/ListPanel", "signoz/AIBuilderQuery", `{"name":"A"}`), false},
|
||||
{"ScatterPlot+ClickHouse", mkQuery("signoz/ScatterPlotPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
// Top-level: rejected
|
||||
{"Table+PromQL", mkQuery("signoz/TablePanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"ScatterPlot+PromQL", mkQuery("signoz/ScatterPlotPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"List+ClickHouse", mkQuery("signoz/ListPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), true},
|
||||
{"List+PromQL", mkQuery("signoz/ListPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"List+Composite", mkQuery("signoz/ListPanel", "signoz/CompositeQuery", `{"queries":[]}`), true},
|
||||
{"List+Formula", mkQuery("signoz/ListPanel", "signoz/Formula", `{"name":"F1","expression":"A+B"}`), true},
|
||||
// Composite sub-queries
|
||||
{"Table+Composite(promql)", mkComposite("signoz/TablePanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
{"ScatterPlot+Composite(promql)", mkComposite("signoz/ScatterPlotPanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
{"Table+Composite(clickhouse)", mkComposite("signoz/TablePanel", "clickhouse_sql", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
{"Table+Composite(builder_ai)", mkComposite("signoz/TablePanel", "builder_ai_query", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
|
||||
}
|
||||
|
||||
@@ -28,16 +28,17 @@ type PanelPlugin struct {
|
||||
// (and strips the duplicate parent properties) after reflection.
|
||||
func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return markDiscriminator(s, "kind", map[string]string{
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -71,6 +72,7 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
|
||||
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
|
||||
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
|
||||
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
|
||||
PanelPluginVariant[ScatterPlotPanelSpec]{Kind: string(PanelKindScatterPlot)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,16 +236,17 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
|
||||
|
||||
var (
|
||||
panelPluginSpecs = map[PanelPluginKind]func() any{
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
|
||||
}
|
||||
queryPluginSpecs = map[QueryPluginKind]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
@@ -260,16 +263,17 @@ var (
|
||||
VariableKindCustom: func() any { return new(CustomVariableSpec) },
|
||||
}
|
||||
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
|
||||
CompositeQuery: composite,
|
||||
FormatOptions: &qb.FormatOptions{
|
||||
FillGaps: fillGaps,
|
||||
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable,
|
||||
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable || panel.Spec.Plugin.Kind == PanelKindScatterPlot,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -251,34 +251,38 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
|
||||
assert.Equal(t, telemetrytypes.SignalTraces, spec.Signal)
|
||||
})
|
||||
|
||||
t.Run("sets FormatTableResultForUI only for table panels", func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: PanelKindTable},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeScalar,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
t.Run("sets FormatTableResultForUI for panels that join scalar rows", func(t *testing.T) {
|
||||
for _, kind := range []PanelPluginKind{PanelKindTable, PanelKindScatterPlot} {
|
||||
t.Run(string(kind), func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: kind},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeScalar,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.True(t, req.FormatOptions.FormatTableResultForUI)
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.True(t, req.FormatOptions.FormatTableResultForUI)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sets FillGaps from the panel visualization", func(t *testing.T) {
|
||||
|
||||
@@ -2,6 +2,8 @@ package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -187,20 +189,21 @@ func (AIBuilderQuerySpec) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
type PanelPluginKind string
|
||||
|
||||
const (
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
|
||||
)
|
||||
|
||||
func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap}
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot}
|
||||
}
|
||||
|
||||
func (k PanelPluginKind) rendersWithoutQuery() bool {
|
||||
@@ -350,6 +353,95 @@ type TextPanelSpec struct {
|
||||
HeaderOptions HeaderOptions `json:"headerOptions"`
|
||||
}
|
||||
|
||||
type ScatterPlotPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Dimensions ScatterPlotDimensions `json:"dimensions"`
|
||||
Formatting TableFormatting `json:"formatting"`
|
||||
Axes ScatterPlotAxes `json:"axes"`
|
||||
ChartAppearance ScatterPlotChartAppearance `json:"chartAppearance"`
|
||||
Legend Legend `json:"legend"`
|
||||
Thresholds []ScatterPlotThreshold `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
func (s *ScatterPlotPanelSpec) validateAgainstQuery(query Query, path string) error {
|
||||
composite, err := query.Spec.Plugin.buildV5CompositeQueryFromPlugin()
|
||||
if err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.dimensions: %s", path, err.Error())
|
||||
}
|
||||
keys, known := composite.CollectResultColumnKeys()
|
||||
if !known {
|
||||
return nil
|
||||
}
|
||||
for _, binding := range []struct{ field, key string }{{"x", s.Dimensions.X}, {"y", s.Dimensions.Y}, {"sizeBy", s.Dimensions.SizeBy}} {
|
||||
// Empty is unset: the renderer picks a column or a fixed size.
|
||||
if binding.key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := keys.Aggregations[binding.key]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.dimensions.%s: %q is not a value column of the panel's query; value columns: %s", path, binding.field, binding.key, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Aggregations))))
|
||||
}
|
||||
}
|
||||
for _, label := range s.Dimensions.ColorBy {
|
||||
if _, ok := keys.Groups[label]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.dimensions.colorBy: %q is not a group-by key of the panel's query; group-by keys: %s", path, label, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Groups))))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScatterPlotDimensions binds result columns to what a dot encodes. Value columns
|
||||
// use the Table column key, so formatting.columnUnits carries over between the
|
||||
// two kinds.
|
||||
type ScatterPlotDimensions struct {
|
||||
X string `json:"x" description:"Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column."`
|
||||
Y string `json:"y" description:"Value column key plotted on the y axis. Empty uses the second value column."`
|
||||
SizeBy string `json:"sizeBy" description:"Value column key that scales dot size. Empty draws every dot at the default size."`
|
||||
ColorBy []string `json:"colorBy" description:"Group-by label names (e.g. k8s.namespace.name) whose combined values colour dots and drive the legend. Empty colours by every group-by label."`
|
||||
}
|
||||
|
||||
type ScatterPlotAxes struct {
|
||||
X ScatterPlotAxis `json:"x"`
|
||||
Y ScatterPlotAxis `json:"y"`
|
||||
}
|
||||
|
||||
type ScatterPlotAxis struct {
|
||||
SoftMin *float64 `json:"softMin"`
|
||||
SoftMax *float64 `json:"softMax"`
|
||||
Scale AxisScale `json:"scale"`
|
||||
Label string `json:"label" description:"Axis title. Empty draws none."`
|
||||
}
|
||||
|
||||
type ScatterPlotChartAppearance struct {
|
||||
Points ScatterPlotPoints `json:"points"`
|
||||
}
|
||||
|
||||
// ScatterPlotPoints keeps the fixed size and the size range side by side so
|
||||
// binding or unbinding dimensions.sizeBy restores the other's last setting. Nil
|
||||
// fields resolve to the renderer default.
|
||||
type ScatterPlotPoints struct {
|
||||
Size *PointDiameter `json:"size" description:"Diameter of every dot when dimensions.sizeBy is unset."`
|
||||
MinSize *PointDiameter `json:"minSize" description:"Diameter of the smallest dot when dimensions.sizeBy is set."`
|
||||
MaxSize *PointDiameter `json:"maxSize" description:"Diameter of the largest dot when dimensions.sizeBy is set."`
|
||||
Opacity *PointOpacity `json:"opacity"`
|
||||
}
|
||||
|
||||
func (p *ScatterPlotPoints) UnmarshalJSON(data []byte) error {
|
||||
type alias ScatterPlotPoints
|
||||
var tmp alias
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid points")
|
||||
}
|
||||
*p = ScatterPlotPoints(tmp)
|
||||
return p.validate()
|
||||
}
|
||||
|
||||
func (p ScatterPlotPoints) validate() error {
|
||||
if p.MinSize != nil && p.MaxSize != nil && *p.MinSize > *p.MaxSize {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid points: minSize %d must not exceed maxSize %d", *p.MinSize, *p.MaxSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TextPresentation struct {
|
||||
TextAlign TextAlign `json:"textAlign"`
|
||||
VerticalAlign VerticalAlign `json:"verticalAlign"`
|
||||
@@ -431,6 +523,11 @@ type ComparisonThreshold struct {
|
||||
Format ThresholdFormat `json:"format"`
|
||||
}
|
||||
|
||||
type ScatterPlotThreshold struct {
|
||||
ThresholdWithLabel
|
||||
Axis ThresholdAxis `json:"axis"`
|
||||
}
|
||||
|
||||
type TableThreshold struct {
|
||||
ComparisonThreshold
|
||||
ColumnName string `json:"columnName" validate:"required" required:"true"`
|
||||
@@ -716,6 +813,86 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
// AxisScale `auto` leaves the choice to the renderer; `symlog` is log-like but
|
||||
// defined at and below zero.
|
||||
type AxisScale struct{ valuer.String }
|
||||
|
||||
var (
|
||||
AxisScaleAuto = AxisScale{valuer.NewString("auto")} // default
|
||||
AxisScaleLinear = AxisScale{valuer.NewString("linear")}
|
||||
AxisScaleLog = AxisScale{valuer.NewString("log")}
|
||||
AxisScaleSymlog = AxisScale{valuer.NewString("symlog")}
|
||||
)
|
||||
|
||||
func (AxisScale) Enum() []any {
|
||||
return []any{AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog}
|
||||
}
|
||||
|
||||
func (as AxisScale) ValueOrDefault() string {
|
||||
if as.IsZero() {
|
||||
return AxisScaleAuto.StringValue()
|
||||
}
|
||||
return as.StringValue()
|
||||
}
|
||||
|
||||
func (as AxisScale) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(as.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (as *AxisScale) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid axis scale: must be a string, one of `auto`, `linear`, `log`, or `symlog`")
|
||||
}
|
||||
val := AxisScale{valuer.NewString(v)}
|
||||
switch val {
|
||||
case AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog:
|
||||
*as = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid axis scale %q: must be `auto`, `linear`, `log`, or `symlog`", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ThresholdAxis is the axis a scatter plot threshold line marks: `y` draws it
|
||||
// horizontally, `x` vertically.
|
||||
type ThresholdAxis struct{ valuer.String }
|
||||
|
||||
var (
|
||||
ThresholdAxisX = ThresholdAxis{valuer.NewString("x")}
|
||||
ThresholdAxisY = ThresholdAxis{valuer.NewString("y")} // default
|
||||
)
|
||||
|
||||
func (ThresholdAxis) Enum() []any {
|
||||
return []any{ThresholdAxisX, ThresholdAxisY}
|
||||
}
|
||||
|
||||
func (ta ThresholdAxis) ValueOrDefault() string {
|
||||
if ta.IsZero() {
|
||||
return ThresholdAxisY.StringValue()
|
||||
}
|
||||
return ta.StringValue()
|
||||
}
|
||||
|
||||
func (ta ThresholdAxis) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(ta.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (ta *ThresholdAxis) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid threshold axis: must be a string, one of `x` or `y`")
|
||||
}
|
||||
val := ThresholdAxis{valuer.NewString(v)}
|
||||
switch val {
|
||||
case ThresholdAxisX, ThresholdAxisY:
|
||||
*ta = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid threshold axis %q: must be `x` or `y`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type FillMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
@@ -854,6 +1031,53 @@ func (o *FillOpacity) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
minPointDiameter = 2
|
||||
maxPointDiameter = 40
|
||||
)
|
||||
|
||||
// PointDiameter is a scatter dot's diameter in whole CSS pixels.
|
||||
type PointDiameter int
|
||||
|
||||
func (PointDiameter) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
s.WithMinimum(minPointDiameter).WithMaximum(maxPointDiameter)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *PointDiameter) UnmarshalJSON(data []byte) error {
|
||||
var v int
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point diameter: must be a whole number between %d and %d", minPointDiameter, maxPointDiameter)
|
||||
}
|
||||
if v < minPointDiameter || v > maxPointDiameter {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point diameter %d: must be a whole number between %d and %d", v, minPointDiameter, maxPointDiameter)
|
||||
}
|
||||
*d = PointDiameter(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
const minPointOpacity = 0.1
|
||||
|
||||
// PointOpacity is a scatter dot's fill alpha. The floor keeps a dot visible.
|
||||
type PointOpacity float64
|
||||
|
||||
func (PointOpacity) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
s.WithMinimum(minPointOpacity).WithMaximum(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *PointOpacity) UnmarshalJSON(data []byte) error {
|
||||
var v float64
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point opacity: must be a number between %v and 1", minPointOpacity)
|
||||
}
|
||||
if v < minPointOpacity || v > 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point opacity %v: must be between %v and 1", v, minPointOpacity)
|
||||
}
|
||||
*o = PointOpacity(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
type SpanGaps struct {
|
||||
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
|
||||
FillLessThan string `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`
|
||||
|
||||
134
pkg/types/dashboardtypes/testdata/perses_scatter_plot_panel.json
vendored
Normal file
134
pkg/types/dashboardtypes/testdata/perses_scatter_plot_panel.json
vendored
Normal file
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"display": {
|
||||
"name": "service health",
|
||||
"description": "throughput against tail latency per pod"
|
||||
},
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "throughput vs p99",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/ScatterPlotPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "last_1_hr"
|
||||
},
|
||||
"dimensions": {
|
||||
"x": "A.count()",
|
||||
"y": "A.p99(duration_nano)",
|
||||
"sizeBy": "A.countIf(has_error = true)",
|
||||
"colorBy": ["k8s.namespace.name", "k8s.pod.name"]
|
||||
},
|
||||
"formatting": {
|
||||
"columnUnits": {
|
||||
"A.p99(duration_nano)": "ns"
|
||||
},
|
||||
"decimalPrecision": "1"
|
||||
},
|
||||
"axes": {
|
||||
"x": {
|
||||
"softMin": 0,
|
||||
"softMax": null,
|
||||
"scale": "log",
|
||||
"label": "Throughput"
|
||||
},
|
||||
"y": {
|
||||
"softMin": null,
|
||||
"softMax": 1000,
|
||||
"scale": "symlog",
|
||||
"label": "p99 latency"
|
||||
}
|
||||
},
|
||||
"chartAppearance": {
|
||||
"points": {
|
||||
"size": 8,
|
||||
"minSize": 4,
|
||||
"maxSize": 24,
|
||||
"opacity": 0.1
|
||||
}
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"mode": "list",
|
||||
"customColors": {
|
||||
"checkout": "#1f77b4"
|
||||
}
|
||||
},
|
||||
"thresholds": [
|
||||
{
|
||||
"value": 300,
|
||||
"unit": "ms",
|
||||
"color": "#f00",
|
||||
"label": "p99 SLO",
|
||||
"axis": "y"
|
||||
},
|
||||
{
|
||||
"value": 50,
|
||||
"color": "#0f0",
|
||||
"axis": "x"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"queries": [
|
||||
{
|
||||
"kind": "scalar",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count()"
|
||||
},
|
||||
{
|
||||
"expression": "p99(duration_nano)"
|
||||
},
|
||||
{
|
||||
"expression": "countIf(has_error = true)"
|
||||
}
|
||||
],
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "k8s.namespace.name"
|
||||
},
|
||||
{
|
||||
"name": "k8s.pod.name"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 12,
|
||||
"height": 8,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/p1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -266,6 +266,43 @@ type CompositeQuery struct {
|
||||
Queries []QueryEnvelope `json:"queries"`
|
||||
}
|
||||
|
||||
// ResultColumnKeys is what a scalar query yields per row, keyed the way the
|
||||
// UI keys columns: an aggregation column is the query name, or
|
||||
// queryName.expression when the query has several aggregations; a group
|
||||
// column is a group-by key name.
|
||||
type ResultColumnKeys struct {
|
||||
Aggregations map[string]struct{}
|
||||
Groups map[string]struct{}
|
||||
}
|
||||
|
||||
// CollectResultColumnKeys reports false when a query's columns are only known
|
||||
// at run time, as for ClickHouse SQL and PromQL. A disabled query yields no
|
||||
// aggregation column but keeps its group columns, since a formula over it
|
||||
// still carries them.
|
||||
func (c *CompositeQuery) CollectResultColumnKeys() (ResultColumnKeys, bool) {
|
||||
keys := ResultColumnKeys{Aggregations: map[string]struct{}{}, Groups: map[string]struct{}{}}
|
||||
for _, envelope := range c.Queries {
|
||||
if envelope.GetType() == QueryTypeClickHouseSQL || envelope.GetType() == QueryTypePromQL {
|
||||
return ResultColumnKeys{}, false
|
||||
}
|
||||
for _, key := range envelope.GetGroupBy() {
|
||||
keys.Groups[key.Name] = struct{}{}
|
||||
}
|
||||
if envelope.IsDisabled() {
|
||||
continue
|
||||
}
|
||||
expressions := envelope.GetAggregationExpressions()
|
||||
if len(expressions) <= 1 {
|
||||
keys.Aggregations[envelope.GetQueryName()] = struct{}{}
|
||||
continue
|
||||
}
|
||||
for _, expression := range expressions {
|
||||
keys.Aggregations[envelope.GetQueryName()+"."+expression] = struct{}{}
|
||||
}
|
||||
}
|
||||
return keys, true
|
||||
}
|
||||
|
||||
// PrepareJSONSchema adds description to the CompositeQuery schema.
|
||||
func (c *CompositeQuery) PrepareJSONSchema(schema *jsonschema.Schema) error {
|
||||
schema.WithDescription("Composite query containing one or more query envelopes. Each query envelope specifies its type and corresponding spec.")
|
||||
|
||||
@@ -225,6 +225,34 @@ func (q *QueryEnvelope) GetGroupBy() []GroupByKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAggregationExpressions returns one entry per aggregation. A metric
|
||||
// aggregation has no expression, so it contributes an empty entry.
|
||||
func (q *QueryEnvelope) GetAggregationExpressions() []string {
|
||||
switch spec := q.Spec.(type) {
|
||||
case QueryBuilderTraceOperator:
|
||||
expressions := make([]string, len(spec.Aggregations))
|
||||
for i, aggregation := range spec.Aggregations {
|
||||
expressions[i] = aggregation.Expression
|
||||
}
|
||||
return expressions
|
||||
case QueryBuilderQuery[TraceAggregation]:
|
||||
expressions := make([]string, len(spec.Aggregations))
|
||||
for i, aggregation := range spec.Aggregations {
|
||||
expressions[i] = aggregation.Expression
|
||||
}
|
||||
return expressions
|
||||
case QueryBuilderQuery[LogAggregation]:
|
||||
expressions := make([]string, len(spec.Aggregations))
|
||||
for i, aggregation := range spec.Aggregations {
|
||||
expressions[i] = aggregation.Expression
|
||||
}
|
||||
return expressions
|
||||
case QueryBuilderQuery[MetricAggregation]:
|
||||
return make([]string, len(spec.Aggregations))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFilter returns the filter.
|
||||
func (q *QueryEnvelope) GetFilter() *Filter {
|
||||
switch spec := q.Spec.(type) {
|
||||
|
||||
@@ -1903,3 +1903,63 @@ func TestQueryRangeRequest_StepIntervalForQuery(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeQueryCollectResultColumnKeys(t *testing.T) {
|
||||
testCases := []struct {
|
||||
scenario string
|
||||
composite CompositeQuery
|
||||
expectedKeys ResultColumnKeys
|
||||
expectedKnown bool
|
||||
}{
|
||||
{
|
||||
scenario: "a single aggregation is keyed by the query name",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[TraceAggregation]{
|
||||
Name: "A",
|
||||
Aggregations: []TraceAggregation{{Expression: "count()"}},
|
||||
GroupBy: []GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
}}}},
|
||||
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"A": {}}, Groups: map[string]struct{}{"service.name": {}}},
|
||||
expectedKnown: true,
|
||||
},
|
||||
{
|
||||
scenario: "several aggregations are keyed by query name and expression",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[LogAggregation]{
|
||||
Name: "A",
|
||||
Aggregations: []LogAggregation{{Expression: "count()"}, {Expression: "p99(duration_nano)"}},
|
||||
}}}},
|
||||
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"A.count()": {}, "A.p99(duration_nano)": {}}, Groups: map[string]struct{}{}},
|
||||
expectedKnown: true,
|
||||
},
|
||||
{
|
||||
scenario: "a disabled query yields no aggregation but keeps its groups for the formula over it",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{
|
||||
{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[MetricAggregation]{
|
||||
Name: "A",
|
||||
Disabled: true,
|
||||
Aggregations: []MetricAggregation{{MetricName: "http.server.request.count"}},
|
||||
GroupBy: []GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
}},
|
||||
{Type: QueryTypeFormula, Spec: QueryBuilderFormula{Name: "F1", Expression: "A * 2"}},
|
||||
}},
|
||||
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"F1": {}}, Groups: map[string]struct{}{"service.name": {}}},
|
||||
expectedKnown: true,
|
||||
},
|
||||
{
|
||||
scenario: "clickhouse columns are only known at run time",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeClickHouseSQL, Spec: ClickHouseQuery{
|
||||
Name: "A",
|
||||
Query: "SELECT 1",
|
||||
}}}},
|
||||
expectedKeys: ResultColumnKeys{},
|
||||
expectedKnown: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.scenario, func(t *testing.T) {
|
||||
keys, known := testCase.composite.CollectResultColumnKeys()
|
||||
assert.Equal(t, testCase.expectedKnown, known)
|
||||
assert.Equal(t, testCase.expectedKeys, keys)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
369
tests/e2e/drivers/channels.ts
generated
369
tests/e2e/drivers/channels.ts
generated
@@ -1,369 +0,0 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -1,36 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import { expect, test } from '../../../drivers/channels';
|
||||
|
||||
test.describe('Notification channels — list', () => {
|
||||
test('TC-01 search narrows the list to the matching channel', async ({
|
||||
channels,
|
||||
channelSet,
|
||||
}) => {
|
||||
await channels.list.goto({ search: channelSet.prefix });
|
||||
await expect(channels.list.rows()).toHaveCount(2);
|
||||
|
||||
await channels.list.search().fill(channelSet.slack.name);
|
||||
await expect(channels.list.row(channelSet.slack.name)).toBeVisible();
|
||||
await expect(channels.list.row(channelSet.email.name)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-02 the kind filter keeps only channels of that kind', async ({
|
||||
channels,
|
||||
channelSet,
|
||||
}) => {
|
||||
await channels.list.goto({ search: channelSet.prefix, kind: 'email' });
|
||||
|
||||
await expect(channels.list.row(channelSet.email.name)).toBeVisible();
|
||||
await expect(channels.list.row(channelSet.slack.name)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-03 a search matching nothing offers to clear itself', async ({
|
||||
channels,
|
||||
channelSet,
|
||||
}) => {
|
||||
await channels.list.goto({ search: `${channelSet.prefix}-nothing-matches` });
|
||||
|
||||
await expect(channels.list.noResults()).toBeVisible();
|
||||
await channels.list.clearSearch();
|
||||
});
|
||||
|
||||
test('TC-04 clicking a row opens that channel for editing', async ({
|
||||
channels,
|
||||
channelSet,
|
||||
authedPage,
|
||||
}) => {
|
||||
await channels.list.goto({ search: channelSet.slack.name });
|
||||
await channels.list.row(channelSet.slack.name).click();
|
||||
|
||||
await authedPage.waitForURL(/\/alerts\/channels\/edit\//);
|
||||
await expect(channels.form.name()).toHaveValue(channelSet.slack.name);
|
||||
});
|
||||
|
||||
test('TC-05 View JSON shows the channel with its secrets masked', async ({
|
||||
channels,
|
||||
channelSet,
|
||||
authedPage,
|
||||
}) => {
|
||||
await channels.list.goto({ search: channelSet.slack.name });
|
||||
await channels.list.openRowMenu(channelSet.slack.name);
|
||||
await channels.list.chooseMenuItem('View JSON');
|
||||
|
||||
await expect(channels.list.jsonDialog()).toBeVisible();
|
||||
await expect(channels.list.jsonDialog()).toContainText('"kind": "slack"');
|
||||
await expect(
|
||||
authedPage.getByTestId('channel-json-toggle-secrets'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user