Compare commits

...

7 Commits

Author SHA1 Message Date
Praneeth Lingam
fd9d86d6c3 Merge branch 'main' of github.com:SigNoz/signoz into feat/telegram-alerts 2026-10-09 13:59:23 +05:30
Aditya Singh
5f8fa52e7c feat(saved-views): views header in the quick filters sidebar (#13011)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description
- real saved views header on top of the sidebar layout.. my view,
selected view and unsaved changes states per figma. create and save as
new share one modal, ✓ updates the view or saves it as new, ✕ discards
back to the saved view and the undo arrow clears it. clear only removes
what the view set, time range stays.
- unsaved changes check compares the queries in their stored v5 form
with empty values dropped like the server does.. comparing after mapping
back to the qb shape made every untouched view read dirty. no amber
flash while switching views or ticking quick filters.
- last used view reopens when the explorer is opened with nothing in the
url, decided on first render.. the default query check the bar uses
never passes, the provider fills `aggregateOperator`.
- logs and traces take the tab from the url, and logs ends live mode
whenever the tab is not the list.
- quick filters header restyled to match the views header.. ghost
buttons, design system tooltips, no funnel, same row height. shared
component so every qf page gets it.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/71

#### Screenshots


https://github.com/user-attachments/assets/982f87ab-185c-4321-8056-310131e6cb98



#### Additional Information
- behind `SAVED_VIEW_ENABLED`, flag off is today's ui.
- manual pass on logs and traces, metrics smoke. restore on logs and the
settings tooltip not rechecked after the last fixes.
- old bar still has its own restore bug, it goes away with the bar.
2026-10-09 07:52:18 +00:00
Praneeth Lingam
4105015444 chore(openapi): regenerate specs for the telegram channel 2026-10-08 00:49:08 +05:30
Praneeth Lingam
e312d010db feat(alertmanager): add a telegram notifier with per-rule templates
Rule templates are authored in markdown, and Telegram's parse_mode HTML
accepts only a small tag subset, rejecting the whole message on anything
outside it. The generic HTML renderer cannot be reused, so a dedicated
one emits that subset and flattens constructs with no equivalent.
2026-10-08 00:48:38 +05:30
Praneeth Lingam
f76ab67a90 refactor(alertmanager): drop parseMode from the telegram channel spec
Rendering targets HTML only. MarkdownV2 needs context-dependent escaping
of eighteen characters, and legacy Markdown cannot express strikethrough,
blockquotes or nested entities at all, so neither is worth supporting.
2026-10-08 00:46:24 +05:30
Praneeth Lingam
3c1a78c85e fix(rules): leave private annotations unexpanded
The ruler's expander rewrites every $-ref into a label lookup, which
blanked the rule template placeholders before any notifier saw them.
2026-10-08 00:38:29 +05:30
Praneeth Lingam
b63ad746b2 feat(telegram): basic workflow 2026-10-07 13:05:03 +05:30
60 changed files with 3965 additions and 279 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,7 +41,7 @@ import { buildCompositeKey, getOptionsFromKeys } from './utils';
interface UseOptionsMenuProps {
storageKey?: string;
dataSource: DataSource;
aggregateOperator: string;
aggregateOperator?: string;
initialOptions?: InitialOptions;
}

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -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: [] };
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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: &notify.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
}

View 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(&params)
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&amp;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 &lt; 2s &amp; rising</b>\n\np99 &lt; 2s &amp; 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"])
}

View File

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

View File

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

View File

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

View File

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

View File

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

View 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("&", "&amp;", "<", "&lt;", ">", "&gt;")
attrEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
)
// 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())
}

View 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&amp;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)", "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 &lt; 2s &amp; rising"},
{"InsideBold", "**a < b**", "<b>a &lt; b</b>"},
{"InsideCodeSpan", "`if a < b && c`", "<code>if a &lt; b &amp;&amp; c</code>"},
{"InsideCodeBlock", "```\na < b\n```", "<pre>a &lt; b</pre>"},
{"InsideLinkText", "[a < b](https://signoz.io)", `<a href="https://signoz.io">a &lt; b</a>`},
{"QuoteInHref", `[x](https://signoz.io/?q=")`, `<a href="https://signoz.io/?q=&quot;">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, "&amp;lt;", "double-escaped")
})
}
}

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

View 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 &lt; 2s &amp; rising</b>")
assert.Contains(t, rendered, "<b>Summary:</b> latency &gt; 2s")
assert.Contains(t, rendered, `<a href="http://localhost:8080/alerts?a=1&amp;b=2">View in SigNoz</a>`)
assert.Contains(t, rendered, `<a href="http://localhost:8080/logs?a=1&amp;b=2">View related logs</a>`)
assert.NotContains(t, rendered, "&amp;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)
}

View File

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

View File

@@ -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"}]}`,
},
},
{

View File

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

View File

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

View File

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