mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-11 20:50:41 +01:00
Compare commits
7 Commits
alerts/clo
...
feat/teleg
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd9d86d6c3 | ||
|
|
5f8fa52e7c | ||
|
|
4105015444 | ||
|
|
e312d010db | ||
|
|
f76ab67a90 | ||
|
|
3c1a78c85e | ||
|
|
b63ad746b2 |
@@ -37,6 +37,7 @@ components:
|
||||
opsgenie: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig'
|
||||
pagerduty: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig'
|
||||
slack: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig'
|
||||
telegram: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfig'
|
||||
webhook: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
@@ -50,6 +51,7 @@ components:
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfig'
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig:
|
||||
properties:
|
||||
@@ -159,6 +161,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- telegram
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelTelegramConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig:
|
||||
properties:
|
||||
kind:
|
||||
@@ -311,6 +325,7 @@ components:
|
||||
- jira
|
||||
- jsmops
|
||||
- incidentio
|
||||
- telegram
|
||||
type: string
|
||||
AlertmanagertypesChannelListOrder:
|
||||
enum:
|
||||
@@ -515,6 +530,29 @@ components:
|
||||
- title
|
||||
- value
|
||||
type: object
|
||||
AlertmanagertypesChannelTelegramConfig:
|
||||
properties:
|
||||
apiUrl:
|
||||
type: string
|
||||
botToken:
|
||||
format: password
|
||||
type: string
|
||||
chatId:
|
||||
format: int64
|
||||
type: integer
|
||||
disableNotifications:
|
||||
type: boolean
|
||||
message:
|
||||
type: string
|
||||
messageThreadId:
|
||||
type: integer
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
required:
|
||||
- botToken
|
||||
- chatId
|
||||
type: object
|
||||
AlertmanagertypesChannelWebhookConfig:
|
||||
properties:
|
||||
bearerToken:
|
||||
|
||||
@@ -277,10 +277,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
|
||||
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
|
||||
|
||||
annotations := make(ruletypes.Labels, 0, len(r.Annotations().Map()))
|
||||
for name, value := range r.Annotations().Map() {
|
||||
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
|
||||
}
|
||||
annotations := r.ExpandAnnotations(expand)
|
||||
if smpl.IsMissing {
|
||||
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
|
||||
lb.Set(ruletypes.NoDataLabel, "true")
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10.5px;
|
||||
padding: var(--spacing-2) 10.5px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.left-actions {
|
||||
@@ -46,6 +46,9 @@
|
||||
justify-content: flex-start;
|
||||
|
||||
.text {
|
||||
padding-left: var(--spacing-4);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
color: var(--l1-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
@@ -78,6 +81,8 @@
|
||||
}
|
||||
|
||||
.select-box {
|
||||
--combobox-trigger-width: auto;
|
||||
|
||||
display: flex;
|
||||
padding: 2px 5px;
|
||||
flex-direction: row;
|
||||
@@ -95,10 +100,9 @@
|
||||
.right-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
gap: var(--spacing-1);
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
--button-variant-link-color: var(--l1-foreground);
|
||||
|
||||
.divider-filter {
|
||||
width: 1px;
|
||||
@@ -106,26 +110,8 @@
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
.sync-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.right-action-icon-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding: 2px;
|
||||
background-color: var(--l1-background);
|
||||
|
||||
.settings-icon {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.active,
|
||||
&:hover {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
.active {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
ArrowUpToLine,
|
||||
Filter,
|
||||
Frown,
|
||||
RefreshCw,
|
||||
Settings2 as SettingsIcon,
|
||||
@@ -14,8 +13,9 @@ import {
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
} from '@signozhq/ui/combobox';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import { Skeleton } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -180,7 +180,6 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
// Helpers to reduce cognitive complexity in main render
|
||||
const renderLeftActions = (): JSX.Element => (
|
||||
<section className="left-actions">
|
||||
<Filter size="md" />
|
||||
<Typography.Text className="text">
|
||||
{displayedQueryName ? 'Filters for' : 'Filters'}
|
||||
</Typography.Text>
|
||||
@@ -217,13 +216,13 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
</Combobox>
|
||||
) : (
|
||||
displayedQueryName && (
|
||||
<Tooltip
|
||||
<TooltipSimple
|
||||
title={`Filter currently in sync with query ${displayedQueryName}`}
|
||||
>
|
||||
<Typography.Text className="sync-tag">
|
||||
{displayedQueryName}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</TooltipSimple>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
@@ -231,51 +230,54 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
|
||||
|
||||
const renderRightActions = (): JSX.Element => (
|
||||
<section className="right-actions">
|
||||
<Tooltip title="Reset All">
|
||||
<TooltipSimple title="Reset All">
|
||||
<Button
|
||||
variant="link"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Reset All"
|
||||
className="right-action-icon-container"
|
||||
onClick={handleReset}
|
||||
prefix={<RefreshCw className="sync-icon" size="md" />}
|
||||
prefix={<RefreshCw size={14} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</TooltipSimple>
|
||||
{showFilterCollapse && (
|
||||
<Tooltip title="Collapse Filters">
|
||||
<TooltipSimple title="Collapse Filters">
|
||||
<Button
|
||||
variant="link"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Collapse Filters"
|
||||
className="right-action-icon-container"
|
||||
onClick={handleFilterVisibilityChange}
|
||||
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size="md" />}
|
||||
prefix={<ArrowUpToLine style={{ rotate: '270deg' }} size={14} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isDynamicFilters && (
|
||||
<AuthZButton
|
||||
checks={QuickFilterManagePermissions}
|
||||
variant="link"
|
||||
color="secondary"
|
||||
aria-label="Settings"
|
||||
className={classNames('right-action-icon-container', {
|
||||
active: isSettingsOpen,
|
||||
})}
|
||||
onClick={(): void => setIsSettingsOpen(true)}
|
||||
testId="settings-icon-container"
|
||||
prefix={
|
||||
<Tooltip title="Settings" open={isSettingsDisabled ? false : undefined}>
|
||||
<SettingsIcon
|
||||
className="settings-icon"
|
||||
data-testid="settings-icon"
|
||||
width={14}
|
||||
height={14}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
{isDynamicFilters &&
|
||||
(isSettingsDisabled ? (
|
||||
<AuthZButton
|
||||
checks={QuickFilterManagePermissions}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Settings"
|
||||
onClick={(): void => setIsSettingsOpen(true)}
|
||||
testId="settings-icon-container"
|
||||
prefix={<SettingsIcon data-testid="settings-icon" size={14} />}
|
||||
/>
|
||||
) : (
|
||||
<TooltipSimple title="Settings">
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Settings"
|
||||
className={classNames({ active: isSettingsOpen })}
|
||||
onClick={(): void => setIsSettingsOpen(true)}
|
||||
data-testid="settings-icon-container"
|
||||
prefix={<SettingsIcon data-testid="settings-icon" size={14} />}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import cx from 'classnames';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import SavedViewsHeader from 'container/SavedViews/SavedViewsHeader';
|
||||
import SavedViewsPanel from 'container/SavedViews/SavedViewsPanel';
|
||||
import SavedViewsRestore from 'container/SavedViews/SavedViewsRestore';
|
||||
import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
|
||||
|
||||
import QuickFilters from '../QuickFilters';
|
||||
@@ -40,7 +41,7 @@ function QuickFiltersLayout({
|
||||
testId,
|
||||
children,
|
||||
}: QuickFiltersLayoutProps): JSX.Element {
|
||||
const [isViewsOpen, setIsViewsOpen] = useState(false);
|
||||
const [isViewsListOpen, setIsViewsListOpen] = useState(false);
|
||||
const isSavedViewEnabled = useSavedViewEnabled();
|
||||
|
||||
const hasQuickFilters = !!quickFilterProps;
|
||||
@@ -48,19 +49,23 @@ function QuickFiltersLayout({
|
||||
|
||||
const showSidebar = showFilters && (hasQuickFilters || hasSavedViews);
|
||||
|
||||
const isPanelPinned = hasSavedViews && !hasQuickFilters;
|
||||
const isViewsListPinned = hasSavedViews && !hasQuickFilters;
|
||||
const isViewsListVisible = isViewsListPinned || isViewsListOpen;
|
||||
|
||||
const isSliding = hasQuickFilters && isViewsOpen;
|
||||
const isSliding = hasQuickFilters && isViewsListOpen;
|
||||
|
||||
const savedViewsHeader = hasSavedViews ? (
|
||||
<SavedViewsHeader
|
||||
{...savedViewProps}
|
||||
onOpenViews={isPanelPinned ? undefined : (): void => setIsViewsOpen(true)}
|
||||
onOpenViews={
|
||||
isViewsListVisible ? undefined : (): void => setIsViewsListOpen(true)
|
||||
}
|
||||
/>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className={cx(styles.layout, className)} data-testid={testId}>
|
||||
{hasSavedViews && <SavedViewsRestore source={savedViewProps.source} />}
|
||||
{showSidebar && (
|
||||
<aside
|
||||
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
|
||||
@@ -80,10 +85,12 @@ function QuickFiltersLayout({
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.savedViews}>
|
||||
{hasSavedViews && (isPanelPinned || isViewsOpen) && (
|
||||
{hasSavedViews && isViewsListVisible && (
|
||||
<SavedViewsPanel
|
||||
{...savedViewProps}
|
||||
onClose={isPanelPinned ? undefined : (): void => setIsViewsOpen(false)}
|
||||
onClose={
|
||||
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,13 @@ jest.mock('hooks/useSavedViewEnabled', () => ({
|
||||
useSavedViewEnabled: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
jest.mock('container/SavedViews/SavedViewsRestore', () => ({
|
||||
__esModule: true,
|
||||
default: ({ source }: { source: string }): JSX.Element => (
|
||||
<div data-testid="saved-views-restore" data-source={source} />
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../../QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
@@ -87,6 +94,10 @@ describe('QuickFiltersLayout', () => {
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('quick-filters')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('content')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('saved-views-restore')).toHaveAttribute(
|
||||
'data-source',
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
});
|
||||
|
||||
it('renders no sidebar at all without quick filters or saved views', () => {
|
||||
@@ -149,6 +160,7 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(
|
||||
screen.queryByTestId('quick-filters-layout-filters'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-restore')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the panel from the header and slides the quick filters drawer, keeping the same QuickFilters node', async () => {
|
||||
@@ -176,12 +188,14 @@ describe('QuickFiltersLayout', () => {
|
||||
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
|
||||
expect(drawer()).toHaveClass('isOpen');
|
||||
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
|
||||
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('saved-views-close'));
|
||||
|
||||
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
|
||||
expect(drawer()).not.toHaveClass('isOpen');
|
||||
expect(screen.getByTestId('quick-filters')).toBe(quickFilters);
|
||||
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hands the source to the header and the panel', async () => {
|
||||
|
||||
@@ -5,12 +5,10 @@ import {
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
findSavedView,
|
||||
getSavedViewQuery,
|
||||
SavedViewSourcePage,
|
||||
toSavedViewSource,
|
||||
} from 'container/SavedViews/utils';
|
||||
import { SavedViewSourcePage } from 'container/SavedViews/types';
|
||||
import { findSavedView } from 'container/SavedViews/utils/findSavedView';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
|
||||
import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils';
|
||||
import { getSavedViewQuery } from 'container/SavedViews/utils/getSavedViewQuery';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
|
||||
import Card from 'periscope/components/Card/Card';
|
||||
|
||||
@@ -41,7 +41,7 @@ import { buildCompositeKey, getOptionsFromKeys } from './utils';
|
||||
interface UseOptionsMenuProps {
|
||||
storageKey?: string;
|
||||
dataSource: DataSource;
|
||||
aggregateOperator: string;
|
||||
aggregateOperator?: string;
|
||||
initialOptions?: InitialOptions;
|
||||
}
|
||||
|
||||
|
||||
34
frontend/src/container/SavedViews/SaveChangesMenu.tsx
Normal file
34
frontend/src/container/SavedViews/SaveChangesMenu.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Check } from '@signozhq/icons';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import { SaveChangesMenuProps } from './types';
|
||||
|
||||
function SaveChangesMenu({
|
||||
disabled,
|
||||
onSaveAsNew,
|
||||
onUpdate,
|
||||
}: SaveChangesMenuProps): JSX.Element {
|
||||
const items = useMemo<MenuItem[]>(
|
||||
() => [
|
||||
{ key: 'save-as-new', label: 'Save as new view', onClick: onSaveAsNew },
|
||||
{ key: 'update', label: 'Update selected view', onClick: onUpdate },
|
||||
],
|
||||
[onSaveAsNew, onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple menu={{ items }} align="end">
|
||||
<SavedViewsIconButton
|
||||
title="Save changes"
|
||||
icon={<Check size={14} />}
|
||||
color="warning"
|
||||
disabled={disabled}
|
||||
testId="saved-views-save-changes"
|
||||
/>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveChangesMenu;
|
||||
18
frontend/src/container/SavedViews/SaveViewModal.module.scss
Normal file
18
frontend/src/container/SavedViews/SaveViewModal.module.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-4);
|
||||
width: 100%;
|
||||
}
|
||||
94
frontend/src/container/SavedViews/SaveViewModal.tsx
Normal file
94
frontend/src/container/SavedViews/SaveViewModal.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { SAVE_VIEW_MODAL_TITLE } from './constants';
|
||||
import { SaveViewModalProps } from './types';
|
||||
|
||||
import styles from './SaveViewModal.module.scss';
|
||||
|
||||
const NAME_INPUT_ID = 'save-view-name';
|
||||
|
||||
function SaveViewModal({
|
||||
mode,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
}: SaveViewModalProps): JSX.Element {
|
||||
const [name, setName] = useState('');
|
||||
const displayName = name.trim();
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!displayName || isSaving) {
|
||||
return;
|
||||
}
|
||||
if (await onSave(displayName)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
void handleSave();
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={SAVE_VIEW_MODAL_TITLE[mode]}
|
||||
testId="save-view-modal"
|
||||
footer={
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="md"
|
||||
prefix={<X size={14} />}
|
||||
onClick={onClose}
|
||||
testId="save-view-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="md"
|
||||
prefix={<Check size={14} />}
|
||||
loading={isSaving}
|
||||
disabled={!displayName}
|
||||
onClick={(): void => {
|
||||
void handleSave();
|
||||
}}
|
||||
testId="save-view-submit"
|
||||
>
|
||||
Save view
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form className={styles.field} onSubmit={handleSubmit}>
|
||||
<label htmlFor={NAME_INPUT_ID}>
|
||||
<Typography.Text className={styles.label}>Enter view name</Typography.Text>
|
||||
</label>
|
||||
<Input
|
||||
id={NAME_INPUT_ID}
|
||||
placeholder="Eg. payments-view"
|
||||
value={name}
|
||||
onChange={(event): void => setName(event.target.value)}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
testId="save-view-name"
|
||||
/>
|
||||
</form>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default SaveViewModal;
|
||||
@@ -3,17 +3,59 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-2);
|
||||
padding: var(--spacing-2) var(--spacing-3);
|
||||
padding: var(--spacing-2) var(--spacing-5);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
flex-shrink: 0;
|
||||
|
||||
&.isDirty {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--callout-warning-background),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
min-width: 0;
|
||||
padding: var(--spacing-1) var(--spacing-4);
|
||||
border-radius: var(--spacing-1);
|
||||
background: var(--l3-background);
|
||||
color: var(--l1-foreground);
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
&.isDirty {
|
||||
background: var(--callout-warning-background);
|
||||
color: var(--callout-warning-title);
|
||||
}
|
||||
}
|
||||
|
||||
.nameText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: var(--spacing-3);
|
||||
height: var(--spacing-3);
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--callout-warning-icon);
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,140 @@
|
||||
import { Menu } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useCallback, useState } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Menu, Plus, Undo2, X } from '@signozhq/icons';
|
||||
import { Skeleton } from '@signozhq/ui/skeleton';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import styles from './SavedViewsHeader.module.scss';
|
||||
import { MY_VIEW_NAME, SAVED_VIEW_TOAST_POSITION } from './constants';
|
||||
import { useActiveSavedView } from './hooks/useActiveSavedView';
|
||||
import { useSavedViewActions } from './hooks/useSavedViewActions';
|
||||
import SaveChangesMenu from './SaveChangesMenu';
|
||||
import SavedViewsIconButton from './SavedViewsIconButton';
|
||||
import SaveViewModal from './SaveViewModal';
|
||||
import { SaveViewModalMode } from './types';
|
||||
|
||||
// Temp Placeholder data till main functionality is plugged in
|
||||
const PLACEHOLDER_VIEW_NAME = 'My view';
|
||||
import styles from './SavedViewsHeader.module.scss';
|
||||
|
||||
function SavedViewsHeader({
|
||||
source,
|
||||
onOpenViews,
|
||||
}: {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
// Absent when the list is always on screen.
|
||||
// Absent while the list is on screen.
|
||||
onOpenViews?: () => void;
|
||||
}): JSX.Element {
|
||||
const { view, isLoading, hasUnsavedChanges } = useActiveSavedView(source);
|
||||
const { revertView, clearView, createView, updateView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
|
||||
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (displayName: string): Promise<boolean> => {
|
||||
const id = await createView(displayName);
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
toast.success('You have created a new view.', {
|
||||
position: SAVED_VIEW_TOAST_POSITION,
|
||||
...(onOpenViews && { action: { label: 'View', onClick: onOpenViews } }),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[createView, onOpenViews],
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback(async (): Promise<void> => {
|
||||
if (view && (await updateView(view))) {
|
||||
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
}
|
||||
}, [view, updateView]);
|
||||
|
||||
const openSaveAsNew = useCallback((): void => setModalMode('saveAsNew'), []);
|
||||
const closeModal = useCallback((): void => setModalMode(null), []);
|
||||
|
||||
const name = view?.spec.displayName ?? MY_VIEW_NAME;
|
||||
const isDirty = !!view && hasUnsavedChanges;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.header}
|
||||
className={cx(styles.header, { [styles.isDirty]: isDirty })}
|
||||
data-testid="saved-views-header"
|
||||
data-source={source}
|
||||
>
|
||||
<Typography.Text className={styles.name}>
|
||||
{PLACEHOLDER_VIEW_NAME}
|
||||
</Typography.Text>
|
||||
{onOpenViews && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="All views"
|
||||
prefix={<Menu size={14} />}
|
||||
onClick={onOpenViews}
|
||||
data-testid="saved-views-open"
|
||||
{isLoading ? (
|
||||
<Skeleton.Input
|
||||
active
|
||||
size="small"
|
||||
className={styles.skeleton}
|
||||
testId="saved-views-loading"
|
||||
/>
|
||||
) : (
|
||||
<TooltipSimple title={name}>
|
||||
<span
|
||||
className={cx(styles.name, { [styles.isDirty]: isDirty })}
|
||||
data-testid="saved-views-name"
|
||||
>
|
||||
{isDirty && <span className={styles.dot} />}
|
||||
<span className={styles.nameText}>{name}</span>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
<div className={styles.actions}>
|
||||
{!view && (
|
||||
<SavedViewsIconButton
|
||||
title="Create new view"
|
||||
icon={<Plus size={14} />}
|
||||
disabled={isLoading}
|
||||
onClick={(): void => setModalMode('create')}
|
||||
testId="saved-views-create"
|
||||
/>
|
||||
)}
|
||||
{view && !isDirty && (
|
||||
<SavedViewsIconButton
|
||||
title="Clear view"
|
||||
icon={<Undo2 size={14} />}
|
||||
onClick={clearView}
|
||||
testId="saved-views-clear"
|
||||
/>
|
||||
)}
|
||||
{view && isDirty && (
|
||||
<>
|
||||
<SaveChangesMenu
|
||||
disabled={isSaving}
|
||||
onSaveAsNew={openSaveAsNew}
|
||||
onUpdate={(): void => {
|
||||
void handleUpdate();
|
||||
}}
|
||||
/>
|
||||
<SavedViewsIconButton
|
||||
title="Discard changes"
|
||||
icon={<X size={14} />}
|
||||
color="warning"
|
||||
disabled={isSaving}
|
||||
onClick={(): void => revertView(view)}
|
||||
testId="saved-views-discard"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!isDirty && onOpenViews && (
|
||||
<SavedViewsIconButton
|
||||
title="All views"
|
||||
icon={<Menu size={14} />}
|
||||
onClick={onOpenViews}
|
||||
testId="saved-views-open"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modalMode && (
|
||||
<SaveViewModal
|
||||
mode={modalMode}
|
||||
isSaving={isSaving}
|
||||
onClose={closeModal}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
41
frontend/src/container/SavedViews/SavedViewsIconButton.tsx
Normal file
41
frontend/src/container/SavedViews/SavedViewsIconButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
|
||||
import { SavedViewsIconButtonProps } from './types';
|
||||
|
||||
// forwardRef and rest props so a menu can use it as its trigger.
|
||||
const SavedViewsIconButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
SavedViewsIconButtonProps
|
||||
>(function SavedViewsIconButton(
|
||||
{
|
||||
title,
|
||||
icon,
|
||||
testId,
|
||||
color = 'secondary',
|
||||
disabled,
|
||||
onClick,
|
||||
...triggerProps
|
||||
},
|
||||
ref,
|
||||
): JSX.Element {
|
||||
return (
|
||||
<TooltipSimple title={title}>
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
color={color}
|
||||
size="icon"
|
||||
aria-label={title}
|
||||
prefix={icon}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
{...triggerProps}
|
||||
data-testid={testId}
|
||||
/>
|
||||
</TooltipSimple>
|
||||
);
|
||||
});
|
||||
|
||||
export default SavedViewsIconButton;
|
||||
17
frontend/src/container/SavedViews/SavedViewsRestore.tsx
Normal file
17
frontend/src/container/SavedViews/SavedViewsRestore.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useRestoreLastUsedView } from './hooks/useRestoreLastUsedView';
|
||||
import { useSavedViewActions } from './hooks/useSavedViewActions';
|
||||
|
||||
// Outside the sidebar, so a collapsed sidebar still restores on a bare explorer.
|
||||
function SavedViewsRestore({
|
||||
source,
|
||||
}: {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
}): null {
|
||||
const { selectView } = useSavedViewActions(source);
|
||||
useRestoreLastUsedView({ source, selectView });
|
||||
return null;
|
||||
}
|
||||
|
||||
export default SavedViewsRestore;
|
||||
@@ -0,0 +1,211 @@
|
||||
import { act, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
mockSavedViewsApi,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
function renderHeader(
|
||||
url: string,
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
/>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const modal = (): HTMLElement => screen.getByTestId('save-view-modal');
|
||||
const isModalOpen = (): boolean => !!screen.queryByTestId('save-view-modal');
|
||||
|
||||
async function openDirtyView(
|
||||
view: ReturnType<typeof makeView>,
|
||||
): Promise<ReturnType<typeof renderWithExplorerProviders>> {
|
||||
const rendered = renderHeader(viewUrl(PATH, view));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
view.spec.displayName,
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
|
||||
);
|
||||
act(() => {
|
||||
rendered.history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: view.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-discard')).toBeInTheDocument(),
|
||||
);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
async function chooseFromSaveMenu(item: string): Promise<void> {
|
||||
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
|
||||
await userEvent.click(await screen.findByRole('menuitem', { name: item }));
|
||||
}
|
||||
|
||||
async function submitName(name: string): Promise<void> {
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), name);
|
||||
await userEvent.click(screen.getByTestId('save-view-submit'));
|
||||
}
|
||||
|
||||
describe('SavedViewsHeader saving', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('saves the current state under a generated name and opens it', async () => {
|
||||
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
|
||||
expect(within(modal()).getByText('Create new view')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('save-view-submit')).toBeDisabled();
|
||||
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() => expect(requests.created).toHaveLength(1));
|
||||
expect(requests.created[0]).toStrictEqual(
|
||||
expect.objectContaining({
|
||||
generateName: true,
|
||||
source: SavedviewtypesSourceDTO.traces,
|
||||
schemaVersion: 'v2',
|
||||
spec: expect.objectContaining({
|
||||
displayName: 'Slow checkout',
|
||||
panelType: 'list',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify('view-new'),
|
||||
),
|
||||
);
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'You have created a new view.',
|
||||
expect.objectContaining({ position: 'top-right' }),
|
||||
);
|
||||
await waitFor(() => expect(isModalOpen()).toBe(false));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
'Slow checkout',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the modal open and reports a failed save', async () => {
|
||||
const requests = mockSavedViewsApi([], { createStatus: 500 });
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
await submitName('Slow checkout');
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(requests.created).toHaveLength(1);
|
||||
expect(isModalOpen()).toBe(true);
|
||||
expect(screen.getByTestId('save-view-name')).toHaveValue('Slow checkout');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancel closes the modal without saving', async () => {
|
||||
const requests = mockSavedViewsApi([]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-create'));
|
||||
await userEvent.type(screen.getByTestId('save-view-name'), 'Draft');
|
||||
await userEvent.click(screen.getByTestId('save-view-cancel'));
|
||||
|
||||
await waitFor(() => expect(isModalOpen()).toBe(false));
|
||||
expect(requests.created).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('save as new creates another view from the changed state', async () => {
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const requests = mockSavedViewsApi([errors], { createdId: 'view-copy' });
|
||||
const { history } = await openDirtyView(errors);
|
||||
|
||||
await chooseFromSaveMenu('Save as new view');
|
||||
|
||||
expect(within(modal()).getByText('Save as new view')).toBeInTheDocument();
|
||||
await submitName('Errors copy');
|
||||
|
||||
await waitFor(() => expect(requests.created).toHaveLength(1));
|
||||
const body = requests.created[0] as {
|
||||
spec: {
|
||||
displayName: string;
|
||||
queries: { spec: { filter: { expression: string } } }[];
|
||||
};
|
||||
};
|
||||
expect(body.spec.displayName).toBe('Errors copy');
|
||||
expect(body.spec.queries[0].spec.filter.expression).toBe('has_error = false');
|
||||
expect(requests.updated).toHaveLength(0);
|
||||
await waitFor(() =>
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify('view-copy'),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('update stores the changed state on the open view, keeping its name', async () => {
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const requests = mockSavedViewsApi([errors]);
|
||||
await openDirtyView(errors);
|
||||
|
||||
await chooseFromSaveMenu('Update selected view');
|
||||
|
||||
await waitFor(() => expect(requests.updated).toHaveLength(1));
|
||||
const { id, body } = requests.updated[0] as {
|
||||
id: string;
|
||||
body: {
|
||||
source: string;
|
||||
spec: {
|
||||
displayName: string;
|
||||
queries: { spec: { filter: { expression: string } } }[];
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(id).toBe('view-1');
|
||||
expect(body.source).toBe(SavedviewtypesSourceDTO.traces);
|
||||
expect(body.spec.displayName).toBe('Errors');
|
||||
expect(body.spec.queries[0].spec.filter.expression).toBe('has_error = false');
|
||||
await waitFor(() =>
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
'View updated',
|
||||
expect.objectContaining({ position: 'top-right' }),
|
||||
),
|
||||
);
|
||||
expect(requests.created).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
import { act, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { useActiveSavedView } from '../hooks/useActiveSavedView';
|
||||
import SavedViewsHeader from '../SavedViewsHeader';
|
||||
import SavedViewsRestore from '../SavedViewsRestore';
|
||||
import {
|
||||
explorerUrl,
|
||||
makeView,
|
||||
mockSavedViewsApi,
|
||||
queryWith,
|
||||
renderWithExplorerProviders,
|
||||
urlParam,
|
||||
viewUrl,
|
||||
} from './savedViewsTestUtils';
|
||||
|
||||
const PATH = ROUTES.TRACES_EXPLORER;
|
||||
|
||||
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
|
||||
const slow = makeView({
|
||||
id: 'view-2',
|
||||
displayName: 'Slow spans',
|
||||
expression: 'duration_nano > 1000',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
|
||||
function renderHeader(
|
||||
url: string,
|
||||
onOpenViews: (() => void) | undefined = jest.fn(),
|
||||
): ReturnType<typeof renderWithExplorerProviders> {
|
||||
return renderWithExplorerProviders(
|
||||
<>
|
||||
<SavedViewsRestore source={SavedviewtypesSourceDTO.traces} />
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={onOpenViews}
|
||||
/>
|
||||
<RenderRecorder />
|
||||
</>,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
const chip = (): HTMLElement => screen.getByTestId('saved-views-name');
|
||||
const isDirty = (): boolean => !!screen.queryByTestId('saved-views-discard');
|
||||
const saveChanges = (): HTMLElement =>
|
||||
screen.getByTestId('saved-views-save-changes');
|
||||
|
||||
async function waitForView(name: string): Promise<void> {
|
||||
await waitFor(() => expect(chip()).toHaveTextContent(name));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
|
||||
);
|
||||
}
|
||||
|
||||
// Each render's answer from the hook the header reads, so a state that lasts a
|
||||
// single render is seen too.
|
||||
interface RenderRecord {
|
||||
viewId?: string;
|
||||
dirty: boolean;
|
||||
// The staged query had not caught up with the url yet.
|
||||
isStaging: boolean;
|
||||
}
|
||||
let renders: RenderRecord[] = [];
|
||||
function RenderRecorder(): null {
|
||||
const { view, hasUnsavedChanges } = useActiveSavedView(
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
renders.push({
|
||||
viewId: view?.id,
|
||||
dirty: hasUnsavedChanges,
|
||||
isStaging: stagedQuery?.id !== compositeQuery?.id,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
async function recordRenders(change: () => void): Promise<RenderRecord[]> {
|
||||
renders = [];
|
||||
act(change);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('saved-views-loading')).toBeNull(),
|
||||
);
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 100)));
|
||||
return renders;
|
||||
}
|
||||
|
||||
describe('SavedViewsHeader', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('shows My view with create and all views when no view is open', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
expect(screen.getByTestId('saved-views-create')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-clear')).toBeNull();
|
||||
expect(screen.queryByTestId('saved-views-save-changes')).toBeNull();
|
||||
expect(screen.queryByTestId('saved-views-discard')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a skeleton while the view in the url loads', async () => {
|
||||
mockSavedViewsApi([errors], { getDelay: 150 });
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
expect(screen.getByTestId('saved-views-loading')).toBeInTheDocument();
|
||||
await waitForView('Errors');
|
||||
expect(screen.queryByTestId('saved-views-loading')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the open view clean, with clear and all views', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
await waitForView('Errors');
|
||||
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-create')).toBeNull();
|
||||
expect(isDirty()).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to My view when the view in the url does not load', async () => {
|
||||
mockSavedViewsApi([]);
|
||||
renderHeader(viewUrl(PATH, errors));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('saved-views-loading')).toBeNull(),
|
||||
);
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
});
|
||||
|
||||
it('marks the view dirty once the query changes, swapping in save and discard', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
|
||||
act(() => {
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
expect(saveChanges()).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('saved-views-clear')).toBeNull();
|
||||
expect(screen.queryByTestId('saved-views-open')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks the view dirty when only the tab changes', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
|
||||
act(() => {
|
||||
const params = new URLSearchParams(history.location.search);
|
||||
params.set(QueryParams.panelTypes, JSON.stringify(PANEL_TYPES.TABLE));
|
||||
history.push(`${PATH}?${params.toString()}`);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
});
|
||||
|
||||
it('discard puts the saved query and tab back', async () => {
|
||||
mockSavedViewsApi([slow]);
|
||||
const { history } = renderHeader(viewUrl(PATH, slow));
|
||||
await waitForView('Slow spans');
|
||||
|
||||
act(() => {
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = true'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: slow.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-discard'));
|
||||
|
||||
await waitFor(() => expect(isDirty()).toBe(false));
|
||||
expect(urlParam(history, QueryParams.panelTypes)).toBe(
|
||||
JSON.stringify(PANEL_TYPES.TIME_SERIES),
|
||||
);
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(JSON.stringify(slow.id));
|
||||
expect(urlParam(history, QueryParams.viewName)).toBeNull();
|
||||
const query = JSON.parse(
|
||||
decodeURIComponent(urlParam(history, QueryParams.compositeQuery) ?? ''),
|
||||
);
|
||||
expect(query.builder.queryData[0].filter.expression).toBe(
|
||||
'duration_nano > 1000',
|
||||
);
|
||||
});
|
||||
|
||||
it('clear drops the view from the url but keeps the time range', async () => {
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
|
||||
JSON.stringify({ traces: { key: errors.id, value: 'Errors' } }),
|
||||
);
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(viewUrl(PATH, errors, '1h'));
|
||||
await waitForView('Errors');
|
||||
|
||||
await userEvent.click(screen.getByTestId('saved-views-clear'));
|
||||
|
||||
await waitFor(() => expect(chip()).toHaveTextContent('My view'));
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
|
||||
expect(urlParam(history, QueryParams.panelTypes)).toBeNull();
|
||||
expect(urlParam(history, QueryParams.relativeTime)).toBe('1h');
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) ?? '{}'),
|
||||
).toStrictEqual({});
|
||||
});
|
||||
|
||||
describe('last used view', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem(
|
||||
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
|
||||
JSON.stringify({ traces: { key: errors.id, value: 'Errors' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('reopens on a bare explorer', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(explorerUrl(PATH, {}));
|
||||
|
||||
await waitForView('Errors');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBe(
|
||||
JSON.stringify(errors.id),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a url with its own query alone', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'service.name = "cart"'),
|
||||
}),
|
||||
);
|
||||
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
|
||||
expect(chip()).toHaveTextContent('My view');
|
||||
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsaved changes indicator', () => {
|
||||
it('never flashes while switching between views already loaded', async () => {
|
||||
mockSavedViewsApi([errors, slow]);
|
||||
const { history } = renderHeader(viewUrl(PATH, slow));
|
||||
await waitForView('Slow spans');
|
||||
act(() => history.push(viewUrl(PATH, errors)));
|
||||
await waitForView('Errors');
|
||||
|
||||
const states = await recordRenders(() => history.push(viewUrl(PATH, slow)));
|
||||
|
||||
expect(chip()).toHaveTextContent('Slow spans');
|
||||
expect(states.some((render) => render.isStaging)).toBe(true);
|
||||
expect(states.filter((render) => render.dirty)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('holds while the query keeps changing on a dirty view', async () => {
|
||||
mockSavedViewsApi([errors]);
|
||||
const { history } = renderHeader(viewUrl(PATH, errors));
|
||||
await waitForView('Errors');
|
||||
act(() => {
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'has_error = false'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() => expect(isDirty()).toBe(true));
|
||||
|
||||
const states = await recordRenders(() =>
|
||||
history.push(
|
||||
explorerUrl(PATH, {
|
||||
query: queryWith(DataSource.TRACES, 'duration_nano > 5'),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
viewKey: errors.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(states.some((render) => render.isStaging)).toBe(true);
|
||||
expect(states.filter((render) => !render.dirty)).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('on metrics compares no columns and has no all views button', async () => {
|
||||
const cpu = makeView({
|
||||
id: 'view-3',
|
||||
displayName: 'CPU',
|
||||
source: SavedviewtypesSourceDTO.metrics,
|
||||
dataSource: DataSource.METRICS,
|
||||
expression: '',
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
});
|
||||
mockSavedViewsApi([cpu]);
|
||||
renderWithExplorerProviders(
|
||||
<SavedViewsHeader source={SavedviewtypesSourceDTO.metrics} />,
|
||||
viewUrl(ROUTES.METRICS_EXPLORER_EXPLORER, cpu),
|
||||
DataSource.METRICS,
|
||||
);
|
||||
|
||||
await waitForView('CPU');
|
||||
expect(isDirty()).toBe(false);
|
||||
expect(screen.queryByTestId('saved-views-open')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import { ReactElement, useEffect, useMemo } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { Router } from 'react-router-dom';
|
||||
import { CompatRouter } from 'react-router-dom-v5-compat';
|
||||
import { render, RenderResult } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { safeNavigateMock } from '__tests__/safeNavigateMock';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSavedViewSpecDTO,
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { createMemoryHistory, MemoryHistory } from 'history';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/react';
|
||||
import { AppContext } from 'providers/App/App';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import store from 'store';
|
||||
import { getAppContextMock } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { __setSearchParamsGetterForTest } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
|
||||
|
||||
export const API = 'http://localhost/api/v2/saved_views';
|
||||
|
||||
// The server stores queries through typed structs: nulls, empty strings,
|
||||
// zeros and empty lists do not come back.
|
||||
function asStoredByServer(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map(asStoredByServer).filter((v) => v !== undefined);
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([key, v]) => [key, asStoredByServer(v)] as const)
|
||||
.filter(([, v]) => v !== undefined),
|
||||
);
|
||||
}
|
||||
return value === null || value === '' || value === 0 ? undefined : value;
|
||||
}
|
||||
|
||||
export function queryWith(dataSource: DataSource, expression: string): Query {
|
||||
const base = initialQueriesMap[dataSource];
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
filter: { expression },
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
}
|
||||
|
||||
export function makeView({
|
||||
id,
|
||||
displayName,
|
||||
source = SavedviewtypesSourceDTO.traces,
|
||||
dataSource = DataSource.TRACES,
|
||||
expression = 'has_error = true',
|
||||
panelType = PANEL_TYPES.LIST,
|
||||
}: {
|
||||
id: string;
|
||||
displayName: string;
|
||||
source?: SavedviewtypesSourceDTO;
|
||||
dataSource?: DataSource;
|
||||
expression?: string;
|
||||
panelType?: PANEL_TYPES;
|
||||
}): SavedviewtypesSavedViewDTO {
|
||||
const spec = toSavedViewSpec({
|
||||
query: queryWith(dataSource, expression),
|
||||
panelType,
|
||||
displayName,
|
||||
});
|
||||
return {
|
||||
id,
|
||||
name: `${displayName.toLowerCase()}-abc`,
|
||||
source,
|
||||
schemaVersion: SavedviewtypesSchemaVersionDTO.v2,
|
||||
createdBy: 'test@signoz.io',
|
||||
updatedBy: 'test@signoz.io',
|
||||
spec: {
|
||||
...spec,
|
||||
queries: asStoredByServer(
|
||||
JSON.parse(JSON.stringify(spec.queries)),
|
||||
) as SavedviewtypesSavedViewSpecDTO['queries'],
|
||||
},
|
||||
} as SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
// Params written the way the query builder redirect writes them.
|
||||
export function explorerUrl(
|
||||
path: string,
|
||||
{
|
||||
query,
|
||||
panelType,
|
||||
viewKey,
|
||||
relativeTime = '15m',
|
||||
}: {
|
||||
query?: Query;
|
||||
panelType?: PANEL_TYPES;
|
||||
viewKey?: string;
|
||||
relativeTime?: string;
|
||||
},
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
if (query) {
|
||||
params.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify({ ...query, id: `url-${Math.random()}` })),
|
||||
);
|
||||
}
|
||||
if (panelType) {
|
||||
params.set(QueryParams.panelTypes, JSON.stringify(panelType));
|
||||
}
|
||||
if (viewKey) {
|
||||
params.set(QueryParams.viewKey, JSON.stringify(viewKey));
|
||||
}
|
||||
params.set(QueryParams.relativeTime, relativeTime);
|
||||
return `${path}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function viewUrl(
|
||||
path: string,
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
relativeTime?: string,
|
||||
): string {
|
||||
return explorerUrl(path, {
|
||||
query: getSavedViewQuery(view),
|
||||
panelType: view.spec.panelType as unknown as PANEL_TYPES,
|
||||
viewKey: view.id,
|
||||
relativeTime,
|
||||
});
|
||||
}
|
||||
|
||||
export interface Requests {
|
||||
created: unknown[];
|
||||
updated: { id: string; body: unknown }[];
|
||||
}
|
||||
|
||||
export function mockSavedViewsApi(
|
||||
views: SavedviewtypesSavedViewDTO[],
|
||||
{
|
||||
createStatus = 201,
|
||||
createdId = 'view-new',
|
||||
getDelay = 0,
|
||||
}: { createStatus?: number; createdId?: string; getDelay?: number } = {},
|
||||
): Requests {
|
||||
const requests: Requests = { created: [], updated: [] };
|
||||
server.use(
|
||||
rest.get(API, (req, res, ctx) => {
|
||||
const source = req.url.searchParams.get('source');
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
status: 'success',
|
||||
data: views.filter((view) => !source || view.source === source),
|
||||
}),
|
||||
);
|
||||
}),
|
||||
rest.get(`${API}/:id`, (req, res, ctx) => {
|
||||
const view = views.find((v) => v.id === req.params.id);
|
||||
if (!view) {
|
||||
return res(ctx.status(404), ctx.json({ status: 'error' }));
|
||||
}
|
||||
return res(
|
||||
ctx.delay(getDelay),
|
||||
ctx.status(200),
|
||||
ctx.json({ status: 'success', data: view }),
|
||||
);
|
||||
}),
|
||||
rest.post(API, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
requests.created.push(body);
|
||||
if (createStatus >= 400) {
|
||||
return res(
|
||||
ctx.status(createStatus),
|
||||
ctx.json({
|
||||
status: 'error',
|
||||
error: { code: 'internal', message: 'boom' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
views.push({ ...body, id: createdId } as SavedviewtypesSavedViewDTO);
|
||||
return res(
|
||||
ctx.status(createStatus),
|
||||
ctx.json({ status: 'success', data: { id: createdId } }),
|
||||
);
|
||||
}),
|
||||
rest.put(`${API}/:id`, async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
requests.updated.push({ id: String(req.params.id), body });
|
||||
return res(ctx.status(204));
|
||||
}),
|
||||
);
|
||||
return requests;
|
||||
}
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
// What the explorer page does around the header: writes its default query to a
|
||||
// bare url, and sets the provider's tab from the url (the provider alone seeds
|
||||
// it from the raw, still JSON-quoted param).
|
||||
function ExplorerShell({ dataSource }: { dataSource: DataSource }): null {
|
||||
const panelType = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
const { handleSetConfig, updateAllQueriesOperators } = useQueryBuilder();
|
||||
const defaultQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueriesMap[dataSource],
|
||||
PANEL_TYPES.LIST,
|
||||
dataSource,
|
||||
),
|
||||
[updateAllQueriesOperators, dataSource],
|
||||
);
|
||||
useShareBuilderUrl({ defaultValue: defaultQuery });
|
||||
useEffect(() => {
|
||||
handleSetConfig(panelType, dataSource);
|
||||
}, [panelType, dataSource, handleSetConfig]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function renderWithExplorerProviders(
|
||||
ui: ReactElement,
|
||||
initialUrl: string,
|
||||
dataSource: DataSource = DataSource.TRACES,
|
||||
): RenderResult & { history: MemoryHistory } {
|
||||
const history = createMemoryHistory({ initialEntries: [initialUrl] });
|
||||
// Parts of the explorer read window.location directly, so it follows the
|
||||
// router's history.
|
||||
const syncWindowLocation = (): void =>
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`${history.location.pathname}${history.location.search}`,
|
||||
);
|
||||
syncWindowLocation();
|
||||
history.listen(syncWindowLocation);
|
||||
__setSearchParamsGetterForTest(
|
||||
() => new URLSearchParams(history.location.search),
|
||||
);
|
||||
safeNavigateMock.mockImplementation((to) => {
|
||||
history.push(
|
||||
typeof to === 'string'
|
||||
? to
|
||||
: `${to.pathname ?? history.location.pathname}${to.search ?? ''}`,
|
||||
);
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { refetchOnWindowFocus: false, retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
const result = render(
|
||||
<Router history={history}>
|
||||
<CompatRouter>
|
||||
<NuqsAdapter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={mockStore(store.getState())}>
|
||||
<AppContext.Provider value={getAppContextMock('ADMIN')}>
|
||||
<TooltipProvider>
|
||||
<PreferenceContextProvider>
|
||||
<QueryBuilderProvider>
|
||||
<ExplorerShell dataSource={dataSource} />
|
||||
{ui}
|
||||
</QueryBuilderProvider>
|
||||
</PreferenceContextProvider>
|
||||
</TooltipProvider>
|
||||
</AppContext.Provider>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
</NuqsAdapter>
|
||||
</CompatRouter>
|
||||
</Router>,
|
||||
);
|
||||
return { ...result, history };
|
||||
}
|
||||
|
||||
export function urlParam(history: MemoryHistory, key: string): string | null {
|
||||
return new URLSearchParams(history.location.search).get(key);
|
||||
}
|
||||
35
frontend/src/container/SavedViews/constants.ts
Normal file
35
frontend/src/container/SavedViews/constants.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
SavedviewtypesSchemaVersionDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SaveViewModalMode } from './types';
|
||||
|
||||
export const SAVED_VIEW_SCHEMA_VERSION = SavedviewtypesSchemaVersionDTO.v2;
|
||||
|
||||
// Only the logs and traces explorers have columns and formatting to save.
|
||||
export const SAVED_VIEW_OPTIONS_DATA_SOURCE: Partial<
|
||||
Record<SavedviewtypesSourceDTO, DataSource>
|
||||
> = {
|
||||
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
|
||||
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
|
||||
};
|
||||
|
||||
// What selecting a view writes to the url, so clearing it removes exactly these.
|
||||
export const SAVED_VIEW_URL_PARAMS = [
|
||||
QueryParams.viewKey,
|
||||
QueryParams.viewName,
|
||||
QueryParams.compositeQuery,
|
||||
QueryParams.panelTypes,
|
||||
];
|
||||
|
||||
export const MY_VIEW_NAME = 'My view';
|
||||
|
||||
export const SAVE_VIEW_MODAL_TITLE: Record<SaveViewModalMode, string> = {
|
||||
create: 'Create new view',
|
||||
saveAsNew: 'Save as new view',
|
||||
};
|
||||
|
||||
export const SAVED_VIEW_TOAST_POSITION = 'top-right' as const;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useGetSavedView } from 'api/generated/services/saved-view';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SAVED_VIEW_OPTIONS_DATA_SOURCE } from '../constants';
|
||||
import { UseActiveSavedViewResult } from '../types';
|
||||
import { hasUnsavedViewChanges } from '../utils/hasUnsavedViewChanges';
|
||||
|
||||
export function useActiveSavedView(
|
||||
source: SavedviewtypesSourceDTO,
|
||||
): UseActiveSavedViewResult {
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
|
||||
const { options } = useOptionsMenu({
|
||||
dataSource: optionsDataSource ?? DataSource.LOGS,
|
||||
});
|
||||
|
||||
const { data, isLoading, isError } = useGetSavedView(
|
||||
{ id: viewKey },
|
||||
{ query: { enabled: !!viewKey } },
|
||||
);
|
||||
const view = viewKey ? data?.data : undefined;
|
||||
|
||||
// The query builder stages the url query a render later. Until the ids
|
||||
// match, keep the last answer for this view; another view starts clean.
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
const isStagedQueryCurrent =
|
||||
!!stagedQuery && stagedQuery.id === compositeQuery?.id;
|
||||
const lastResult = useRef<{ viewId?: string; hasUnsavedChanges: boolean }>({
|
||||
hasUnsavedChanges: false,
|
||||
});
|
||||
|
||||
const hasUnsavedChanges = useMemo((): boolean => {
|
||||
if (!view) {
|
||||
return false;
|
||||
}
|
||||
if (!isStagedQueryCurrent) {
|
||||
return (
|
||||
lastResult.current.viewId === view.id &&
|
||||
lastResult.current.hasUnsavedChanges
|
||||
);
|
||||
}
|
||||
return hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery,
|
||||
panelType: panelType ?? PANEL_TYPES.LIST,
|
||||
options: optionsDataSource ? options : undefined,
|
||||
});
|
||||
}, [
|
||||
view,
|
||||
isStagedQueryCurrent,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
optionsDataSource,
|
||||
options,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
lastResult.current = { viewId: view?.id, hasUnsavedChanges };
|
||||
}, [view?.id, hasUnsavedChanges]);
|
||||
|
||||
return {
|
||||
view,
|
||||
isLoading: !!viewKey && isLoading,
|
||||
isError: !!viewKey && isError,
|
||||
hasUnsavedChanges,
|
||||
};
|
||||
}
|
||||
55
frontend/src/container/SavedViews/hooks/useLastUsedView.ts
Normal file
55
frontend/src/container/SavedViews/hooks/useLastUsedView.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from 'react';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import {
|
||||
PreservedViewsInLocalStorage,
|
||||
PreservedViewType,
|
||||
} from 'container/ExplorerOptions/types';
|
||||
|
||||
import { UseLastUsedViewResult } from '../types';
|
||||
|
||||
function readLastUsedViews(): PreservedViewsInLocalStorage {
|
||||
try {
|
||||
return JSON.parse(
|
||||
getLocalStorageKey(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) || '{}',
|
||||
) as PreservedViewsInLocalStorage;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Read at call time, not held in state: the hook is used from more than one
|
||||
// place and a cached copy would bring back a view another caller just cleared.
|
||||
export function useLastUsedView(
|
||||
savedViewSource: SavedviewtypesSourceDTO,
|
||||
): UseLastUsedViewResult {
|
||||
// The bar's enum carries the same four values as the v2 source.
|
||||
const source = savedViewSource as unknown as PreservedViewType;
|
||||
|
||||
const getLastUsedViewKey = useCallback(
|
||||
(): string | undefined => readLastUsedViews()[source]?.key,
|
||||
[source],
|
||||
);
|
||||
|
||||
const setLastUsedView = useCallback(
|
||||
(id: string, displayName: string): void => {
|
||||
setLocalStorageKey(
|
||||
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
|
||||
JSON.stringify({
|
||||
...readLastUsedViews(),
|
||||
[source]: { key: id, value: displayName },
|
||||
}),
|
||||
);
|
||||
},
|
||||
[source],
|
||||
);
|
||||
|
||||
const clearLastUsedView = useCallback((): void => {
|
||||
const { [source]: _removed, ...rest } = readLastUsedViews();
|
||||
setLocalStorageKey(LOCALSTORAGE.LAST_USED_SAVED_VIEWS, JSON.stringify(rest));
|
||||
}, [source]);
|
||||
|
||||
return { getLastUsedViewKey, setLastUsedView, clearLastUsedView };
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useGetSavedView } from 'api/generated/services/saved-view';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import { UseRestoreLastUsedViewArgs } from '../types';
|
||||
import { useLastUsedView } from './useLastUsedView';
|
||||
|
||||
export function useRestoreLastUsedView({
|
||||
source,
|
||||
selectView,
|
||||
}: UseRestoreLastUsedViewArgs): void {
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const { getLastUsedViewKey, clearLastUsedView } = useLastUsedView(source);
|
||||
|
||||
// Decided on the first render, before the explorer writes its default query
|
||||
// to the url; later an empty url means the view was cleared.
|
||||
const [lastUsedViewKey] = useState(() => {
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
const isBare =
|
||||
!params.get(QueryParams.viewKey) && !params.get(QueryParams.compositeQuery);
|
||||
return isBare ? getLastUsedViewKey() : undefined;
|
||||
});
|
||||
const hasRestored = useRef(false);
|
||||
|
||||
const shouldRestore = !!lastUsedViewKey && !viewKey && !hasRestored.current;
|
||||
|
||||
const { data, isError } = useGetSavedView(
|
||||
{ id: lastUsedViewKey ?? '' },
|
||||
{ query: { enabled: shouldRestore } },
|
||||
);
|
||||
const view = data?.data;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRestore) {
|
||||
return undefined;
|
||||
}
|
||||
if (isError) {
|
||||
hasRestored.current = true;
|
||||
clearLastUsedView();
|
||||
return undefined;
|
||||
}
|
||||
if (!view) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Deferred like the bar's restore, so it lands after the explorer has
|
||||
// written its default query to the url.
|
||||
const timeoutId = setTimeout(() => {
|
||||
hasRestored.current = true;
|
||||
selectView(view);
|
||||
}, 0);
|
||||
return (): void => clearTimeout(timeoutId);
|
||||
}, [shouldRestore, isError, view, clearLastUsedView, selectView]);
|
||||
}
|
||||
193
frontend/src/container/SavedViews/hooks/useSavedViewActions.ts
Normal file
193
frontend/src/container/SavedViews/hooks/useSavedViewActions.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
invalidateGetSavedView,
|
||||
invalidateListSavedViews,
|
||||
useCreateSavedView,
|
||||
useUpdateSavedView,
|
||||
} from 'api/generated/services/saved-view';
|
||||
import {
|
||||
RenderErrorResponseDTO,
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSavedViewSpecDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { ErrorType } from 'api/generatedAPIInstance';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
|
||||
|
||||
import {
|
||||
SAVED_VIEW_OPTIONS_DATA_SOURCE,
|
||||
SAVED_VIEW_SCHEMA_VERSION,
|
||||
SAVED_VIEW_TOAST_POSITION,
|
||||
SAVED_VIEW_URL_PARAMS,
|
||||
} from '../constants';
|
||||
import { UseSavedViewActionsResult } from '../types';
|
||||
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
|
||||
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
|
||||
import { useLastUsedView } from './useLastUsedView';
|
||||
|
||||
// Columns and formatting need no handling here: with a viewKey in the url the
|
||||
// preference provider reads them from the view, and without one it falls back
|
||||
// to the user's own.
|
||||
export function useSavedViewActions(
|
||||
source: SavedviewtypesSourceDTO,
|
||||
): UseSavedViewActionsResult {
|
||||
const queryClient = useQueryClient();
|
||||
const { pathname } = useLocation();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { currentQuery, stagedQuery, panelType, redirectWithQueryBuilderData } =
|
||||
useQueryBuilder();
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const { setLastUsedView, clearLastUsedView } = useLastUsedView(source);
|
||||
|
||||
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
|
||||
const { options } = useOptionsMenu({
|
||||
dataSource: optionsDataSource ?? DataSource.LOGS,
|
||||
});
|
||||
|
||||
const { mutateAsync: createSavedView, isLoading: isCreating } =
|
||||
useCreateSavedView();
|
||||
const { mutateAsync: updateSavedView, isLoading: isUpdating } =
|
||||
useUpdateSavedView();
|
||||
|
||||
const invalidateViews = useCallback(
|
||||
async (id?: string): Promise<void> => {
|
||||
await Promise.all([
|
||||
invalidateListSavedViews(queryClient),
|
||||
id ? invalidateGetSavedView(queryClient, { id }) : undefined,
|
||||
// The saved views bar still lists through v1.
|
||||
queryClient.invalidateQueries([{ sourcepage: source }]),
|
||||
]);
|
||||
},
|
||||
[queryClient, source],
|
||||
);
|
||||
|
||||
const toCurrentSpec = useCallback(
|
||||
(displayName: string, color?: string): SavedviewtypesSavedViewSpecDTO =>
|
||||
toSavedViewSpec({
|
||||
query: stagedQuery ?? currentQuery,
|
||||
panelType: panelType ?? PANEL_TYPES.LIST,
|
||||
displayName,
|
||||
options: optionsDataSource ? options : undefined,
|
||||
color,
|
||||
}),
|
||||
[stagedQuery, currentQuery, panelType, optionsDataSource, options],
|
||||
);
|
||||
|
||||
const selectView = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
handleExplorerTabChange(view.spec.panelType, {
|
||||
query: getSavedViewQuery(view),
|
||||
viewKey: view.id,
|
||||
});
|
||||
setLastUsedView(view.id, view.spec.displayName);
|
||||
},
|
||||
[handleExplorerTabChange, setLastUsedView],
|
||||
);
|
||||
|
||||
// The preference provider re-reads a view's columns only when its list
|
||||
// changes, so the list is refetched as the view is applied again.
|
||||
const revertView = useCallback(
|
||||
(view: SavedviewtypesSavedViewDTO): void => {
|
||||
void invalidateListSavedViews(queryClient);
|
||||
selectView(view);
|
||||
},
|
||||
[queryClient, selectView],
|
||||
);
|
||||
|
||||
// The query builder redirects only ever add params, so the url is rebuilt
|
||||
// here; the time range and everything else a view does not set stays.
|
||||
const clearView = useCallback((): void => {
|
||||
clearLastUsedView();
|
||||
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
SAVED_VIEW_URL_PARAMS.forEach((param) => params.delete(param));
|
||||
safeNavigate(`${pathname}?${params.toString()}`);
|
||||
}, [clearLastUsedView, pathname, safeNavigate]);
|
||||
|
||||
const createView = useCallback(
|
||||
async (displayName: string): Promise<string | undefined> => {
|
||||
try {
|
||||
const { data } = await createSavedView({
|
||||
data: {
|
||||
generateName: true,
|
||||
source,
|
||||
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
|
||||
spec: toCurrentSpec(displayName),
|
||||
},
|
||||
});
|
||||
await invalidateViews();
|
||||
redirectWithQueryBuilderData(stagedQuery ?? currentQuery, {
|
||||
[QueryParams.viewKey]: data.id,
|
||||
});
|
||||
setLastUsedView(data.id, displayName);
|
||||
return data.id;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
toAPIError(
|
||||
error as ErrorType<RenderErrorResponseDTO>,
|
||||
'Could not save the view',
|
||||
).getErrorMessage(),
|
||||
{ position: SAVED_VIEW_TOAST_POSITION },
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
[
|
||||
createSavedView,
|
||||
source,
|
||||
toCurrentSpec,
|
||||
invalidateViews,
|
||||
redirectWithQueryBuilderData,
|
||||
stagedQuery,
|
||||
currentQuery,
|
||||
setLastUsedView,
|
||||
],
|
||||
);
|
||||
|
||||
const updateView = useCallback(
|
||||
async (view: SavedviewtypesSavedViewDTO): Promise<boolean> => {
|
||||
try {
|
||||
await updateSavedView({
|
||||
pathParams: { id: view.id },
|
||||
data: {
|
||||
source,
|
||||
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
|
||||
spec: toCurrentSpec(view.spec.displayName, view.spec.display?.color),
|
||||
},
|
||||
});
|
||||
await invalidateViews(view.id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
toAPIError(
|
||||
error as ErrorType<RenderErrorResponseDTO>,
|
||||
'Could not update the view',
|
||||
).getErrorMessage(),
|
||||
{ position: SAVED_VIEW_TOAST_POSITION },
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[updateSavedView, source, toCurrentSpec, invalidateViews],
|
||||
);
|
||||
|
||||
return {
|
||||
selectView,
|
||||
revertView,
|
||||
clearView,
|
||||
createView,
|
||||
updateView,
|
||||
isSaving: isCreating || isUpdating,
|
||||
};
|
||||
}
|
||||
83
frontend/src/container/SavedViews/types.ts
Normal file
83
frontend/src/container/SavedViews/types.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ReactElement } from 'react';
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export type SavedViewSourcePage = DataSource | 'meter';
|
||||
|
||||
export type SavedViewOptions = Pick<
|
||||
OptionsQuery,
|
||||
'selectColumns' | 'format' | 'maxLines' | 'fontSize'
|
||||
>;
|
||||
|
||||
export interface ToSavedViewSpecArgs {
|
||||
query: Query;
|
||||
panelType: PANEL_TYPES;
|
||||
displayName: string;
|
||||
options?: SavedViewOptions;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface HasUnsavedViewChangesArgs {
|
||||
view: SavedviewtypesSavedViewDTO;
|
||||
stagedQuery: Query | null;
|
||||
panelType: PANEL_TYPES;
|
||||
options?: SavedViewOptions;
|
||||
}
|
||||
|
||||
export interface UseLastUsedViewResult {
|
||||
getLastUsedViewKey: () => string | undefined;
|
||||
setLastUsedView: (id: string, displayName: string) => void;
|
||||
clearLastUsedView: () => void;
|
||||
}
|
||||
|
||||
export interface UseActiveSavedViewResult {
|
||||
view: SavedviewtypesSavedViewDTO | undefined;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
hasUnsavedChanges: boolean;
|
||||
}
|
||||
|
||||
export interface UseSavedViewActionsResult {
|
||||
selectView: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
revertView: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
clearView: () => void;
|
||||
createView: (displayName: string) => Promise<string | undefined>;
|
||||
updateView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export interface UseRestoreLastUsedViewArgs {
|
||||
source: SavedviewtypesSourceDTO;
|
||||
selectView: UseSavedViewActionsResult['selectView'];
|
||||
}
|
||||
|
||||
export type SaveViewModalMode = 'create' | 'saveAsNew';
|
||||
|
||||
export interface SaveViewModalProps {
|
||||
mode: SaveViewModalMode;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
// Resolves true when saved; the modal stays open otherwise.
|
||||
onSave: (displayName: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface SaveChangesMenuProps {
|
||||
disabled: boolean;
|
||||
onSaveAsNew: () => void;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export interface SavedViewsIconButtonProps {
|
||||
title: string;
|
||||
icon: ReactElement;
|
||||
testId: string;
|
||||
color?: 'secondary' | 'warning';
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryEnvelope } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export type SavedViewSourcePage = DataSource | 'meter';
|
||||
|
||||
// Explorers and the preferences module are keyed by DataSource (the signal),
|
||||
// the api keys views by source page. Same values today, so this is the one
|
||||
// place they meet. AI observability views will come with their own source and
|
||||
// DataSource cannot tell them apart from traces, so preferences should move to
|
||||
// source page at that point and this map goes with it.
|
||||
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
|
||||
{
|
||||
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
|
||||
[DataSource.TRACES]: SavedviewtypesSourceDTO.traces,
|
||||
[DataSource.METRICS]: SavedviewtypesSourceDTO.metrics,
|
||||
meter: SavedviewtypesSourceDTO.meter,
|
||||
};
|
||||
|
||||
export function toSavedViewSource(
|
||||
sourcePage: SavedViewSourcePage,
|
||||
): SavedviewtypesSourceDTO {
|
||||
return SAVED_VIEW_SOURCE[sourcePage];
|
||||
}
|
||||
|
||||
// Explorers only save builder queries; v2 carries no queryType, so it is fixed here.
|
||||
export function getSavedViewQuery(view: SavedviewtypesSavedViewDTO): Query {
|
||||
const { queries, panelType } = view.spec;
|
||||
return mapQueryDataFromApi({
|
||||
queries: queries as QueryEnvelope[],
|
||||
panelType: panelType as unknown as PANEL_TYPES,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
unit: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function findSavedView(
|
||||
views: SavedviewtypesSavedViewDTO[] | null | undefined,
|
||||
id: string,
|
||||
): SavedviewtypesSavedViewDTO | undefined {
|
||||
return views?.find((view) => view.id === id);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { findSavedView } from '../findSavedView';
|
||||
|
||||
describe('findSavedView', () => {
|
||||
const views = [{ id: 'a' }, { id: 'b' }] as SavedviewtypesSavedViewDTO[];
|
||||
|
||||
it('returns the view with the matching id', () => {
|
||||
expect(findSavedView(views, 'b')?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('returns undefined when the id is not in the list', () => {
|
||||
expect(findSavedView(views, 'c')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a null or not yet loaded list', () => {
|
||||
expect(findSavedView(null, 'a')).toBeUndefined();
|
||||
expect(findSavedView(undefined, 'a')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { findSavedView, getSavedViewQuery, toSavedViewSource } from '../utils';
|
||||
import { getSavedViewQuery } from '../getSavedViewQuery';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: (): string => 'test-id',
|
||||
@@ -91,36 +91,3 @@ describe('getSavedViewQuery', () => {
|
||||
expect(query).not.toHaveProperty('panelType', PANEL_TYPES.TIME_SERIES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSavedViewSource', () => {
|
||||
it('maps every explorer source page to the v2 source', () => {
|
||||
expect(toSavedViewSource(DataSource.LOGS)).toBe(SavedviewtypesSourceDTO.logs);
|
||||
expect(toSavedViewSource(DataSource.TRACES)).toBe(
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
expect(toSavedViewSource(DataSource.METRICS)).toBe(
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
);
|
||||
expect(toSavedViewSource('meter')).toBe(SavedviewtypesSourceDTO.meter);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSavedView', () => {
|
||||
const views = [
|
||||
{ ...makeView(), id: 'a' },
|
||||
{ ...makeView(), id: 'b' },
|
||||
];
|
||||
|
||||
it('returns the view with the matching id', () => {
|
||||
expect(findSavedView(views, 'b')?.id).toBe('b');
|
||||
});
|
||||
|
||||
it('returns undefined when the id is not in the list', () => {
|
||||
expect(findSavedView(views, 'c')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a null or not yet loaded list', () => {
|
||||
expect(findSavedView(null, 'a')).toBeUndefined();
|
||||
expect(findSavedView(undefined, 'a')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
SavedviewtypesSavedViewDTO,
|
||||
SavedviewtypesSavedViewSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormValuesMap,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
ensureLogsRequiredColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { getSavedViewQuery } from '../getSavedViewQuery';
|
||||
import { hasUnsavedViewChanges } from '../hasUnsavedViewChanges';
|
||||
import { toSavedViewSpec } from '../toSavedViewSpec';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: (): string => 'test-id',
|
||||
}));
|
||||
|
||||
function query(dataSource: DataSource, expression = 'has_error = true'): Query {
|
||||
const base = initialQueriesMap[dataSource];
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
filter: { expression },
|
||||
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
}
|
||||
|
||||
// The server stores the queries through typed structs: nulls, empty strings,
|
||||
// zeros and empty lists do not come back.
|
||||
function asStoredByServer(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map(asStoredByServer).filter((v) => v !== undefined);
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const entries = Object.entries(value)
|
||||
.map(([key, v]) => [key, asStoredByServer(v)] as const)
|
||||
.filter(([, v]) => v !== undefined);
|
||||
return Object.fromEntries(entries);
|
||||
}
|
||||
return value === null || value === '' || value === 0 ? undefined : value;
|
||||
}
|
||||
|
||||
function viewFrom(
|
||||
spec: SavedviewtypesSavedViewSpecDTO,
|
||||
): SavedviewtypesSavedViewDTO {
|
||||
return {
|
||||
id: 'view-1',
|
||||
spec: {
|
||||
...spec,
|
||||
queries: asStoredByServer(
|
||||
JSON.parse(JSON.stringify(spec.queries)),
|
||||
) as SavedviewtypesSavedViewSpecDTO['queries'],
|
||||
},
|
||||
} as SavedviewtypesSavedViewDTO;
|
||||
}
|
||||
|
||||
// What the query builder stages after the view is opened: the stored query,
|
||||
// with its defaults filled in.
|
||||
function stagedFrom(
|
||||
view: SavedviewtypesSavedViewDTO,
|
||||
dataSource: DataSource,
|
||||
): Query {
|
||||
const saved = getSavedViewQuery(view);
|
||||
return {
|
||||
...saved,
|
||||
builder: {
|
||||
...saved.builder,
|
||||
queryData: saved.builder.queryData.map((queryData) => ({
|
||||
...initialQueryBuilderFormValuesMap[dataSource],
|
||||
...queryData,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ name: 'service.name', key: 'service.name' },
|
||||
{ name: 'name', key: 'name' },
|
||||
] as TelemetryFieldKey[];
|
||||
|
||||
const formatting = {
|
||||
format: 'raw' as LogViewMode,
|
||||
maxLines: 2,
|
||||
fontSize: FontSize.SMALL,
|
||||
};
|
||||
|
||||
describe('hasUnsavedViewChanges', () => {
|
||||
const options = { selectColumns: columns, ...formatting };
|
||||
const view = viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: query(DataSource.TRACES),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
options,
|
||||
}),
|
||||
);
|
||||
const staged = stagedFrom(view, DataSource.TRACES);
|
||||
|
||||
it('is dirty when the columns change', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
options: { ...options, selectColumns: [columns[0]] },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a traces view without columns as the default columns', () => {
|
||||
const bare = viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: query(DataSource.TRACES),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: bare,
|
||||
stagedQuery: stagedFrom(bare, DataSource.TRACES),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
options: { ...options, selectColumns: defaultTraceSelectedColumns },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
describe('on logs', () => {
|
||||
const logsView = viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: query(DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
options: { selectColumns: columns, ...formatting },
|
||||
}),
|
||||
);
|
||||
const logsStaged = stagedFrom(logsView, DataSource.LOGS);
|
||||
// The explorer always adds the required logs columns.
|
||||
const shownColumns = ensureLogsRequiredColumns(columns);
|
||||
|
||||
it('is clean with the required columns the explorer adds', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: logsView,
|
||||
stagedQuery: logsStaged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
options: { selectColumns: shownColumns, ...formatting },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is dirty when the formatting changes', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: logsView,
|
||||
stagedQuery: logsStaged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
options: { selectColumns: shownColumns, ...formatting, maxLines: 5 },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reads a view without formatting as the default formatting', () => {
|
||||
const bare = viewFrom(
|
||||
toSavedViewSpec({
|
||||
query: query(DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view: bare,
|
||||
stagedQuery: stagedFrom(bare, DataSource.LOGS),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
options: {
|
||||
selectColumns: defaultLogsSelectedColumns,
|
||||
format: 'table',
|
||||
maxLines: 1,
|
||||
fontSize: FontSize.SMALL,
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores formatting on traces', () => {
|
||||
expect(
|
||||
hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery: staged,
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
options: { ...options, ...formatting, maxLines: 5 },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { toSavedViewSource } from '../toSavedViewSource';
|
||||
|
||||
describe('toSavedViewSource', () => {
|
||||
it('maps every explorer source page to the v2 source', () => {
|
||||
expect(toSavedViewSource(DataSource.LOGS)).toBe(SavedviewtypesSourceDTO.logs);
|
||||
expect(toSavedViewSource(DataSource.TRACES)).toBe(
|
||||
SavedviewtypesSourceDTO.traces,
|
||||
);
|
||||
expect(toSavedViewSource(DataSource.METRICS)).toBe(
|
||||
SavedviewtypesSourceDTO.metrics,
|
||||
);
|
||||
expect(toSavedViewSource('meter')).toBe(SavedviewtypesSourceDTO.meter);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
Querybuildertypesv5RequestTypeDTO,
|
||||
SavedviewtypesPanelTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
|
||||
|
||||
import { toSavedViewPanelType, toSavedViewSpec } from '../toSavedViewSpec';
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: (): string => 'test-id',
|
||||
}));
|
||||
|
||||
function tracesQuery(): Query {
|
||||
const base = initialQueriesMap.traces;
|
||||
return {
|
||||
...base,
|
||||
builder: {
|
||||
...base.builder,
|
||||
queryData: [
|
||||
{
|
||||
...base.builder.queryData[0],
|
||||
filter: { expression: 'has_error = true' },
|
||||
groupBy: [{ key: 'service.name', dataType: 'string', type: 'resource' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as Query;
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: 'service.name',
|
||||
key: 'service.name',
|
||||
signal: 'traces',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
isIndexed: true,
|
||||
},
|
||||
{ key: 'name' },
|
||||
] as unknown as TelemetryFieldKey[];
|
||||
|
||||
describe('toSavedViewPanelType', () => {
|
||||
it('folds a dashboard only panel type to graph', () => {
|
||||
expect(toSavedViewPanelType(PANEL_TYPES.PIE)).toBe(
|
||||
SavedviewtypesPanelTypeDTO.graph,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toSavedViewSpec', () => {
|
||||
it('stores only the field identity of the columns, naming key only ones', () => {
|
||||
const spec = toSavedViewSpec({
|
||||
query: tracesQuery(),
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
displayName: 'Errors',
|
||||
options: {
|
||||
selectColumns: columns,
|
||||
format: 'raw' as LogViewMode,
|
||||
maxLines: 2,
|
||||
fontSize: FontSize.SMALL,
|
||||
},
|
||||
});
|
||||
|
||||
expect(spec.selectedFields).toStrictEqual([
|
||||
{
|
||||
name: 'service.name',
|
||||
signal: 'traces',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
{ name: 'name' },
|
||||
]);
|
||||
expect(spec.display).toStrictEqual({
|
||||
format: 'raw',
|
||||
maxLines: 2,
|
||||
fontSize: FontSize.SMALL,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the colour in display', () => {
|
||||
const spec = toSavedViewSpec({
|
||||
query: tracesQuery(),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
displayName: 'Errors',
|
||||
color: 'red',
|
||||
});
|
||||
|
||||
expect(spec.panelType).toBe(SavedviewtypesPanelTypeDTO.graph);
|
||||
expect(spec.requestType).toBe(Querybuildertypesv5RequestTypeDTO.time_series);
|
||||
expect(spec.display).toStrictEqual({ color: 'red' });
|
||||
});
|
||||
});
|
||||
8
frontend/src/container/SavedViews/utils/findSavedView.ts
Normal file
8
frontend/src/container/SavedViews/utils/findSavedView.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export function findSavedView(
|
||||
views: SavedviewtypesSavedViewDTO[] | null | undefined,
|
||||
id: string,
|
||||
): SavedviewtypesSavedViewDTO | undefined {
|
||||
return views?.find((view) => view.id === id);
|
||||
}
|
||||
17
frontend/src/container/SavedViews/utils/getSavedViewQuery.ts
Normal file
17
frontend/src/container/SavedViews/utils/getSavedViewQuery.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { SavedviewtypesSavedViewDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryEnvelope } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
// Explorers only save builder queries; v2 carries no queryType, so it is fixed here.
|
||||
export function getSavedViewQuery(view: SavedviewtypesSavedViewDTO): Query {
|
||||
const { queries, panelType } = view.spec;
|
||||
return mapQueryDataFromApi({
|
||||
queries: queries as QueryEnvelope[],
|
||||
panelType: panelType as unknown as PANEL_TYPES,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
unit: undefined,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { SavedviewtypesSavedViewSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
ensureLogsRequiredColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
|
||||
import { Preferences } from 'providers/preferences/types';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
function withColumnNames(
|
||||
columns: TelemetryFieldKey[] | undefined,
|
||||
): TelemetryFieldKey[] | null {
|
||||
if (!columns) {
|
||||
return null;
|
||||
}
|
||||
return columns.map((column) => ({
|
||||
...column,
|
||||
name: column.name ?? column.key,
|
||||
}));
|
||||
}
|
||||
|
||||
// The columns and formatting the explorer shows for a saved view, with the
|
||||
// defaults filled in where the view has none.
|
||||
export function getViewColumnsAndFormatting(
|
||||
spec: SavedviewtypesSavedViewSpecDTO | undefined,
|
||||
dataSource: DataSource,
|
||||
): Preferences {
|
||||
const selectedFields = spec?.selectedFields as TelemetryFieldKey[] | undefined;
|
||||
|
||||
if (dataSource === DataSource.LOGS) {
|
||||
return {
|
||||
columns: ensureLogsRequiredColumns(
|
||||
withColumnNames(selectedFields) || defaultLogsSelectedColumns,
|
||||
),
|
||||
formatting: {
|
||||
maxLines: spec?.display?.maxLines || 1,
|
||||
format: (spec?.display?.format as LogViewMode) || 'table',
|
||||
fontSize: (spec?.display?.fontSize as FontSize) || FontSize.SMALL,
|
||||
version: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
return { columns: selectedFields || defaultTraceSelectedColumns };
|
||||
}
|
||||
return { columns: [] };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
|
||||
import isEqual from 'lodash-es/isEqual';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { HasUnsavedViewChangesArgs } from '../types';
|
||||
import { toSavedViewPanelType } from './toSavedViewSpec';
|
||||
import { getViewColumnsAndFormatting } from './getViewColumnsAndFormatting';
|
||||
|
||||
// The server drops empty values when it stores a query, while the query
|
||||
// builder fills them with defaults, so both sides lose them before comparing.
|
||||
function withoutEmptyValues(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
const items = value
|
||||
.map(withoutEmptyValues)
|
||||
.filter((item) => item !== undefined);
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const entries = Object.entries(value)
|
||||
.map(([key, item]) => [key, withoutEmptyValues(item)] as const)
|
||||
.filter(([, item]) => item !== undefined);
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
||||
}
|
||||
if (value === null || value === '' || value === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// Compared as stored (v5 envelopes): mapped back to the builder's shape, each
|
||||
// side gets different defaults.
|
||||
export function hasUnsavedViewChanges({
|
||||
view,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
options,
|
||||
}: HasUnsavedViewChangesArgs): boolean {
|
||||
if (!stagedQuery) {
|
||||
return false;
|
||||
}
|
||||
if (view.spec.panelType !== toSavedViewPanelType(panelType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { queries: stagedQueries } = mapCompositeQueryFromQuery(
|
||||
stagedQuery,
|
||||
panelType,
|
||||
);
|
||||
if (
|
||||
!isEqual(
|
||||
withoutEmptyValues(view.spec.queries),
|
||||
withoutEmptyValues(stagedQueries),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!options) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const dataSource = stagedQuery.builder.queryData[0]?.dataSource;
|
||||
const { columns, formatting } = getViewColumnsAndFormatting(
|
||||
view.spec,
|
||||
dataSource,
|
||||
);
|
||||
const toNames = (fields: { name?: string; key?: string }[]): unknown[] =>
|
||||
fields.map((field) => field.name ?? field.key);
|
||||
if (!isEqual(toNames(columns), toNames(options.selectColumns ?? []))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (dataSource !== DataSource.LOGS || !formatting) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
formatting.format !== options.format ||
|
||||
formatting.maxLines !== options.maxLines ||
|
||||
formatting.fontSize !== options.fontSize
|
||||
);
|
||||
}
|
||||
23
frontend/src/container/SavedViews/utils/toSavedViewSource.ts
Normal file
23
frontend/src/container/SavedViews/utils/toSavedViewSource.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { SavedViewSourcePage } from '../types';
|
||||
|
||||
// Explorers and the preferences module are keyed by DataSource (the signal),
|
||||
// the api keys views by source page. Same values today, so this is the one
|
||||
// place they meet. AI observability views will come with their own source and
|
||||
// DataSource cannot tell them apart from traces, so preferences should move to
|
||||
// source page at that point and this map goes with it.
|
||||
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
|
||||
{
|
||||
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,
|
||||
[DataSource.TRACES]: SavedviewtypesSourceDTO.traces,
|
||||
[DataSource.METRICS]: SavedviewtypesSourceDTO.metrics,
|
||||
meter: SavedviewtypesSourceDTO.meter,
|
||||
};
|
||||
|
||||
export function toSavedViewSource(
|
||||
sourcePage: SavedViewSourcePage,
|
||||
): SavedviewtypesSourceDTO {
|
||||
return SAVED_VIEW_SOURCE[sourcePage];
|
||||
}
|
||||
80
frontend/src/container/SavedViews/utils/toSavedViewSpec.ts
Normal file
80
frontend/src/container/SavedViews/utils/toSavedViewSpec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
SavedviewtypesDisplayDTO,
|
||||
SavedviewtypesPanelTypeDTO,
|
||||
SavedviewtypesSavedViewSpecDTO,
|
||||
TelemetrytypesFieldContextDTO,
|
||||
TelemetrytypesFieldDataTypeDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
|
||||
import { panelTypeToRequestType } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { ToSavedViewSpecArgs } from '../types';
|
||||
|
||||
const SAVED_VIEW_PANEL_TYPES = new Set<string>(
|
||||
Object.values(SavedviewtypesPanelTypeDTO),
|
||||
);
|
||||
|
||||
// Explorers only produce list / trace / graph / table; anything else a caller
|
||||
// hands over (a dashboard panel type) is stored as a time series view.
|
||||
export function toSavedViewPanelType(
|
||||
panelType: PANEL_TYPES,
|
||||
): SavedviewtypesPanelTypeDTO {
|
||||
return SAVED_VIEW_PANEL_TYPES.has(panelType)
|
||||
? (panelType as unknown as SavedviewtypesPanelTypeDTO)
|
||||
: SavedviewtypesPanelTypeDTO.graph;
|
||||
}
|
||||
|
||||
// The api stores the field identity only; `key` and the column flags are ui state.
|
||||
function toFieldKey(
|
||||
column: TelemetryFieldKey,
|
||||
): TelemetrytypesTelemetryFieldKeyDTO {
|
||||
return {
|
||||
name: column.name ?? column.key ?? '',
|
||||
...(column.signal && {
|
||||
signal: column.signal as unknown as TelemetrytypesSignalDTO,
|
||||
}),
|
||||
...(column.fieldContext && {
|
||||
fieldContext:
|
||||
column.fieldContext as unknown as TelemetrytypesFieldContextDTO,
|
||||
}),
|
||||
...(column.fieldDataType && {
|
||||
fieldDataType:
|
||||
column.fieldDataType as unknown as TelemetrytypesFieldDataTypeDTO,
|
||||
}),
|
||||
...(column.description && { description: column.description }),
|
||||
...(column.unit && { unit: column.unit }),
|
||||
};
|
||||
}
|
||||
|
||||
export function toSavedViewSpec({
|
||||
query,
|
||||
panelType,
|
||||
displayName,
|
||||
options,
|
||||
color,
|
||||
}: ToSavedViewSpecArgs): SavedviewtypesSavedViewSpecDTO {
|
||||
const { queries } = mapCompositeQueryFromQuery(query, panelType);
|
||||
|
||||
const display: SavedviewtypesDisplayDTO = {
|
||||
...(color && { color }),
|
||||
...(options?.format && { format: options.format }),
|
||||
...(options?.maxLines && { maxLines: options.maxLines }),
|
||||
...(options?.fontSize && { fontSize: options.fontSize }),
|
||||
};
|
||||
|
||||
return {
|
||||
displayName,
|
||||
panelType: toSavedViewPanelType(panelType),
|
||||
requestType: panelTypeToRequestType(panelType),
|
||||
queries: (queries ?? []) as unknown as Querybuildertypesv5QueryEnvelopeDTO[],
|
||||
...(options?.selectColumns?.length && {
|
||||
selectedFields: options.selectColumns.map(toFieldKey),
|
||||
}),
|
||||
...(Object.keys(display).length > 0 && { display }),
|
||||
};
|
||||
}
|
||||
@@ -73,32 +73,20 @@ export const useHandleExplorerTabChange = (): {
|
||||
}
|
||||
|
||||
const query = currentQueryData?.query || getUpdateQuery(newPanelType);
|
||||
const nextViewKey = currentQueryData?.viewKey || viewKey;
|
||||
const nextViewName = currentQueryData?.viewName || viewName;
|
||||
|
||||
if (redirectToUrl) {
|
||||
redirectWithQueryBuilderData(
|
||||
query,
|
||||
{
|
||||
[QueryParams.panelTypes]: newPanelType,
|
||||
[QueryParams.viewName]: currentQueryData?.viewName || viewName,
|
||||
[QueryParams.viewKey]: currentQueryData?.viewKey || viewKey,
|
||||
},
|
||||
redirectToUrl,
|
||||
undefined,
|
||||
newTab,
|
||||
);
|
||||
} else {
|
||||
redirectWithQueryBuilderData(
|
||||
query,
|
||||
{
|
||||
[QueryParams.panelTypes]: newPanelType,
|
||||
[QueryParams.viewName]: currentQueryData?.viewName || viewName,
|
||||
[QueryParams.viewKey]: currentQueryData?.viewKey || viewKey,
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
newTab,
|
||||
);
|
||||
}
|
||||
redirectWithQueryBuilderData(
|
||||
query,
|
||||
{
|
||||
[QueryParams.panelTypes]: newPanelType,
|
||||
...(nextViewKey ? { [QueryParams.viewKey]: nextViewKey } : {}),
|
||||
...(nextViewName ? { [QueryParams.viewName]: nextViewName } : {}),
|
||||
},
|
||||
redirectToUrl,
|
||||
undefined,
|
||||
newTab,
|
||||
);
|
||||
},
|
||||
[panelType, getUpdateQuery, redirectWithQueryBuilderData, viewName, viewKey],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { MemoryRouter } from 'react-router-dom-v5-compat';
|
||||
import * as panelTypesQueryParamHooks from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
|
||||
import LogsExplorer from '../index';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${ROUTES.LOGS_EXPLORER}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/Toolbar/Toolbar',
|
||||
() =>
|
||||
function Toolbar({
|
||||
showLiveLogs,
|
||||
onGoLive,
|
||||
}: {
|
||||
showLiveLogs?: boolean;
|
||||
onGoLive?: () => void;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<button type="button" data-testid="go-live" onClick={onGoLive}>
|
||||
Live
|
||||
</button>
|
||||
<span data-testid="live-state">{showLiveLogs ? 'live' : 'not-live'}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'container/TimeSeriesView/TimeSeriesView',
|
||||
() =>
|
||||
function TimeSeriesView(): JSX.Element {
|
||||
return <div>Time Series Chart</div>;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'container/LogsExplorerChart',
|
||||
() =>
|
||||
function LogsExplorerChart(): JSX.Element {
|
||||
return <div>Frequency chart content</div>;
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('container/LiveLogs/LiveLogsContainer', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div>Live logs</div>,
|
||||
}));
|
||||
|
||||
jest.mock('constants/panelTypes', () => ({
|
||||
AVAILABLE_EXPORT_PANEL_TYPES: ['graph', 'table'],
|
||||
}));
|
||||
|
||||
jest.mock('d3-interpolate', () => ({
|
||||
interpolate: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
|
||||
safeNavigate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('providers/preferences/sync/usePreferenceSync', () => ({
|
||||
usePreferenceSync: (): unknown => ({
|
||||
preferences: {
|
||||
columns: [],
|
||||
formatting: { maxLines: 1, format: 'table', fontSize: 'small', version: 1 },
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
updateColumns: jest.fn(),
|
||||
updateFormatting: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
function page(): JSX.Element {
|
||||
return (
|
||||
<MemoryRouter initialEntries={[ROUTES.LOGS_EXPLORER]}>
|
||||
<PreferenceContextProvider>
|
||||
<LogsExplorer />
|
||||
</PreferenceContextProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe('Logs Explorer live mode', () => {
|
||||
// useLocation is mocked without a search string, so the tab is driven
|
||||
// through the hook that reads it from the url.
|
||||
const panelTypeSpy = jest.spyOn(
|
||||
panelTypesQueryParamHooks,
|
||||
'useGetPanelTypesQueryParam',
|
||||
);
|
||||
|
||||
afterAll(() => {
|
||||
panelTypeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('ends when the url moves off the list tab, whatever moved it', () => {
|
||||
panelTypeSpy.mockReturnValue(PANEL_TYPES.LIST);
|
||||
const { rerender } = render(page());
|
||||
|
||||
fireEvent.click(screen.getByTestId('go-live'));
|
||||
expect(screen.getByTestId('live-state')).toHaveTextContent('live');
|
||||
|
||||
panelTypeSpy.mockReturnValue(PANEL_TYPES.TIME_SERIES);
|
||||
rerender(page());
|
||||
|
||||
expect(screen.getByTestId('live-state')).toHaveTextContent('not-live');
|
||||
});
|
||||
|
||||
it('stays on while the url keeps the list tab', () => {
|
||||
panelTypeSpy.mockReturnValue(PANEL_TYPES.LIST);
|
||||
const { rerender } = render(page());
|
||||
|
||||
fireEvent.click(screen.getByTestId('go-live'));
|
||||
rerender(page());
|
||||
|
||||
expect(screen.getByTestId('live-state')).toHaveTextContent('live');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MemoryRouter } from 'react-router-dom-v5-compat';
|
||||
import * as panelTypesQueryParamHooks from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
// https://virtuoso.dev/mocking-in-tests/
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import {
|
||||
@@ -130,6 +131,30 @@ describe('Logs Explorer Tests', () => {
|
||||
// expect(oldLogsCTA).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the tab the url panelTypes param names', () => {
|
||||
// useLocation is mocked without a search string in this file, so drive
|
||||
// the param through the hook that reads it.
|
||||
const panelTypeSpy = jest
|
||||
.spyOn(panelTypesQueryParamHooks, 'useGetPanelTypesQueryParam')
|
||||
.mockReturnValue(PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
const { container } = render(
|
||||
<MemoryRouter initialEntries={['/logs-explorer/?panelTypes=%22graph%22']}>
|
||||
<PreferenceContextProvider>
|
||||
<LogsExplorer />
|
||||
</PreferenceContextProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('.timeseries-view-tab')).toHaveClass(
|
||||
'active-tab',
|
||||
);
|
||||
expect(container.querySelector('.list-view-tab')).not.toHaveClass(
|
||||
'active-tab',
|
||||
);
|
||||
panelTypeSpy.mockRestore();
|
||||
});
|
||||
|
||||
// update this test properly
|
||||
it.skip('Logs Explorer Page should render with data', async () => {
|
||||
// mocking the query range API to return the logs
|
||||
|
||||
@@ -51,9 +51,13 @@ function LogsExplorer(): JSX.Element {
|
||||
// Get panel type from URL
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(
|
||||
() => panelTypeToExplorerView[panelTypesFromUrl],
|
||||
);
|
||||
const selectedView = panelTypeToExplorerView[panelTypesFromUrl];
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedView !== ExplorerViews.LIST) {
|
||||
setShowLiveLogs(false);
|
||||
}
|
||||
}, [selectedView]);
|
||||
|
||||
const [showFilters, setShowFilters] = useState<boolean>(() => {
|
||||
const localStorageValue = getLocalStorageKey(
|
||||
@@ -116,15 +120,10 @@ function LogsExplorer(): JSX.Element {
|
||||
);
|
||||
|
||||
handleSetConfig(nextPanelType, DataSource.LOGS);
|
||||
setSelectedView(view);
|
||||
|
||||
if (view !== ExplorerViews.LIST) {
|
||||
setShowLiveLogs(false);
|
||||
}
|
||||
|
||||
handleExplorerTabChange(nextPanelType, querySearchParameters);
|
||||
},
|
||||
[handleSetConfig, handleExplorerTabChange, setSelectedView],
|
||||
[handleSetConfig, handleExplorerTabChange],
|
||||
);
|
||||
|
||||
// ─── AI Assistant page actions (only when license feature is on) ───────────
|
||||
|
||||
@@ -112,8 +112,9 @@ function TracesExplorer(): JSX.Element {
|
||||
setIsLoadingQueries(false);
|
||||
}, [queryClient]);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
|
||||
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
const selectedView = useMemo(
|
||||
() => getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
[searchParams, panelTypesFromUrl],
|
||||
);
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>();
|
||||
@@ -139,8 +140,6 @@ function TracesExplorer(): JSX.Element {
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
|
||||
|
||||
setSelectedView(view);
|
||||
|
||||
handleExplorerTabChange(
|
||||
explorerViewToPanelType[view],
|
||||
querySearchParameters,
|
||||
|
||||
@@ -2,13 +2,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useListSavedViews } from 'api/generated/services/saved-view';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import {
|
||||
defaultLogsSelectedColumns,
|
||||
defaultTraceSelectedColumns,
|
||||
ensureLogsRequiredColumns,
|
||||
} from 'container/OptionsMenu/constants';
|
||||
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
|
||||
import { findSavedView, toSavedViewSource } from 'container/SavedViews/utils';
|
||||
import { findSavedView } from 'container/SavedViews/utils/findSavedView';
|
||||
import { getViewColumnsAndFormatting } from 'container/SavedViews/utils/getViewColumnsAndFormatting';
|
||||
import { toSavedViewSource } from 'container/SavedViews/utils/toSavedViewSource';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { usePreferenceLoader } from '../loader/usePreferenceLoader';
|
||||
@@ -38,43 +34,11 @@ export function usePreferenceSync({
|
||||
const [savedViewPreferences, setSavedViewPreferences] =
|
||||
useState<Preferences | null>(null);
|
||||
|
||||
const withColumnNames = (
|
||||
columns: TelemetryFieldKey[] | undefined,
|
||||
): TelemetryFieldKey[] | null => {
|
||||
if (!columns) {
|
||||
return null;
|
||||
}
|
||||
return columns.map((column) => ({
|
||||
...column,
|
||||
name: column.name ?? column.key,
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const spec = savedViewId
|
||||
? findSavedView(viewsData?.data, savedViewId)?.spec
|
||||
: undefined;
|
||||
const selectedFields = spec?.selectedFields as
|
||||
| TelemetryFieldKey[]
|
||||
| undefined;
|
||||
|
||||
let columns: TelemetryFieldKey[] = [];
|
||||
let formatting: FormattingOptions | undefined;
|
||||
if (dataSource === DataSource.LOGS) {
|
||||
columns = ensureLogsRequiredColumns(
|
||||
withColumnNames(selectedFields) || defaultLogsSelectedColumns,
|
||||
);
|
||||
formatting = {
|
||||
maxLines: spec?.display?.maxLines || 1,
|
||||
format: (spec?.display?.format as LogViewMode) || 'table',
|
||||
fontSize: (spec?.display?.fontSize as FontSize) || FontSize.SMALL,
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
if (dataSource === DataSource.TRACES) {
|
||||
columns = selectedFields || defaultTraceSelectedColumns;
|
||||
}
|
||||
setSavedViewPreferences({ columns, formatting });
|
||||
setSavedViewPreferences(getViewColumnsAndFormatting(spec, dataSource));
|
||||
}, [viewsData, dataSource, savedViewId, mode]);
|
||||
|
||||
// We are using a reSync state because we have URL updates as well as local storage updates
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/pagerduty"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/slack"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/telegram"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/webhook"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/prometheus/alertmanager/config/receiver"
|
||||
@@ -32,6 +33,7 @@ var customNotifierIntegrations = []string{
|
||||
jira.Integration,
|
||||
jsmops.Integration,
|
||||
incidentio.Integration,
|
||||
telegram.Integration,
|
||||
}
|
||||
|
||||
func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
|
||||
@@ -102,6 +104,11 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp
|
||||
return incidentio.New(c, tmpl, l, templater)
|
||||
})
|
||||
}
|
||||
for i, c := range nc.TelegramConfigs {
|
||||
add(telegram.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
|
||||
return telegram.New(c, tmpl, l, templater)
|
||||
})
|
||||
}
|
||||
|
||||
if errs.Len() > 0 {
|
||||
return nil, &errs
|
||||
|
||||
248
pkg/alertmanager/alertmanagernotify/telegram/telegram.go
Normal file
248
pkg/alertmanager/alertmanagernotify/telegram/telegram.go
Normal file
@@ -0,0 +1,248 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// Copyright 2022 Prometheus Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package telegram delivers alerts to a Telegram chat. It follows upstream's
|
||||
// notifier for everything about the transport and adds the per-rule title and
|
||||
// body templates carried on the _title_template and _body_template
|
||||
// annotations.
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
telebot "gopkg.in/telebot.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
Integration = "telegram"
|
||||
|
||||
// Telegram supports 4096 chars max - from https://limits.tginfo.me/en.
|
||||
maxMessageLenRunes = 4096
|
||||
|
||||
parseModeHTML = "HTML"
|
||||
)
|
||||
|
||||
type Notifier struct {
|
||||
conf *config.TelegramConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *telebot.Bot
|
||||
retrier *notify.Retrier
|
||||
templater alertmanagertypes.Templater
|
||||
}
|
||||
|
||||
func New(conf *config.TelegramConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
if conf.HTTPConfig == nil {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "telegram http_config is nil")
|
||||
}
|
||||
|
||||
if conf.APIUrl == nil {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "telegram api_url is nil")
|
||||
}
|
||||
|
||||
// Rendering targets the HTML subset only. A receiver written through the v1
|
||||
// API can still carry another mode, and sending HTML-escaped text under it
|
||||
// would mangle the message, so the integration refuses to build instead.
|
||||
if conf.ParseMode != parseModeHTML {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "telegram parse_mode %q is not supported, only %s is", conf.ParseMode, parseModeHTML)
|
||||
}
|
||||
|
||||
httpclient, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration, httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := telebot.NewBot(telebot.Settings{
|
||||
URL: conf.APIUrl.String(),
|
||||
ParseMode: conf.ParseMode,
|
||||
Client: httpclient,
|
||||
Offline: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Notifier{
|
||||
conf: conf,
|
||||
tmpl: t,
|
||||
logger: l,
|
||||
client: client,
|
||||
retrier: ¬ify.Retrier{},
|
||||
templater: templater,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
key, err := notify.ExtractGroupKey(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
logger := n.logger.With(slog.String("group_key", key.String()))
|
||||
|
||||
messageText, err := n.buildMessage(ctx, logger, as)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
messageText, truncated := notify.TruncateInRunes(messageText, maxMessageLenRunes)
|
||||
if truncated {
|
||||
logger.WarnContext(ctx, "truncated message", slog.Int("max_runes", maxMessageLenRunes))
|
||||
}
|
||||
|
||||
botToken, err := n.getBotToken()
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
n.client.Token = botToken
|
||||
|
||||
chatID, err := n.getChatID()
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
|
||||
message, err := n.client.Send(telebot.ChatID(chatID), messageText, &telebot.SendOptions{
|
||||
DisableNotification: n.conf.DisableNotifications,
|
||||
DisableWebPagePreview: true,
|
||||
ThreadID: n.conf.MessageThreadID,
|
||||
ParseMode: n.conf.ParseMode,
|
||||
})
|
||||
if err != nil {
|
||||
return true, notify.NewErrorWithReason(notify.ClientErrorReason, err)
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, "telegram message published", slog.Int("message_id", message.ID), slog.Int64("chat_id", message.Chat.ID))
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// buildMessage renders the channel's own message template unless the rule
|
||||
// carries templates of its own, in which case those replace it.
|
||||
func (n *Notifier) buildMessage(ctx context.Context, logger *slog.Logger, as []*types.Alert) (string, error) {
|
||||
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(as)
|
||||
if customTitle == "" && customBody == "" {
|
||||
return n.renderChannelTemplate(ctx, as)
|
||||
}
|
||||
|
||||
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: customTitle,
|
||||
BodyTemplate: customBody,
|
||||
}, as)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(result.MissingVars) > 0 {
|
||||
logger.WarnContext(ctx, "unresolved variables in rule template", slog.Any("template.missing_vars", result.MissingVars))
|
||||
}
|
||||
|
||||
// A rule may set only one of the two, so the other half still comes from
|
||||
// the channel.
|
||||
body := strings.Join(nonEmpty(result.Body), "\n\n")
|
||||
if customBody == "" {
|
||||
body, err = n.renderChannelTemplate(ctx, as)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
body, err = n.renderRuleTemplate(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if result.Title == "" {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
title, err := n.renderRuleTemplate(result.Title)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace("<b>" + title + "</b>\n\n" + body), nil
|
||||
}
|
||||
|
||||
// renderChannelTemplate reproduces upstream's rendering, including its choice
|
||||
// of engine under parse_mode HTML: html/template escapes every interpolated
|
||||
// value, so a channel template that does not pipe through html still sends.
|
||||
func (n *Notifier) renderChannelTemplate(ctx context.Context, as []*types.Alert) (string, error) {
|
||||
var tmplErr error
|
||||
data := notify.GetTemplateData(ctx, n.tmpl, as, n.logger)
|
||||
|
||||
message := notify.TmplHTML(n.tmpl, data, &tmplErr)(n.conf.Message)
|
||||
if tmplErr != nil {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to execute telegram message template: %s", tmplErr.Error())
|
||||
}
|
||||
|
||||
return message, nil
|
||||
}
|
||||
|
||||
// renderRuleTemplate converts the templater's output from markdown, which is
|
||||
// the format rule templates are authored in for every channel, into Telegram's
|
||||
// HTML subset. The renderer escapes as it goes, so a stray < or & in an alert
|
||||
// value cannot fail the send with "can't parse entities".
|
||||
func (n *Notifier) renderRuleTemplate(markdown string) (string, error) {
|
||||
rendered, err := markdownrenderer.RenderTelegramHTML(markdown)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(rendered), nil
|
||||
}
|
||||
|
||||
func (n *Notifier) getBotToken() (string, error) {
|
||||
if n.conf.BotTokenFile == "" {
|
||||
return string(n.conf.BotToken), nil
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(n.conf.BotTokenFile)
|
||||
if err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "could not read %s", n.conf.BotTokenFile)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(content)), nil
|
||||
}
|
||||
|
||||
func (n *Notifier) getChatID() (int64, error) {
|
||||
if n.conf.ChatIDFile == "" {
|
||||
return n.conf.ChatID, nil
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(n.conf.ChatIDFile)
|
||||
if err != nil {
|
||||
return 0, errors.WrapInternalf(err, errors.CodeInternal, "could not read %s", n.conf.ChatIDFile)
|
||||
}
|
||||
|
||||
chatID, err := strconv.ParseInt(strings.TrimSpace(string(content)), 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.WrapInternalf(err, errors.CodeInternal, "could not parse chat_id from %s", n.conf.ChatIDFile)
|
||||
}
|
||||
|
||||
return chatID, nil
|
||||
}
|
||||
|
||||
func nonEmpty(parts []string) []string {
|
||||
kept := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part != "" {
|
||||
kept = append(kept, part)
|
||||
}
|
||||
}
|
||||
|
||||
return kept
|
||||
}
|
||||
265
pkg/alertmanager/alertmanagernotify/telegram/telegram_test.go
Normal file
265
pkg/alertmanager/alertmanagernotify/telegram/telegram_test.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
test "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/alertmanagernotifytest"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mockBotAPI answers sendMessage the way api.telegram.org does. telebot posts
|
||||
// every parameter as a string but parses chat.id back into an int64, so the
|
||||
// echoed id has to be a number or the send fails and the pipeline retries.
|
||||
type mockBotAPI struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
sent []map[string]string
|
||||
}
|
||||
|
||||
func newMockBotAPI(t *testing.T) *mockBotAPI {
|
||||
t.Helper()
|
||||
mock := &mockBotAPI{}
|
||||
mock.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var params map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(¶ms)
|
||||
|
||||
mock.mu.Lock()
|
||||
mock.sent = append(mock.sent, params)
|
||||
mock.mu.Unlock()
|
||||
|
||||
chatID, _ := strconv.ParseInt(params["chat_id"], 10, 64)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":1,"date":0,"chat":{"id":` + strconv.FormatInt(chatID, 10) + `,"type":"private"}}}`))
|
||||
}))
|
||||
t.Cleanup(mock.srv.Close)
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
func (m *mockBotAPI) only(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
require.Len(t, m.sent, 1)
|
||||
|
||||
return m.sent[0]
|
||||
}
|
||||
|
||||
func newNotifier(t *testing.T, mock *mockBotAPI) *Notifier {
|
||||
t.Helper()
|
||||
tmpl := test.CreateTmpl(t)
|
||||
apiURL, err := url.Parse(mock.srv.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
notifier, err := New(&config.TelegramConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
APIUrl: &config.URL{URL: apiURL},
|
||||
BotToken: "123456:tok",
|
||||
ChatID: -1001234567890,
|
||||
ParseMode: "HTML",
|
||||
Message: alertmanagertypes.DefaultTelegramMessageTemplate,
|
||||
}, tmpl, logger, alertmanagertemplate.New(tmpl, logger))
|
||||
require.NoError(t, err)
|
||||
|
||||
return notifier
|
||||
}
|
||||
|
||||
func newAlert(host string, annotations model.LabelSet) *types.Alert {
|
||||
merged := model.LabelSet{"summary": "p99 is 612ms"}
|
||||
for name, value := range annotations {
|
||||
merged[name] = value
|
||||
}
|
||||
|
||||
return &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "checkout-latency-high", "severity": "warning", "host": model.LabelValue(host)},
|
||||
Annotations: merged,
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
GeneratorURL: "http://am/alerts/overview?ruleId=r1",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyBuildsTheMessage(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
alerts []*types.Alert
|
||||
wantExact string
|
||||
wantContains []string
|
||||
wantNotContains []string
|
||||
}{
|
||||
{
|
||||
name: "NoRuleTemplates_UsesChannelTemplate",
|
||||
alerts: []*types.Alert{newAlert("web-3", nil)},
|
||||
wantContains: []string{
|
||||
"<b>[FIRING:1] checkout-latency-high</b>",
|
||||
"<b>Summary:</b> p99 is 612ms",
|
||||
`<a href="http://am/alerts/overview?ruleId=r1">View in SigNoz</a>`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TitleAndBody_ReplaceTheChannelTemplate",
|
||||
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
|
||||
ruletypes.AnnotationTitleTemplate: "Incident on $labels.host",
|
||||
ruletypes.AnnotationBodyTemplate: "$alert.status for $rule.name",
|
||||
})},
|
||||
wantExact: "<b>Incident on web-3</b>\n\nfiring for checkout-latency-high",
|
||||
wantNotContains: []string{"View in SigNoz"},
|
||||
},
|
||||
{
|
||||
// $-refs are template variables, so the braced form is the real
|
||||
// syntax and the only one that reaches conditionals. A bare ref in
|
||||
// text is rewritten into an action before expansion.
|
||||
name: "BracedAndBareRefs_ExpandAlike",
|
||||
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
|
||||
ruletypes.AnnotationBodyTemplate: "{{ $rule.name }}|$rule.name|{{ if $alert.is_firing }}up{{ end }}",
|
||||
})},
|
||||
wantExact: "checkout-latency-high|checkout-latency-high|up",
|
||||
},
|
||||
{
|
||||
name: "BodyOnly_OmitsTheTitleLine",
|
||||
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
|
||||
ruletypes.AnnotationBodyTemplate: "$alert.status for $rule.name",
|
||||
})},
|
||||
wantExact: "firing for checkout-latency-high",
|
||||
},
|
||||
{
|
||||
name: "TitleOnly_KeepsTheChannelBody",
|
||||
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
|
||||
ruletypes.AnnotationTitleTemplate: "Incident on $labels.host",
|
||||
})},
|
||||
wantContains: []string{"<b>Incident on web-3</b>", "<b>Summary:</b> p99 is 612ms"},
|
||||
},
|
||||
{
|
||||
// Rule templates are authored in markdown for every channel, so
|
||||
// Telegram converts rather than escaping the markers away.
|
||||
name: "RuleTemplateMarkdown_RenderedToTelegramHTML",
|
||||
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
|
||||
ruletypes.AnnotationBodyTemplate: "**{{ $rule.name }}** on `{{ $labels.host }}` — [open](https://signoz.io/d?a=1&b=2)",
|
||||
})},
|
||||
wantExact: `<b>checkout-latency-high</b> on <code>web-3</code> — <a href="https://signoz.io/d?a=1&b=2">open</a>`,
|
||||
},
|
||||
{
|
||||
// The templater renders plain text, so anything interpolated from
|
||||
// an alert has to be escaped before a parse_mode HTML send.
|
||||
name: "RuleTemplateOutput_EscapedForHTML",
|
||||
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
|
||||
"summary": "p99 < 2s & rising",
|
||||
ruletypes.AnnotationTitleTemplate: "$annotations.summary",
|
||||
ruletypes.AnnotationBodyTemplate: "$annotations.summary",
|
||||
})},
|
||||
wantExact: "<b>p99 < 2s & rising</b>\n\np99 < 2s & rising",
|
||||
},
|
||||
{
|
||||
name: "GroupedAlerts_BodyRenderedPerAlert",
|
||||
alerts: []*types.Alert{
|
||||
newAlert("web-3", model.LabelSet{ruletypes.AnnotationBodyTemplate: "host $labels.host"}),
|
||||
newAlert("web-4", model.LabelSet{ruletypes.AnnotationBodyTemplate: "host $labels.host"}),
|
||||
},
|
||||
wantExact: "host web-3\n\nhost web-4",
|
||||
},
|
||||
{
|
||||
// Picking one rule's template for a mixed group would be arbitrary,
|
||||
// so the channel template is used instead.
|
||||
name: "GroupedAlerts_DisagreeingTemplates_FallBackToChannel",
|
||||
alerts: []*types.Alert{
|
||||
newAlert("web-3", model.LabelSet{ruletypes.AnnotationBodyTemplate: "host $labels.host"}),
|
||||
newAlert("web-4", model.LabelSet{ruletypes.AnnotationBodyTemplate: "something else"}),
|
||||
},
|
||||
wantContains: []string{"<b>Summary:</b> p99 is 612ms"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
mock := newMockBotAPI(t)
|
||||
|
||||
retry, err := newNotifier(t, mock).Notify(notify.WithGroupKey(context.Background(), "1"), testCase.alerts...)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, retry)
|
||||
|
||||
text := mock.only(t)["text"]
|
||||
if testCase.wantExact != "" {
|
||||
assert.Equal(t, testCase.wantExact, text)
|
||||
}
|
||||
for _, want := range testCase.wantContains {
|
||||
assert.Contains(t, text, want)
|
||||
}
|
||||
for _, notWant := range testCase.wantNotContains {
|
||||
assert.NotContains(t, text, notWant)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A v1-written receiver can carry a mode the v2 spec no longer offers. Nothing
|
||||
// renders to those, so the integration refuses to build rather than sending
|
||||
// HTML-escaped text under a markdown parser.
|
||||
func TestNewRejectsAParseModeOtherThanHTML(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
parseMode string
|
||||
wantErr bool
|
||||
}{
|
||||
{"HTML", "HTML", false},
|
||||
{"MarkdownV2", "MarkdownV2", true},
|
||||
{"Markdown", "Markdown", true},
|
||||
{"Empty", "", true},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
mock := newMockBotAPI(t)
|
||||
apiURL, err := url.Parse(mock.srv.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
_, err = New(&config.TelegramConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
APIUrl: &config.URL{URL: apiURL},
|
||||
BotToken: "123456:tok",
|
||||
ChatID: -1001234567890,
|
||||
ParseMode: testCase.parseMode,
|
||||
Message: alertmanagertypes.DefaultTelegramMessageTemplate,
|
||||
}, tmpl, logger, alertmanagertemplate.New(tmpl, logger))
|
||||
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifySendsTheConfiguredSendOptions(t *testing.T) {
|
||||
mock := newMockBotAPI(t)
|
||||
|
||||
_, err := newNotifier(t, mock).Notify(notify.WithGroupKey(context.Background(), "1"), newAlert("web-3", nil))
|
||||
require.NoError(t, err)
|
||||
|
||||
sent := mock.only(t)
|
||||
assert.Equal(t, "-1001234567890", sent["chat_id"])
|
||||
assert.Equal(t, "HTML", sent["parse_mode"])
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
@@ -253,6 +254,24 @@ func (r *BaseRule) GeneratorURL() string {
|
||||
return r.ExternalURL("alerts/overview", params)
|
||||
}
|
||||
|
||||
// ExpandAnnotations runs expand over the rule's annotations, leaving private
|
||||
// ones as they are. Those carry templates the notifier expands against its own
|
||||
// data model, and the ruler's expander rewrites every $-ref into a label
|
||||
// lookup, which blanks the ones that are not labels.
|
||||
func (r *BaseRule) ExpandAnnotations(expand func(string) string) ruletypes.Labels {
|
||||
annotations := make(ruletypes.Labels, 0, len(r.annotations.Map()))
|
||||
for name, value := range r.annotations.Map() {
|
||||
if alertmanagertypes.IsPrivateAnnotation(name) {
|
||||
annotations = append(annotations, ruletypes.Label{Name: name, Value: value})
|
||||
continue
|
||||
}
|
||||
|
||||
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
|
||||
}
|
||||
|
||||
return annotations
|
||||
}
|
||||
|
||||
func (r *BaseRule) ExternalURL(path string, params url.Values) string {
|
||||
u := r.externalURL.JoinPath(path)
|
||||
if len(params) > 0 {
|
||||
|
||||
@@ -833,3 +833,55 @@ func labelsKey(lbls []*qbtypes.Label) string {
|
||||
}
|
||||
return temp.Labels().String()
|
||||
}
|
||||
|
||||
func TestExpandAnnotations(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
annotations map[string]string
|
||||
want map[string]string
|
||||
}{
|
||||
{
|
||||
name: "PublicAnnotation_Expanded",
|
||||
annotations: map[string]string{"summary": "observed {{$value}}"},
|
||||
want: map[string]string{"summary": "observed 0.35"},
|
||||
},
|
||||
{
|
||||
// The ruler's expander rewrites every $-ref into a label lookup, so
|
||||
// expanding these would blank the paths the notifier resolves.
|
||||
name: "PrivateAnnotation_LeftAlone",
|
||||
annotations: map[string]string{ruletypes.AnnotationTitleTemplate: "{{ $rule.name }} on {{ $labels.host }}"},
|
||||
want: map[string]string{ruletypes.AnnotationTitleTemplate: "{{ $rule.name }} on {{ $labels.host }}"},
|
||||
},
|
||||
{
|
||||
name: "MixedAnnotations_OnlyPublicExpanded",
|
||||
annotations: map[string]string{
|
||||
"summary": "observed {{$value}}",
|
||||
ruletypes.AnnotationBodyTemplate: "$alert.status for $rule.name",
|
||||
},
|
||||
want: map[string]string{
|
||||
"summary": "observed 0.35",
|
||||
ruletypes.AnnotationBodyTemplate: "$alert.status for $rule.name",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
rule := &BaseRule{annotations: ruletypes.FromMap(testCase.annotations)}
|
||||
expand := func(text string) string {
|
||||
tmpl := ruletypes.NewTemplateExpander(
|
||||
context.Background(),
|
||||
"{{$labels := .Labels}}{{$value := .Value}}{{$threshold := .Threshold}}"+text,
|
||||
"__alert_test",
|
||||
ruletypes.AlertTemplateData(map[string]string{}, "0.35", "0.32"),
|
||||
nil,
|
||||
)
|
||||
out, err := tmpl.Expand()
|
||||
require.NoError(t, err)
|
||||
return out
|
||||
}
|
||||
|
||||
assert.Equal(t, testCase.want, rule.ExpandAnnotations(expand).Map())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,10 +211,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
|
||||
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
|
||||
|
||||
annotations := make(ruletypes.Labels, 0, len(r.annotations.Map()))
|
||||
for name, value := range r.annotations.Map() {
|
||||
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
|
||||
}
|
||||
annotations := r.ExpandAnnotations(expand)
|
||||
if result.IsMissing {
|
||||
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
|
||||
lb.Set(ruletypes.NoDataLabel, "true")
|
||||
|
||||
@@ -295,10 +295,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
|
||||
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
|
||||
|
||||
annotations := make(ruletypes.Labels, 0, len(r.annotations.Map()))
|
||||
for name, value := range r.annotations.Map() {
|
||||
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
|
||||
}
|
||||
annotations := r.ExpandAnnotations(expand)
|
||||
if smpl.IsMissing {
|
||||
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
|
||||
lb.Set(ruletypes.NoDataLabel, "true")
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/blockkit"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/mrkdwn"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/plaintext"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/telegram"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
)
|
||||
@@ -38,6 +39,11 @@ var (
|
||||
return goldmark.New(goldmark.WithExtensions(plaintext.Extender))
|
||||
},
|
||||
}
|
||||
telegramPool = sync.Pool{
|
||||
New: func() any {
|
||||
return goldmark.New(goldmark.WithExtensions(telegram.Extender))
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// RenderHTML converts markdown to HTML.
|
||||
@@ -67,6 +73,14 @@ func RenderPlainText(markdown string) (string, error) {
|
||||
return render(md, markdown, "plain text")
|
||||
}
|
||||
|
||||
// RenderTelegramHTML converts markdown to the HTML subset Telegram's
|
||||
// parse_mode HTML accepts, flattening constructs it has no tag for.
|
||||
func RenderTelegramHTML(markdown string) (string, error) {
|
||||
md := telegramPool.Get().(goldmark.Markdown)
|
||||
defer telegramPool.Put(md)
|
||||
return render(md, markdown, "Telegram HTML")
|
||||
}
|
||||
|
||||
func render(md goldmark.Markdown, markdown string, format string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(markdown), &buf); err != nil {
|
||||
|
||||
360
pkg/templating/markdownrenderer/telegram/telegram.go
Normal file
360
pkg/templating/markdownrenderer/telegram/telegram.go
Normal file
@@ -0,0 +1,360 @@
|
||||
// Package telegram provides a goldmark node renderer that emits the HTML
|
||||
// subset Telegram's parse_mode HTML accepts. Telegram rejects the whole
|
||||
// message when it meets a tag outside that set, so constructs with no
|
||||
// equivalent (headings, lists, tables, images) are flattened to text rather
|
||||
// than rendered with their usual tags.
|
||||
// https://core.telegram.org/bots/api#html-style
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
extensionast "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// textEscaper covers the three characters Telegram requires as entities in
|
||||
// message text. attrEscaper additionally covers the quote that would end an
|
||||
// href value early.
|
||||
var (
|
||||
textEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">")
|
||||
attrEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
||||
)
|
||||
|
||||
// Extender registers the Telegram node renderer plus the GFM extensions it
|
||||
// handles (tables, strikethrough).
|
||||
var Extender goldmark.Extender = &extender{}
|
||||
|
||||
type extender struct{}
|
||||
|
||||
func (e *extender) Extend(m goldmark.Markdown) {
|
||||
extension.Table.Extend(m)
|
||||
extension.Strikethrough.Extend(m)
|
||||
m.Renderer().AddOptions(
|
||||
renderer.WithNodeRenderers(util.Prioritized(newRenderer(), 1)),
|
||||
)
|
||||
}
|
||||
|
||||
// nodeRenderer holds per-document nesting prefixes, so it is not safe for
|
||||
// concurrent Convert calls; callers pool one instance per goroutine.
|
||||
type nodeRenderer struct {
|
||||
prefixes []string
|
||||
}
|
||||
|
||||
func newRenderer() renderer.NodeRenderer {
|
||||
return &nodeRenderer{}
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
// Blocks
|
||||
reg.Register(ast.KindDocument, r.renderDocument)
|
||||
reg.Register(ast.KindHeading, r.renderHeading)
|
||||
reg.Register(ast.KindBlockquote, r.renderBlockquote)
|
||||
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindFencedCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindHTMLBlock, r.renderRawHTML)
|
||||
reg.Register(ast.KindList, r.renderList)
|
||||
reg.Register(ast.KindListItem, r.renderListItem)
|
||||
reg.Register(ast.KindParagraph, r.renderBlock)
|
||||
reg.Register(ast.KindTextBlock, r.renderTextBlock)
|
||||
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
|
||||
|
||||
// Inlines
|
||||
reg.Register(ast.KindAutoLink, r.renderAutoLink)
|
||||
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
|
||||
reg.Register(ast.KindEmphasis, r.renderEmphasis)
|
||||
reg.Register(ast.KindImage, r.renderImage)
|
||||
reg.Register(ast.KindLink, r.renderLink)
|
||||
reg.Register(ast.KindText, r.renderText)
|
||||
reg.Register(ast.KindString, r.renderString)
|
||||
reg.Register(ast.KindRawHTML, r.renderRawHTML)
|
||||
|
||||
// Extensions
|
||||
reg.Register(extensionast.KindStrikethrough, r.renderStrikethrough)
|
||||
reg.Register(extensionast.KindTable, r.renderTable)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) writePrefix(w util.BufWriter) {
|
||||
for _, p := range r.prefixes {
|
||||
_, _ = w.WriteString(p)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) writeLineSeparator(w util.BufWriter) {
|
||||
_ = w.WriteByte('\n')
|
||||
r.writePrefix(w)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) writeBlockSeparator(w util.BufWriter) {
|
||||
r.writeLineSeparator(w)
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) separateFromPrevious(w util.BufWriter, n ast.Node) {
|
||||
if n.PreviousSibling() != nil {
|
||||
r.writeBlockSeparator(w)
|
||||
}
|
||||
}
|
||||
|
||||
// wrap emits an opening tag on the way in and its closing tag on the way out.
|
||||
func wrap(w util.BufWriter, tag string, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
_, _ = w.WriteString("<" + tag + ">")
|
||||
} else {
|
||||
_, _ = w.WriteString("</" + tag + ">")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.prefixes = r.prefixes[:0]
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, node)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// renderHeading emits bold text: Telegram has no heading tag.
|
||||
func (r *nodeRenderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, node)
|
||||
}
|
||||
return wrap(w, "b", entering)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, node)
|
||||
}
|
||||
return wrap(w, "blockquote", entering)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
tag := "i"
|
||||
if node.(*ast.Emphasis).Level >= 2 {
|
||||
tag = "b"
|
||||
}
|
||||
return wrap(w, tag, entering)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
return wrap(w, "s", entering)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
r.separateFromPrevious(w, n)
|
||||
|
||||
var body bytes.Buffer
|
||||
for i := 0; i < n.Lines().Len(); i++ {
|
||||
line := n.Lines().At(i)
|
||||
body.Write(line.Value(source))
|
||||
}
|
||||
|
||||
_, _ = w.WriteString("<pre>")
|
||||
_, _ = w.WriteString(textEscaper.Replace(strings.TrimRight(body.String(), "\n")))
|
||||
_, _ = w.WriteString("</pre>")
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
value := c.(*ast.Text).Segment.Value(source)
|
||||
if bytes.HasSuffix(value, []byte("\n")) {
|
||||
body.Write(value[:len(value)-1])
|
||||
body.WriteByte(' ')
|
||||
} else {
|
||||
body.Write(value)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = w.WriteString("<code>" + textEscaper.Replace(body.String()) + "</code>")
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// renderList and renderListItem mirror the plain-text renderer: Telegram has no
|
||||
// list tag, so items become prefixed lines.
|
||||
func (r *nodeRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering && node.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
if node.Parent() == nil || node.Parent().Kind() != ast.KindListItem {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderListItem(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
r.prefixes = r.prefixes[:len(r.prefixes)-1]
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
if n.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
|
||||
parent := n.Parent().(*ast.List)
|
||||
if parent.IsOrdered() {
|
||||
index := parent.Start
|
||||
for c := parent.FirstChild(); c != nil && c != n; c = c.NextSibling() {
|
||||
index++
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "%d. ", index)
|
||||
} else {
|
||||
_, _ = w.WriteString("• ")
|
||||
}
|
||||
r.prefixes = append(r.prefixes, " ")
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderTextBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering && n.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderThematicBreak(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
_, _ = w.WriteString("———")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
n := node.(*ast.AutoLink)
|
||||
url := string(n.URL(source))
|
||||
if n.AutoLinkType == ast.AutoLinkEmail && !strings.HasPrefix(strings.ToLower(url), "mailto:") {
|
||||
url = "mailto:" + url
|
||||
}
|
||||
_, _ = w.WriteString(`<a href="` + attrEscaper.Replace(url) + `">` + textEscaper.Replace(url) + "</a>")
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Link)
|
||||
if entering {
|
||||
_, _ = w.WriteString(`<a href="` + attrEscaper.Replace(string(n.Destination)) + `">`)
|
||||
} else {
|
||||
_, _ = w.WriteString("</a>")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// renderImage flattens to "alt (url)": a Telegram text message cannot carry an
|
||||
// inline image, and <img> is not on the allowlist.
|
||||
func (r *nodeRenderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
n := node.(*ast.Image)
|
||||
if len(n.Destination) > 0 {
|
||||
_, _ = fmt.Fprintf(w, " (%s)", textEscaper.Replace(string(n.Destination)))
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
n := node.(*ast.Text)
|
||||
_, _ = w.WriteString(textEscaper.Replace(string(n.Segment.Value(source))))
|
||||
if n.HardLineBreak() || n.SoftLineBreak() {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
_, _ = w.WriteString(textEscaper.Replace(string(node.(*ast.String).Value)))
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// renderRawHTML drops markup the author embedded by hand. Passing it through
|
||||
// would hand Telegram tags outside the allowlist and fail the whole send.
|
||||
func (r *nodeRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// renderTable flattens to "cell | cell" lines: Telegram has no table tag.
|
||||
func (r *nodeRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
r.separateFromPrevious(w, node)
|
||||
|
||||
first := true
|
||||
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if c.Kind() != extensionast.KindTableHeader && c.Kind() != extensionast.KindTableRow {
|
||||
continue
|
||||
}
|
||||
if !first {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
first = false
|
||||
|
||||
cellFirst := true
|
||||
for cc := c.FirstChild(); cc != nil; cc = cc.NextSibling() {
|
||||
if cc.Kind() != extensionast.KindTableCell {
|
||||
continue
|
||||
}
|
||||
if !cellFirst {
|
||||
_, _ = w.WriteString(" | ")
|
||||
}
|
||||
cellFirst = false
|
||||
_, _ = w.WriteString(textEscaper.Replace(extractPlainText(cc, source)))
|
||||
}
|
||||
}
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
func extractPlainText(n ast.Node, source []byte) string {
|
||||
var buf bytes.Buffer
|
||||
_ = ast.Walk(n, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
switch t := node.(type) {
|
||||
case *ast.Text:
|
||||
buf.Write(t.Segment.Value(source))
|
||||
case *ast.String:
|
||||
buf.Write(t.Value)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
return strings.TrimSpace(buf.String())
|
||||
}
|
||||
75
pkg/templating/markdownrenderer/telegram/telegram_test.go
Normal file
75
pkg/templating/markdownrenderer/telegram/telegram_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
func render(t *testing.T, markdown string) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, goldmark.New(goldmark.WithExtensions(Extender)).Convert([]byte(markdown), &buf))
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestRenderEmitsTelegramHTML(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
markdown string
|
||||
want string
|
||||
}{
|
||||
{"Bold", "**critical**", "<b>critical</b>"},
|
||||
{"Italic", "*degraded*", "<i>degraded</i>"},
|
||||
{"Strikethrough", "~~resolved~~", "<s>resolved</s>"},
|
||||
{"CodeSpan", "use `kubectl get pods`", "use <code>kubectl get pods</code>"},
|
||||
{"Link", "[dashboard](https://signoz.io/d?a=1&b=2)", `<a href="https://signoz.io/d?a=1&b=2">dashboard</a>`},
|
||||
{"AutoLink", "<https://signoz.io>", `<a href="https://signoz.io">https://signoz.io</a>`},
|
||||
{"Blockquote", "> escalate now", "<blockquote>escalate now</blockquote>"},
|
||||
// Telegram has no heading, list, table or image tag, so these flatten.
|
||||
{"Heading_BecomesBold", "## Incident", "<b>Incident</b>"},
|
||||
{"BulletList_BecomesLines", "- one\n- two", "• one\n• two"},
|
||||
{"OrderedList_BecomesLines", "1. one\n2. two", "1. one\n2. two"},
|
||||
{"Table_BecomesPipedLines", "| a | b |\n|---|---|\n| 1 | 2 |", "a | b\n1 | 2"},
|
||||
{"Image_BecomesTextAndURL", "", "graph (https://signoz.io/g.png)"},
|
||||
{"ThematicBreak", "a\n\n---\n\nb", "a\n\n———\n\nb"},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.want, render(t, testCase.markdown))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram drops the whole message on an unparseable entity, so anything that
|
||||
// could be read as markup has to leave as an entity.
|
||||
func TestRenderEscapesEverythingElse(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
markdown string
|
||||
want string
|
||||
}{
|
||||
{"BareSpecialChars", "p99 < 2s & rising", "p99 < 2s & rising"},
|
||||
{"InsideBold", "**a < b**", "<b>a < b</b>"},
|
||||
{"InsideCodeSpan", "`if a < b && c`", "<code>if a < b && c</code>"},
|
||||
{"InsideCodeBlock", "```\na < b\n```", "<pre>a < b</pre>"},
|
||||
{"InsideLinkText", "[a < b](https://signoz.io)", `<a href="https://signoz.io">a < b</a>`},
|
||||
{"QuoteInHref", `[x](https://signoz.io/?q=")`, `<a href="https://signoz.io/?q="">x</a>`},
|
||||
// Hand-written markup would hand Telegram tags outside the allowlist.
|
||||
// The tags go; the text between them stays.
|
||||
{"RawHTMLTagsDropped_TextKept", "before <marquee>x</marquee> after", "before x after"},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
rendered := render(t, testCase.markdown)
|
||||
assert.Equal(t, testCase.want, rendered)
|
||||
assert.NotContains(t, rendered, "&lt;", "double-escaped")
|
||||
})
|
||||
}
|
||||
}
|
||||
133
pkg/types/alertmanagertypes/channel_telegram.go
Normal file
133
pkg/types/alertmanagertypes/channel_telegram.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
// Telegram renders the message with parse_mode HTML, which only accepts a small
|
||||
// tag set and requires every stray <, > and & to be escaped. Each interpolated
|
||||
// value is piped through html so the template renders identically under both
|
||||
// template engines; an unescaped alert annotation would otherwise fail the
|
||||
// whole send with "can't parse entities".
|
||||
// https://core.telegram.org/bots/api#html-style
|
||||
const DefaultTelegramMessageTemplate = `<b>[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname | html }}</b>
|
||||
|
||||
{{ range .Alerts -}}
|
||||
<b>Alert:</b> {{ .Labels.alertname | html }}{{ if .Labels.severity }} ({{ .Labels.severity | html }}){{ end }}
|
||||
{{ if .Annotations.summary }}<b>Summary:</b> {{ .Annotations.summary | html }}
|
||||
{{ end }}{{ if .Annotations.description }}<b>Description:</b> {{ .Annotations.description | html }}
|
||||
{{ end }}{{ if .GeneratorURL }}<a href="{{ .GeneratorURL | html }}">View in SigNoz</a>
|
||||
{{ end }}{{ if .Annotations.related_logs }}<a href="{{ .Annotations.related_logs | html }}">View related logs</a>
|
||||
{{ end }}{{ if .Annotations.related_traces }}<a href="{{ .Annotations.related_traces | html }}">View related traces</a>
|
||||
{{ end }}
|
||||
{{ end }}`
|
||||
|
||||
// telegramParseMode is the only mode SigNoz renders for. Upstream also accepts
|
||||
// Markdown, MarkdownV2 and the empty string, but those carry per-mode escaping
|
||||
// rules that nothing renders to, so the spec does not offer them.
|
||||
const telegramParseMode = "HTML"
|
||||
|
||||
// ChannelTelegramConfig configures delivery to a Telegram chat through a bot.
|
||||
// ChatID is the numeric chat the bot posts to, negative for groups and channels;
|
||||
// Telegram's @username form is not accepted upstream.
|
||||
type ChannelTelegramConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
BotToken string `json:"botToken" required:"true" format:"password"`
|
||||
ChatID int64 `json:"chatId" required:"true"`
|
||||
// APIURL points at a self-hosted Bot API server. Left empty, the global
|
||||
// telegram_api_url applies.
|
||||
APIURL string `json:"apiUrl"`
|
||||
// MessageThreadID targets a topic inside a forum group.
|
||||
MessageThreadID int `json:"messageThreadId,omitempty"`
|
||||
Message valuer.UnsetOrNonEmptyString `json:"message,omitzero"`
|
||||
DisableNotifications bool `json:"disableNotifications,omitempty"`
|
||||
}
|
||||
|
||||
func (c *ChannelTelegramConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelTelegramConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultTelegramConfig.VSendResolved)
|
||||
c.Message.SetIfUnset(DefaultTelegramMessageTemplate)
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
// Validate repeats the checks upstream's TelegramConfig.UnmarshalYAML makes, so
|
||||
// a bad channel fails on the request with a field name rather than as a yaml
|
||||
// error surfacing out of the config resolve.
|
||||
func (c ChannelTelegramConfig) Validate() error {
|
||||
if c.BotToken == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.botToken is required for a telegram channel")
|
||||
}
|
||||
|
||||
if c.ChatID == 0 {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.chatId is required for a telegram channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelTelegramConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
var apiURL *config.URL
|
||||
if c.APIURL != "" {
|
||||
parsed, err := parseUpstreamURL(c.APIURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiURL = parsed
|
||||
}
|
||||
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
TelegramConfigs: []*config.TelegramConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultTelegramConfig.VSendResolved)},
|
||||
APIUrl: apiURL,
|
||||
BotToken: config.Secret(c.BotToken),
|
||||
ChatID: c.ChatID,
|
||||
MessageThreadID: c.MessageThreadID,
|
||||
Message: c.Message.StringValue(),
|
||||
ParseMode: telegramParseMode,
|
||||
DisableNotifications: c.DisableNotifications,
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelTelegramConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
telegram := receiver.TelegramConfigs[0]
|
||||
sendResolved := telegram.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, telegram.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Both read a path on the server's filesystem, so they are refused rather
|
||||
// than silently dropped on the way back out.
|
||||
if telegram.BotTokenFile != "" {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets bot_token_file, which is not supported", name)
|
||||
}
|
||||
|
||||
if telegram.ChatIDFile != "" {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets chat_id_file, which is not supported", name)
|
||||
}
|
||||
|
||||
// Dropping a non-HTML parse_mode on the way out would silently rewrite the
|
||||
// channel on the next write, so it is refused instead.
|
||||
if telegram.ParseMode != telegramParseMode {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets parse_mode %q, only %s is supported", name, telegram.ParseMode, telegramParseMode)
|
||||
}
|
||||
|
||||
return &ChannelTelegramConfig{
|
||||
SendResolved: &sendResolved,
|
||||
BotToken: string(telegram.BotToken),
|
||||
ChatID: telegram.ChatID,
|
||||
APIURL: formatUpstreamURL(telegram.APIUrl),
|
||||
MessageThreadID: telegram.MessageThreadID,
|
||||
Message: valuer.UnsetIfEmpty(telegram.Message),
|
||||
DisableNotifications: telegram.DisableNotifications,
|
||||
}, nil
|
||||
}
|
||||
129
pkg/types/alertmanagertypes/channel_telegram_test.go
Normal file
129
pkg/types/alertmanagertypes/channel_telegram_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestChannelTelegramConfigDefaults(t *testing.T) {
|
||||
var spec ChannelTelegramConfig
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"botToken":"123456:tok","chatId":-1001234567890}`), &spec))
|
||||
|
||||
require.NotNil(t, spec.SendResolved)
|
||||
assert.True(t, *spec.SendResolved, "upstream defaults telegram send_resolved on")
|
||||
assert.Equal(t, DefaultTelegramMessageTemplate, spec.Message.StringValue())
|
||||
assert.Empty(t, spec.APIURL, "the global telegram_api_url applies when none is given")
|
||||
}
|
||||
|
||||
func TestChannelTelegramConfigValidation(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
spec string
|
||||
}{
|
||||
{"BotToken_Missing", `{"chatId":1}`},
|
||||
{"ChatID_Missing", `{"botToken":"123456:tok"}`},
|
||||
{"ChatID_Username_Rejected", `{"botToken":"123456:tok","chatId":"@signoz"}`},
|
||||
{"ParseMode_NotAField", `{"botToken":"123456:tok","chatId":1,"parseMode":"HTML"}`},
|
||||
{"BotTokenFile_NotAField", `{"botToken":"123456:tok","chatId":1,"botTokenFile":"/etc/token"}`},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
var spec ChannelTelegramConfig
|
||||
assert.Error(t, json.Unmarshal([]byte(testCase.spec), &spec))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Settings the spec cannot represent would be dropped on read and silently
|
||||
// rewrite the channel on the next write, so the read fails instead.
|
||||
func TestDeriveChannelConfigRejectsUnsupportedTelegramSettings(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
data string
|
||||
}{
|
||||
{"BotTokenFile", `{"name":"tg","telegram_configs":[{"chat_id":1,"token_file":"/etc/token"}]}`},
|
||||
{"ChatIDFile", `{"name":"tg","telegram_configs":[{"token":"123456:tok","chat_file":"/etc/chat"}]}`},
|
||||
{"ParseMode_NotHTML", `{"name":"tg","telegram_configs":[{"token":"123456:tok","chat":1,"parse_mode":"MarkdownV2"}]}`},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
channel := Channel{DisplayName: "tg", Data: testCase.data}
|
||||
_, err := channel.deriveChannelConfig()
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultTelegramMessageTemplateEscapesAlertContent(t *testing.T) {
|
||||
tmpl, err := FromGlobs([]string{})
|
||||
require.NoError(t, err)
|
||||
tmpl.ExternalURL = &url.URL{Scheme: "http", Host: "localhost:8080"}
|
||||
|
||||
alert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "p99 < 2s & rising", "severity": "critical"},
|
||||
Annotations: model.LabelSet{"summary": "latency > 2s", "related_logs": "http://localhost:8080/logs?a=1&b=2"},
|
||||
GeneratorURL: "http://localhost:8080/alerts?a=1&b=2",
|
||||
},
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
data := tmpl.Data("__receiver", model.LabelSet{}, alert)
|
||||
|
||||
// The channel template is rendered with html/template under parse_mode HTML
|
||||
// and with text/template otherwise, so it has to escape identically under
|
||||
// both. html/template recognises the html pipeline and adds no second pass.
|
||||
renderers := map[string]func(string, any) (string, error){
|
||||
"Text": tmpl.ExecuteTextString,
|
||||
"HTML": tmpl.ExecuteHTMLString,
|
||||
}
|
||||
|
||||
for name, render := range renderers {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rendered, err := render(DefaultTelegramMessageTemplate, data)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, rendered, "<b>[FIRING:1] p99 < 2s & rising</b>")
|
||||
assert.Contains(t, rendered, "<b>Summary:</b> latency > 2s")
|
||||
assert.Contains(t, rendered, `<a href="http://localhost:8080/alerts?a=1&b=2">View in SigNoz</a>`)
|
||||
assert.Contains(t, rendered, `<a href="http://localhost:8080/logs?a=1&b=2">View related logs</a>`)
|
||||
assert.NotContains(t, rendered, "&lt;", "double-escaped")
|
||||
assert.NotContains(t, rendered, "View related traces")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// APIUrl and HTTPConfig are filled from the global rather than the spec, and
|
||||
// the notifier refuses to start without them. A resolve that leaves either nil
|
||||
// fails the channel at delivery.
|
||||
func TestTelegramResolvesTheGlobalAPIURLAndHTTPConfig(t *testing.T) {
|
||||
cfg, err := NewDefaultConfig(GlobalConfig{}, RouteConfig{GroupInterval: 1 * time.Minute, GroupWait: 1 * time.Minute, RepeatInterval: 1 * time.Minute}, "org-1")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, cfg.SetGlobalConfig(GlobalConfig{}))
|
||||
|
||||
spec := ChannelTelegramConfig{BotToken: "123456:tok", ChatID: -1001234567890}
|
||||
receiver, err := spec.toUndefaultedReceiver("tg")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, cfg.CreateReceiver(receiver))
|
||||
|
||||
resolved, err := cfg.Resolved()
|
||||
require.NoError(t, err)
|
||||
|
||||
resolvedReceiver, err := resolved.GetReceiver("tg")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resolvedReceiver.TelegramConfigs, 1)
|
||||
|
||||
telegram := resolvedReceiver.TelegramConfigs[0]
|
||||
require.NotNil(t, telegram.APIUrl)
|
||||
assert.Equal(t, "https://api.telegram.org", telegram.APIUrl.String())
|
||||
assert.NotNil(t, telegram.HTTPConfig)
|
||||
}
|
||||
@@ -96,6 +96,7 @@ func (ChannelConfig) JSONSchemaOneOf() []any {
|
||||
ChannelConfigVariant[ChannelJiraConfig]{Kind: ChannelKindJira.StringValue()},
|
||||
ChannelConfigVariant[ChannelJSMOpsConfig]{Kind: ChannelKindJSMOps.StringValue()},
|
||||
ChannelConfigVariant[ChannelIncidentIOConfig]{Kind: ChannelKindIncidentIO.StringValue()},
|
||||
ChannelConfigVariant[ChannelTelegramConfig]{Kind: ChannelKindTelegram.StringValue()},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +114,7 @@ func (ChannelConfig) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
ChannelKindJira.StringValue(): channelVariantRef("ChannelJiraConfig"),
|
||||
ChannelKindJSMOps.StringValue(): channelVariantRef("ChannelJSMOpsConfig"),
|
||||
ChannelKindIncidentIO.StringValue(): channelVariantRef("ChannelIncidentIOConfig"),
|
||||
ChannelKindTelegram.StringValue(): channelVariantRef("ChannelTelegramConfig"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -258,6 +258,28 @@ func TestDeriveChannelConfigRoundTripsEveryFieldOfEveryKind(t *testing.T) {
|
||||
Metadata: map[string]string{"team": "platform"},
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "telegram",
|
||||
kind: ChannelKindTelegram,
|
||||
spec: &ChannelTelegramConfig{
|
||||
SendResolved: &sendResolved,
|
||||
BotToken: "123456:bot-token",
|
||||
ChatID: -1001234567890,
|
||||
APIURL: "https://bot-api.example.com",
|
||||
MessageThreadID: 42,
|
||||
Message: valuer.MustNewUnsetOrNonEmptyString("telegram message"),
|
||||
DisableNotifications: true,
|
||||
},
|
||||
expectedRoundTrip: &ChannelTelegramConfig{
|
||||
SendResolved: &sendResolved,
|
||||
BotToken: "123456:bot-token",
|
||||
ChatID: -1001234567890,
|
||||
APIURL: "https://bot-api.example.com",
|
||||
MessageThreadID: 42,
|
||||
Message: valuer.MustNewUnsetOrNonEmptyString("telegram message"),
|
||||
DisableNotifications: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
@@ -396,8 +418,8 @@ func TestDeriveChannelConfigRejectsUnrepresentableChannels(t *testing.T) {
|
||||
{
|
||||
description: "notifier kind outside the supported set",
|
||||
channel: Channel{
|
||||
DisplayName: "tg",
|
||||
Data: `{"name":"tg","telegram_configs":[{"chat_id":1}]}`,
|
||||
DisplayName: "dc",
|
||||
Data: `{"name":"dc","discord_configs":[{"webhook_url":"https://discord.com/api/webhooks/1/x"}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ var (
|
||||
ChannelKindJira = ChannelKind{valuer.NewString("jira")}
|
||||
ChannelKindJSMOps = ChannelKind{valuer.NewString("jsmops")}
|
||||
ChannelKindIncidentIO = ChannelKind{valuer.NewString("incidentio")}
|
||||
ChannelKindTelegram = ChannelKind{valuer.NewString("telegram")}
|
||||
)
|
||||
|
||||
func (ChannelKind) Enum() []any {
|
||||
|
||||
@@ -74,6 +74,12 @@ var channelKinds = []channelKindEntry{
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.IncidentIOConfigs) },
|
||||
extractSpec: newChannelIncidentIOConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindTelegram,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelTelegramConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.TelegramConfigs) },
|
||||
extractSpec: newChannelTelegramConfigFromReceiver,
|
||||
},
|
||||
}
|
||||
|
||||
func buildEmptyChannelSpecForKind(kind ChannelKind) (ChannelSpec, bool) {
|
||||
|
||||
@@ -43,13 +43,13 @@ func TestListChannelsParamsValidateRejectsALongQuery(t *testing.T) {
|
||||
// possible until they are migrated. One of them must not fail the whole list.
|
||||
func TestChannelToListedChannelLeavesUnmodelledKindsEmpty(t *testing.T) {
|
||||
channel := Channel{
|
||||
DisplayName: "tg",
|
||||
Name: "tg",
|
||||
Type: "telegram",
|
||||
Data: `{"name":"tg","telegram_configs":[{"chat_id":1}]}`,
|
||||
DisplayName: "dc",
|
||||
Name: "dc",
|
||||
Type: "discord",
|
||||
Data: `{"name":"dc","discord_configs":[{"webhook_url":"https://discord.com/api/webhooks/1/x"}]}`,
|
||||
}
|
||||
|
||||
listed := channel.ToListedNotificationChannel()
|
||||
assert.Equal(t, "tg", listed.Name)
|
||||
assert.Equal(t, "dc", listed.Name)
|
||||
assert.True(t, listed.Kind.IsZero())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user