Compare commits

..

20 Commits

Author SHA1 Message Date
Abhi Kumar
438e9c1e9c test(dashboards): cover the dashboard settings drawer 2026-10-27 13:21:26 +05:30
Abhi Kumar
ffce806512 test(dashboards): cover dashboard variables 2026-10-27 13:21:26 +05:30
Abhi Kumar
5a82cb6517 test(dashboards): cover the time range 2026-10-27 13:21:26 +05:30
Abhi Kumar
f020b1e09b test(dashboards): add the time picker, variables bar and settings driver 2026-10-27 13:21:26 +05:30
Abhi Kumar
f64c38b28b test(dashboards): cover sections and dashboard actions 2026-10-27 13:21:26 +05:30
Abhi Kumar
3a95718667 test(dashboards): cover viewing a dashboard and its edge cases 2026-10-27 13:21:26 +05:30
Abhi Kumar
203011a389 test(dashboards): add the dashboard page driver 2026-10-27 13:21:26 +05:30
Abhi Kumar
1e5d90fa65 test(dashboards): check that Extend time range widens the requested window 2026-10-27 13:17:24 +05:30
Abhi Kumar
07d387342b test(dashboards): cover type switching, capability errors and context links 2026-10-27 01:07:53 +05:30
Abhi Kumar
5515b36a85 test(dashboards): cover the New Panel picker, editor shell and query pane 2026-10-27 01:07:52 +05:30
Abhi Kumar
3ed816ed74 test(dashboards): cover creating a dashboard from the list page 2026-10-27 01:07:51 +05:30
Abhi Kumar
2393165b13 test(dashboards): cover zoom, drilldown and the View modal 2026-10-27 01:07:51 +05:30
Abhi Kumar
50d5ccf739 test(dashboards): cover the panel header, states, actions and grid layout 2026-10-27 01:07:50 +05:30
Abhi Kumar
61608dffc9 test(dashboards): add the panel editor, config and dashboard list drivers 2026-10-27 01:07:49 +05:30
Abhi Kumar
b4c44b80d6 test(dashboards): add the panels driver 2026-10-27 01:07:48 +05:30
Abhi Kumar
babf696234 test(dashboards): add the dashboards driver and spec builders 2026-10-27 01:07:47 +05:30
Abhi Kumar
11e87a2892 test(dashboards): drop the specs written for the old dashboard
They target /api/v1/dashboards, which the backend now rejects, and have been skipped
since. Each behaviour is ported onto the drivers in the list and page specs.
2026-10-27 01:07:46 +05:30
Abhi Kumar
d9f8755bc6 test(e2e): let a driver clean up after its test 2026-10-27 01:07:46 +05:30
Abhi Kumar
f14f7497b6 test(dashboards): add panel test ids 2026-10-27 01:07:41 +05:30
Abhi Kumar
5d28d0b6a0 test(dashboards): expose the uPlot instance on the chart container
A chart is a canvas, so a test cannot read its series, scales or bands any other way. Both the
e2e suite and the panel stories read it.
2026-10-27 00:11:17 +05:30
182 changed files with 9249 additions and 11999 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -128,7 +128,7 @@ test('TC-09 RouteTab switches the url', async ({ routing }) => {
});
```
- `driver(name, build)` adds a test-scoped fixture built on `authedPage`.
- `driver(name, build, dispose?)` adds a test-scoped fixture built on `authedPage`. `dispose` runs after the test on the same page, so a driver can delete what it seeded.
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
- A driver reuses helpers by calling them, never by reaching for another fixture.
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
export const DEFAULT_VISIBLE_ITEMS_COUNT = 10;

View File

@@ -2,9 +2,10 @@ import { useMemo, useState } from 'react';
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DEFAULT_VISIBLE_ITEMS_COUNT } from './constants';
import { isKeyMatch } from './utils';
const DEFAULT_VISIBLE_ITEMS_COUNT = 10;
interface UseCheckboxDisclosureProps {
filter: IQuickFiltersConfig;
activeQueryIndex: number;

View File

@@ -6,6 +6,44 @@
border-bottom: 1px solid var(--l1-border);
}
.sectionActions {
width: 0;
overflow: hidden;
opacity: 0;
transform: translateX(4px);
pointer-events: none;
transition:
opacity 0.16s ease,
transform 0.16s ease,
width 0s linear 0.16s;
}
.checkboxFilter:hover .sectionActions,
.sectionActions.sectionActionsPinned {
width: auto;
opacity: 1;
transform: translateX(0);
pointer-events: auto;
transition:
opacity 0.16s ease,
transform 0.16s ease;
}
.sectionActionsPinned .sectionActionHoverOnly {
opacity: 0;
transform: translateX(4px);
pointer-events: none;
transition:
opacity 0.16s ease,
transform 0.16s ease;
}
.checkboxFilter:hover .sectionActionsPinned .sectionActionHoverOnly {
opacity: 1;
transform: translateX(0);
pointer-events: auto;
}
.search {
--input-background: var(--l2-background);
--input-hover-background: var(--l2-background);

View File

@@ -11,7 +11,6 @@ import {
QuickFiltersSource,
} from '../../../types';
import CheckboxFilterV2 from './CheckboxFilterV2';
import { DEFAULT_VISIBLE_ITEMS_COUNT } from '../constants';
export const DEFAULT_FILTER: IQuickFiltersConfig = {
type: FiltersType.CHECKBOX,
@@ -31,13 +30,6 @@ export const DEFAULT_USE_FIELD_APIS: QuickFilterCheckboxUseFieldApis = {
existingQuery: null,
};
// Enough extra values to push a section past the count that shows its search box.
// Named to sort last, so a test's own values stay within the first page.
export const EXTRA_VALUES = Array.from(
{ length: DEFAULT_VISIBLE_ITEMS_COUNT },
(_, index) => `zz-extra-value-${index + 1}`,
);
export function mockFieldsValuesAPI(response: {
relatedValues?: (string | null)[];
stringValues?: (string | null)[];

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Input } from '@signozhq/ui/input';
import { Skeleton } from 'antd';
import classNames from 'classnames';
import { Typography } from '@signozhq/ui/typography';
import { LoaderCircle } from '@signozhq/icons';
import {
@@ -16,7 +17,6 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { NON_SELECTED_OPERATORS } from '../checkboxFilterQuery';
import useActiveQueryIndex from 'components/QuickFilters/hooks/useActiveQueryIndex';
import { DEFAULT_VISIBLE_ITEMS_COUNT } from '../constants';
import useCheckboxDisclosure from '../useCheckboxDisclosure';
import useCheckboxFilterActions from '../useCheckboxFilterActions';
import useCheckboxFilterState from '../useCheckboxFilterState';
@@ -45,8 +45,14 @@ export default function CheckboxFilterV2(
const { source, filter, onFilterChange, onQuickFilterChange, useFieldApis } =
props;
const [searchText, setSearchText] = useState<string>('');
const [isSearchOpen, setIsSearchOpen] = useState<boolean>(false);
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
const handleToggleSearch = (): void => {
setIsSearchOpen((prev) => !prev);
setSearchText('');
};
const { currentQuery } = useQueryBuilder();
const activeQueryIndex = useActiveQueryIndex(source);
@@ -115,14 +121,6 @@ export default function CheckboxFilterV2(
setSearchText(args[0] as string);
}, DEBOUNCE_DELAY);
const handleToggleOpen = (): void => {
if (isOpen) {
setSearchTextDebounced.cancel();
setSearchText('');
}
onToggleOpen();
};
const currentFilterOp = useMemo(() => {
const filterSync = currentQuery?.builder.queryData?.[
activeQueryIndex
@@ -168,22 +166,19 @@ export default function CheckboxFilterV2(
relatedExclusions,
});
// Kept while a search is typed, so narrowing the list does not remove the box,
// and while a refetch still shows the previous, narrowed results.
const wasSearchShown = useRef(false);
const isSearchShown =
searchText !== '' ||
totalCount > DEFAULT_VISIBLE_ITEMS_COUNT ||
(isFetching && wasSearchShown.current);
wasSearchShown.current = isSearchShown;
return (
<div className={styles.checkboxFilter} data-testid="checkbox-filter-v2">
<CheckboxFilterV2Header
title={filter.title}
isOpen={isOpen}
canReset={isSomeFilterPresentForCurrentAttribute}
onToggleOpen={handleToggleOpen}
actionsClassName={classNames(styles.sectionActions, {
[styles.sectionActionsPinned]: isSearchOpen,
})}
resetActionClassName={
isSearchOpen ? styles.sectionActionHoverOnly : undefined
}
onToggleOpen={onToggleOpen}
onToggleSearch={handleToggleSearch}
onClear={onClear}
/>
{isOpen && isLoading && !hasLoadedOnce.current && (
@@ -193,9 +188,10 @@ export default function CheckboxFilterV2(
)}
{isOpen && (!isLoading || hasLoadedOnce.current) && (
<>
{isSearchShown && (
{isSearchOpen && (
<section className={styles.search}>
<Input
autoFocus
placeholder="Filter values"
onChange={(e): void => setSearchTextDebounced(e.target.value)}
disabled={isFilterDisabled}

View File

@@ -1,25 +1,31 @@
import { useState } from 'react';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import { ChevronDown, ChevronRight, Undo2 } from '@signozhq/icons';
import { ChevronDown, ChevronRight, Search, Undo2 } from '@signozhq/icons';
import { SectionActionButton } from '../../shared/SectionActionButton/SectionActionButton';
import classNames from 'classnames';
import styles from './CheckboxFilterV2Header.module.scss';
interface CheckboxFilterHeaderProps {
title: string;
isOpen: boolean;
canReset: boolean;
actionsClassName?: string;
resetActionClassName?: string;
onToggleOpen: () => void;
onToggleSearch: () => void;
onClear: () => void;
}
export function CheckboxFilterV2Header({
title,
isOpen,
canReset,
actionsClassName,
resetActionClassName,
onToggleOpen,
onToggleSearch,
onClear,
}: CheckboxFilterHeaderProps): JSX.Element {
const [isTitleTruncated, setIsTitleTruncated] = useState(false);
@@ -58,14 +64,21 @@ export function CheckboxFilterV2Header({
)}
<Tooltip title={isTitleTruncated ? title : undefined}>{titleText}</Tooltip>
</section>
{isOpen && canReset && (
<section className={styles.rightAction}>
{isOpen && (
<section className={classNames(styles.rightAction, actionsClassName)}>
<SectionActionButton
icon={<Undo2 size={14} />}
className={resetActionClassName}
tooltip="Reset"
onClick={onClear}
testId="checkbox-filter-clear-all"
/>
<SectionActionButton
icon={<Search size={14} />}
tooltip="Search"
onClick={onToggleSearch}
testId="checkbox-filter-search-toggle"
/>
</section>
)}
</section>

View File

@@ -10,7 +10,6 @@ import {
buildQueryBuilderOverrides,
DEFAULT_FILTER,
DEFAULT_USE_FIELD_APIS,
EXTRA_VALUES,
getFilterFromCall,
mockFieldsValuesAPI,
renderWithFilter,
@@ -50,7 +49,7 @@ describe('CheckboxFilterV2 - interactions', () => {
const values =
searchTextReceived === ''
? ['production', 'staging', 'development', ...EXTRA_VALUES]
? ['production', 'staging', 'development']
: ['production'];
return res(
@@ -80,6 +79,7 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-production');
expect(screen.getByTestId('checkbox-value-row-staging')).toBeInTheDocument();
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
@@ -92,41 +92,6 @@ describe('CheckboxFilterV2 - interactions', () => {
screen.queryByTestId('checkbox-value-row-staging'),
).not.toBeInTheDocument();
});
expect(screen.getByTestId('checkbox-filter-search')).toBeInTheDocument();
});
it('shows no search box when every value fits', async () => {
mockFieldsValuesAPI({ stringValues: EXTRA_VALUES });
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId(`checkbox-value-row-${EXTRA_VALUES[0]}`);
expect(
screen.queryByTestId('checkbox-filter-search'),
).not.toBeInTheDocument();
});
it('shows the search box once there are more values than fit', async () => {
mockFieldsValuesAPI({ stringValues: ['production', ...EXTRA_VALUES] });
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await expect(
screen.findByTestId('checkbox-filter-search'),
).resolves.toBeInTheDocument();
});
it('filters values via search while preserving existingQuery context', async () => {
@@ -146,7 +111,7 @@ describe('CheckboxFilterV2 - interactions', () => {
data: {
values: {
relatedValues: ['production'],
stringValues: ['staging', 'development', ...EXTRA_VALUES],
stringValues: ['staging', 'development'],
numberValues: [],
},
},
@@ -198,6 +163,7 @@ describe('CheckboxFilterV2 - interactions', () => {
// Related values now appear in "Related" section (no badge, uses divider instead)
expect(screen.getByTestId('section-divider-related')).toBeInTheDocument();
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
@@ -225,10 +191,7 @@ describe('CheckboxFilterV2 - interactions', () => {
data: {
values: {
relatedValues: [],
stringValues:
searchText === ''
? ['prod', 'staging', ...EXTRA_VALUES]
: ['prod-match'],
stringValues: searchText === '' ? ['prod', 'staging'] : ['prod-match'],
numberValues: [],
},
},
@@ -250,6 +213,7 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-prod');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');
@@ -275,8 +239,7 @@ describe('CheckboxFilterV2 - interactions', () => {
data: {
values: {
relatedValues: [],
stringValues:
searchText === '' ? ['prod', 'staging', ...EXTRA_VALUES] : [],
stringValues: searchText === '' ? ['prod', 'staging'] : [],
numberValues: [],
},
},
@@ -295,6 +258,7 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-prod');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'xyz-no-match');
@@ -371,9 +335,7 @@ describe('CheckboxFilterV2 - interactions', () => {
const relatedValues =
searchText === '' ? ['pod-a-v1', 'pod-b-v1', 'pod-c-v1'] : ['pod-a-v1']; // filtered to match 'pod-a'
const stringValues =
searchText === ''
? ['pod-a-v2', 'pod-b-v2', 'pod-c-v2', ...EXTRA_VALUES]
: ['pod-a-v2']; // filtered to match 'pod-a'
searchText === '' ? ['pod-a-v2', 'pod-b-v2', 'pod-c-v2'] : ['pod-a-v2']; // filtered to match 'pod-a'
return res(
ctx.status(200),
@@ -404,6 +366,7 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-pod-a-v1');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'pod-a');
@@ -538,56 +501,6 @@ describe('CheckboxFilterV2 - interactions', () => {
expect(header).toHaveAttribute('data-state', 'open');
await screen.findByTestId('checkbox-value-row-production');
});
it('clears the search when the filter is collapsed', async () => {
const user = userEvent.setup();
const values = Array.from(
{ length: 12 },
(_, i) => `value-${String(i).padStart(2, '0')}`,
);
server.use(
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) => {
const searchText = req.url.searchParams.get('searchText') || '';
return res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
values: {
relatedValues: [],
stringValues: values.filter((value) => value.includes(searchText)),
numberValues: [],
},
},
}),
);
}),
);
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
/>,
);
await screen.findByTestId('checkbox-value-row-value-00');
await user.type(screen.getByTestId('checkbox-filter-search'), '11');
await waitFor(() =>
expect(
screen.queryByTestId('checkbox-value-row-value-00'),
).not.toBeInTheDocument(),
);
const header = screen.getByTestId('checkbox-filter-header');
await user.click(header);
await user.click(header);
await screen.findByTestId('checkbox-value-row-value-00');
expect(screen.getByTestId('checkbox-filter-search')).toHaveValue('');
});
});
describe('show more functionality', () => {
@@ -683,7 +596,7 @@ describe('CheckboxFilterV2 - interactions', () => {
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
});
it('hides reset when the section has no filter applied', async () => {
it('shows the reset action when expanded even with no active filter', async () => {
mockFieldsValuesAPI({
stringValues: ['production'],
});
@@ -698,9 +611,45 @@ describe('CheckboxFilterV2 - interactions', () => {
await screen.findByTestId('checkbox-value-row-production');
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
// Reset is always available on an expanded section now (hover-gated via
// CSS), not conditional on an active filter.
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
});
it('does not dispatch on clear when the key has no filter', async () => {
const user = userEvent.setup();
const onFilterChange = jest.fn();
mockFieldsValuesAPI({
stringValues: ['production'],
});
render(
<CheckboxFilterV2
filter={DEFAULT_FILTER}
source={QuickFiltersSource.TRACES_EXPLORER}
useFieldApis={DEFAULT_USE_FIELD_APIS}
onFilterChange={onFilterChange}
/>,
undefined,
{
queryBuilderOverrides: buildQueryBuilderOverrides({
builder: {
queryData: [
{
filters: { items: [], op: 'AND' },
filter: { expression: '' },
},
],
},
}),
},
);
await screen.findByTestId('checkbox-value-row-production');
await user.click(screen.getByTestId('checkbox-filter-clear-all'));
expect(onFilterChange).not.toHaveBeenCalled();
});
it('calls onFilterChange when clear clicked', async () => {

View File

@@ -9,7 +9,6 @@ import CheckboxFilterV2 from '../CheckboxFilterV2';
import {
DEFAULT_FILTER,
DEFAULT_USE_FIELD_APIS,
EXTRA_VALUES,
mockFieldsValuesAPI,
mockFieldsValuesAPILoading,
setupServer,
@@ -90,7 +89,7 @@ describe('CheckboxFilterV2 - states', () => {
data: {
values: {
relatedValues: [],
stringValues: ['production', 'staging', ...EXTRA_VALUES],
stringValues: ['production', 'staging'],
numberValues: [],
},
},
@@ -111,6 +110,7 @@ describe('CheckboxFilterV2 - states', () => {
await screen.findByTestId('checkbox-value-row-production');
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
const searchInput = screen.getByTestId('checkbox-filter-search');
await user.type(searchInput, 'prod');

View File

@@ -7,8 +7,8 @@ describe('CheckboxFilterV2Header', () => {
const defaultProps = {
title: 'Environment',
isOpen: false,
canReset: true,
onToggleOpen: jest.fn(),
onToggleSearch: jest.fn(),
onClear: jest.fn(),
};
@@ -30,9 +30,12 @@ describe('CheckboxFilterV2Header', () => {
expect(header).toHaveAttribute('data-state', 'closed');
});
it('does not render reset when collapsed', () => {
it('does not render the section actions when collapsed', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen={false} />);
expect(
screen.queryByTestId('checkbox-filter-search-toggle'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
@@ -47,18 +50,13 @@ describe('CheckboxFilterV2Header', () => {
expect(header).toHaveAttribute('data-state', 'open');
});
it('renders reset when the section has a filter applied', () => {
it('renders both search and reset actions when expanded', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen />);
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
});
it('hides reset when there is nothing to reset', () => {
render(<CheckboxFilterV2Header {...defaultProps} isOpen canReset={false} />);
expect(
screen.queryByTestId('checkbox-filter-clear-all'),
).not.toBeInTheDocument();
screen.getByTestId('checkbox-filter-search-toggle'),
).toBeInTheDocument();
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
});
});
@@ -101,6 +99,25 @@ describe('CheckboxFilterV2Header', () => {
expect(onToggleOpen).toHaveBeenCalledTimes(1);
});
it('calls onToggleSearch on search click without toggling open', async () => {
const user = userEvent.setup();
const onToggleSearch = jest.fn();
const onToggleOpen = jest.fn();
render(
<CheckboxFilterV2Header
{...defaultProps}
isOpen
onToggleSearch={onToggleSearch}
onToggleOpen={onToggleOpen}
/>,
);
await user.click(screen.getByTestId('checkbox-filter-search-toggle'));
expect(onToggleSearch).toHaveBeenCalledTimes(1);
expect(onToggleOpen).not.toHaveBeenCalled();
});
it('calls onClear on reset click without toggling open', async () => {
const user = userEvent.setup();
const onClear = jest.fn();

View File

@@ -9,7 +9,6 @@ import { useSavedViewEnabled } from 'hooks/useSavedViewEnabled';
import QuickFilters from '../QuickFilters';
import { SIDEBAR_TOOLTIP_DELAY_MS } from './constants';
import { useCloseOnOutsidePress } from './useCloseOnOutsidePress';
import styles from './QuickFiltersLayout.module.scss';
@@ -62,20 +61,12 @@ function QuickFiltersLayout({
const isSliding = hasQuickFilters && isViewsListOpen;
const openViews = (): void => setIsViewsListOpen(true);
const toggleViews = (): void => setIsViewsListOpen((isOpen) => !isOpen);
const closeViews = (): void => setIsViewsListOpen(false);
const markSidebarPress = useCloseOnOutsidePress(
showSidebar && isSliding,
closeViews,
);
const savedViewsHeader = hasSavedViews ? (
<SavedViewsHeader
{...savedViewProps}
onOpenViews={isViewsListVisible ? undefined : openViews}
isViewsOpen={isViewsListOpen}
onToggleViews={isViewsListPinned ? undefined : toggleViews}
onOpenViews={
isViewsListVisible ? undefined : (): void => setIsViewsListOpen(true)
}
onCollapse={hasQuickFilters ? undefined : onToggleFilters}
/>
) : undefined;
@@ -86,7 +77,6 @@ function QuickFiltersLayout({
{showSidebar && (
<aside
className={cx(styles.sidebar, { [styles.isStatic]: !hasQuickFilters })}
onPointerDownCapture={markSidebarPress}
data-testid="quick-filters-layout-filters"
>
<TooltipProvider delay={SIDEBAR_TOOLTIP_DELAY_MS}>
@@ -108,7 +98,9 @@ function QuickFiltersLayout({
{hasSavedViews && isViewsListVisible && (
<SavedViewsPanel
{...savedViewProps}
onClose={isViewsListPinned ? undefined : closeViews}
onClose={
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
}
/>
)}
</div>

View File

@@ -218,62 +218,6 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByTestId('saved-views-open')).toBeInTheDocument();
});
it('opens and closes the panel from the view name', async () => {
render(
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div>content</div>
</QuickFiltersLayout>,
);
const user = userEvent.setup();
const toggle = await screen.findByTestId('saved-views-name-toggle');
expect(toggle).toHaveAttribute('aria-expanded', 'false');
await user.click(toggle);
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(drawer()).toHaveClass('isOpen');
expect(toggle).toHaveAttribute('aria-expanded', 'true');
await user.click(toggle);
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
});
it('closes the panel on a click outside the sidebar, not inside it', async () => {
render(
<>
<button type="button" data-testid="page-tab">
Pipelines
</button>
<QuickFiltersLayout
showFilters
quickFilterProps={quickFilterProps}
savedViewProps={savedViewProps}
>
<div data-testid="page-content">content</div>
</QuickFiltersLayout>
</>,
);
const user = userEvent.setup();
await user.click(await screen.findByTestId('saved-views-name-toggle'));
await user.click(screen.getByTestId('saved-views-panel-header'));
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
await user.click(screen.getByTestId('page-tab'));
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
expect(drawer()).not.toHaveClass('isOpen');
await user.click(await screen.findByTestId('saved-views-name-toggle'));
await user.click(screen.getByTestId('page-content'));
expect(screen.queryByTestId('saved-views-panel')).not.toBeInTheDocument();
});
it('hands the source to the header and the panel', async () => {
render(
<QuickFiltersLayout
@@ -314,9 +258,6 @@ describe('QuickFiltersLayout', () => {
expect(screen.getByTestId('saved-views-header')).toBeInTheDocument();
expect(screen.getByTestId('saved-views-panel')).toBeInTheDocument();
expect(screen.queryByTestId('saved-views-open')).not.toBeInTheDocument();
expect(
screen.queryByTestId('saved-views-name-toggle'),
).not.toBeInTheDocument();
expect(
screen.queryByTestId('saved-views-panel-header'),
).not.toBeInTheDocument();

View File

@@ -1,63 +0,0 @@
import { createPortal } from 'react-dom';
import { render, screen, userEvent } from 'tests/test-utils';
import { useCloseOnOutsidePress } from '../useCloseOnOutsidePress';
function Harness({
enabled,
onClose,
}: {
enabled: boolean;
onClose: () => void;
}): JSX.Element {
const markInside = useCloseOnOutsidePress(enabled, onClose);
return (
<>
<div onPointerDownCapture={markInside}>
<button type="button" data-testid="inside">
inside
</button>
{createPortal(
<button type="button" data-testid="portal">
menu item
</button>,
document.body,
)}
</div>
<button type="button" data-testid="outside">
outside
</button>
</>
);
}
describe('useCloseOnOutsidePress', () => {
it('closes on a press outside', async () => {
const onClose: jest.MockedFunction<() => void> = jest.fn();
render(<Harness enabled onClose={onClose} />);
await userEvent.click(screen.getByTestId('outside'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('stays open on a press inside, including a portal rendered from inside', async () => {
const onClose: jest.MockedFunction<() => void> = jest.fn();
render(<Harness enabled onClose={onClose} />);
await userEvent.click(screen.getByTestId('inside'));
await userEvent.click(screen.getByTestId('portal'));
expect(onClose).not.toHaveBeenCalled();
});
it('does nothing while disabled', async () => {
const onClose: jest.MockedFunction<() => void> = jest.fn();
render(<Harness enabled={false} onClose={onClose} />);
await userEvent.click(screen.getByTestId('outside'));
expect(onClose).not.toHaveBeenCalled();
});
});

View File

@@ -1,39 +0,0 @@
import { useCallback, useEffect, useRef } from 'react';
/**
* Calls `onClose` on any press outside the element given the returned handler as
* `onPointerDownCapture`. Menus and dialogs rendered in portals still count as
* inside: React events follow the component tree, so their presses reach the
* handler before the document listener runs.
*/
export function useCloseOnOutsidePress(
enabled: boolean,
onClose: () => void,
): () => void {
const isPressInside = useRef(false);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
if (!enabled) {
return undefined;
}
isPressInside.current = false;
const handlePointerDown = (): void => {
if (isPressInside.current) {
isPressInside.current = false;
return;
}
onCloseRef.current();
};
document.addEventListener('pointerdown', handlePointerDown);
return (): void =>
document.removeEventListener('pointerdown', handlePointerDown);
}, [enabled]);
return useCallback((): void => {
isPressInside.current = true;
}, []);
}

View File

@@ -71,7 +71,6 @@ export const AIAssistantOpenSource = {
Shortcut: 'shortcut',
Cmdk: 'cmdk',
TraceDetails: 'trace_details',
BottomStrip: 'bottom_strip',
} as const;
export type AIAssistantOpenSource =
(typeof AIAssistantOpenSource)[keyof typeof AIAssistantOpenSource];

View File

@@ -1,15 +1,8 @@
import { useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import logEvent from 'api/common/logEvent';
import Noz from 'components/Noz/Noz';
import { NOZ_TOOLTIP_TITLE } from 'components/Noz/Noz.constants';
import {
AIAssistantEvents,
AIAssistantOpenSource,
} from 'container/AIAssistant/events';
import { normalizePage } from 'container/AIAssistant/hooks/useAIAssistantAnalyticsContext';
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
import {
openAIAssistant,
@@ -44,14 +37,6 @@ function AskNoz(): JSX.Element | null {
// The drawer does not render on the Noz full page, so the button would be inert.
const isAIAssistantPage = pathname.startsWith(ROUTES.AI_ASSISTANT_BASE);
const handleOpen = useCallback((): void => {
void logEvent(AIAssistantEvents.Opened, {
source: AIAssistantOpenSource.BottomStrip,
currentPage: normalizePage(pathname),
});
openAIAssistant();
}, [pathname]);
if (!isAIAssistantEnabled || isDrawerOpen || isAIAssistantPage) {
return null;
}
@@ -72,7 +57,7 @@ function AskNoz(): JSX.Element | null {
color="secondary"
size="sm"
prefix={<Noz size={16} />}
onClick={handleOpen}
onClick={(): void => openAIAssistant()}
aria-label={
showPendingBadge
? `Ask Noz, ${pendingUserInputCount} ${

View File

@@ -1,9 +1,4 @@
import { fireEvent } from '@testing-library/react';
import logEvent from 'api/common/logEvent';
import {
AIAssistantEvents,
AIAssistantOpenSource,
} from 'container/AIAssistant/events';
import { openAIAssistant } from 'container/AIAssistant/store/useAIAssistantStore';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { render } from 'tests/test-utils';
@@ -11,10 +6,6 @@ import { render } from 'tests/test-utils';
import AskNoz from '../AskNoz';
jest.mock('hooks/useIsAIAssistantEnabled');
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('container/AIAssistant/store/useAIAssistantStore', () => ({
openAIAssistant: jest.fn(),
useAIAssistantStore: jest.fn(),
@@ -100,17 +91,6 @@ describe('AskNoz', () => {
expect(mockOpen).toHaveBeenCalledTimes(1);
});
it('reports the open as coming from the strip', () => {
const { getByRole } = render(<AskNoz />);
fireEvent.click(getByRole('button'));
expect(logEvent).toHaveBeenCalledWith(
AIAssistantEvents.Opened,
expect.objectContaining({ source: AIAssistantOpenSource.BottomStrip }),
);
});
});
describe('pending badge', () => {

View File

@@ -1,16 +1,9 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import logEvent from 'api/common/logEvent';
import AddCreditCardModal from 'components/AddCreditCardModal/AddCreditCardModal';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { MessageSquareText } from '@signozhq/icons';
import {
BottomStripEventKeys,
BottomStripEvents,
BottomStripSupportType,
} from '../events';
function SupportButton(): JSX.Element | null {
const chatSupport = useChatSupport();
const [isAddCreditCardModalOpen, setIsAddCreditCardModalOpen] =
@@ -21,13 +14,7 @@ function SupportButton(): JSX.Element | null {
}
const handleClick = (): void => {
const needsCard = chatSupport === ChatSupportState.NeedsCard;
void logEvent(BottomStripEvents.SupportClicked, {
[BottomStripEventKeys.Type]: needsCard
? BottomStripSupportType.AddCard
: BottomStripSupportType.Pylon,
});
if (needsCard) {
if (chatSupport === ChatSupportState.NeedsCard) {
setIsAddCreditCardModalOpen(true);
return;
}

View File

@@ -1,16 +1,9 @@
import { fireEvent } from '@testing-library/react';
import logEvent from 'api/common/logEvent';
import { ChatSupportState, useChatSupport } from 'hooks/useChatSupport';
import { render } from 'tests/test-utils';
import { BottomStripEvents, BottomStripSupportType } from '../../events';
import SupportButton from '../SupportButton';
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('hooks/useChatSupport', () => ({
...jest.requireActual('hooks/useChatSupport'),
useChatSupport: jest.fn(),
@@ -44,9 +37,6 @@ describe('SupportButton', () => {
fireEvent.click(getByTestId(BUTTON));
expect(window.Pylon).toHaveBeenCalledWith('show');
expect(logEvent).toHaveBeenCalledWith(BottomStripEvents.SupportClicked, {
type: BottomStripSupportType.Pylon,
});
});
it('does not open the credit card modal', () => {
@@ -74,9 +64,6 @@ describe('SupportButton', () => {
expect(getByText(MODAL_TITLE)).toBeInTheDocument();
expect(window.Pylon).not.toHaveBeenCalled();
expect(logEvent).toHaveBeenCalledWith(BottomStripEvents.SupportClicked, {
type: BottomStripSupportType.AddCard,
});
});
});

View File

@@ -1,12 +0,0 @@
export enum BottomStripEvents {
SupportClicked = 'Bottom Strip: Support clicked',
}
export enum BottomStripEventKeys {
Type = 'type',
}
export enum BottomStripSupportType {
Pylon = 'pylon',
AddCard = 'add_card',
}

View File

@@ -12,7 +12,6 @@ import {
SavedViewCreatePermission,
SavedViewListPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import {
MY_VIEW_NAME,
@@ -20,13 +19,11 @@ import {
SAVED_VIEW_LOAD_FAILED_NAME,
SAVED_VIEW_TOAST_POSITION,
} from './constants';
import { SavedViewCreatedFrom } from './events';
import { useActiveSavedView } from './hooks/useActiveSavedView';
import { useSaveNewView } from './hooks/useSaveNewView';
import { useSavedViewActions } from './hooks/useSavedViewActions';
import SaveChangesMenu from './SaveChangesMenu';
import SavedViewsIconButton from './SavedViewsIconButton';
import SavedViewsNameToggle from './SavedViewsNameToggle';
import SaveViewModal from './SaveViewModal';
import { SaveViewModalMode } from './types';
@@ -35,16 +32,11 @@ import styles from './SavedViewsHeader.module.scss';
function SavedViewsHeader({
source,
onOpenViews,
isViewsOpen = false,
onToggleViews,
onCollapse,
}: {
source: SavedviewtypesSourceDTO;
// Absent while the list is on screen.
onOpenViews?: () => void;
isViewsOpen?: boolean;
// Absent while the list is pinned open.
onToggleViews?: () => void;
onCollapse?: () => void;
}): JSX.Element {
const {
@@ -57,27 +49,12 @@ function SavedViewsHeader({
} = useActiveSavedView(source);
const { viewKey } = useGetSavedViewParams();
const readPermission = buildSavedViewReadPermission(viewKey);
const { isLoading: isCheckingList, deniedPermissions: deniedList } = useAuthZ(
[SavedViewListPermission],
{ enabled: !!onToggleViews },
);
const canListViews = !isCheckingList && deniedList.length === 0;
const { revertView, clearView, createView, updateView, isSaving } =
useSavedViewActions(source);
const [modalMode, setModalMode] = useState<SaveViewModalMode | null>(null);
const saveNewView = useSaveNewView(createView);
const handleSave = useCallback(
(displayName: string): Promise<boolean> =>
saveNewView(
displayName,
modalMode === 'saveAsNew'
? SavedViewCreatedFrom.SaveAsNew
: SavedViewCreatedFrom.Header,
),
[saveNewView, modalMode],
);
const handleSave = useSaveNewView(createView);
const handleUpdate = useCallback(async (): Promise<void> => {
if (view && (await updateView(view))) {
@@ -95,9 +72,6 @@ function SavedViewsHeader({
name = SAVED_VIEW_LOAD_FAILED_NAME;
}
const isDirty = !isError && !!view && hasUnsavedChanges;
// Same gates as the All views button; an open panel can always be closed.
const canToggleViews =
isViewsOpen || (canListViews && !isDirty && !isReadDenied);
const nameChip = (
<span
@@ -138,17 +112,10 @@ function SavedViewsHeader({
className={styles.skeleton}
testId="saved-views-loading"
/>
) : isReadDenied ? (
<AuthZTooltip checks={[readPermission]}>{nameChip}</AuthZTooltip>
) : (
<SavedViewsNameToggle
isOpen={isViewsOpen}
onToggle={canToggleViews ? onToggleViews : undefined}
>
{isReadDenied ? (
<AuthZTooltip checks={[readPermission]}>{nameChip}</AuthZTooltip>
) : (
<Tooltip title={errorMessage ?? name}>{nameChip}</Tooltip>
)}
</SavedViewsNameToggle>
<Tooltip title={errorMessage ?? name}>{nameChip}</Tooltip>
)}
</div>

View File

@@ -10,7 +10,6 @@ import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { SAVED_VIEWS_LIST_LOADING_ROWS } from './constants';
import DeleteSavedViewDialog from './DeleteSavedViewDialog';
import { SavedViewCreatedFrom } from './events';
import { useSavedViewActions } from './hooks/useSavedViewActions';
import { useSavedViewHoverCard } from './hooks/useSavedViewHoverCard';
import { useSavedViewRowActions } from './hooks/useSavedViewRowActions';
@@ -36,7 +35,7 @@ function SavedViewsList({
search: string;
}): JSX.Element {
const { viewKey } = useGetSavedViewParams();
const { selectView, clearView, resetView, createView, isSaving } =
const { selectView, clearView, createView, isSaving } =
useSavedViewActions(source);
const saveNewView = useSaveNewView(createView);
const { createdByMe, createdByOthers, isLoading, isError, refetch } =
@@ -103,10 +102,10 @@ function SavedViewsList({
return;
}
if (pendingAction.view.id === viewKey) {
resetView();
clearView();
}
setPendingAction(null);
}, [pendingAction, deleteView, viewKey, resetView]);
}, [pendingAction, deleteView, viewKey, clearView]);
if (isLoading) {
return (
@@ -142,9 +141,7 @@ function SavedViewsList({
<SavedViewsEmptyState
isSearching={search.trim().length > 0}
isSaving={isSaving}
onSave={(displayName): Promise<boolean> =>
saveNewView(displayName, SavedViewCreatedFrom.EmptyState)
}
onSave={saveNewView}
/>
);
}

View File

@@ -1,14 +0,0 @@
.toggle {
display: flex;
min-width: 0;
--button-height: auto;
// A flex label lets the chip shrink and use its own ellipsis.
--button-label-display: flex;
}
// The chip inherits its type from the button; doubled to outrank the button's
// size rule so the chip reads the same as when it is not a toggle.
.toggle.toggle > button {
font: inherit;
letter-spacing: inherit;
}

View File

@@ -1,36 +0,0 @@
import { ReactElement } from 'react';
import { Button } from '@signozhq/ui/button';
import styles from './SavedViewsNameToggle.module.scss';
function SavedViewsNameToggle({
isOpen,
onToggle,
children,
}: {
isOpen: boolean;
onToggle?: () => void;
children: ReactElement;
}): JSX.Element {
if (!onToggle) {
return children;
}
return (
<span className={styles.toggle}>
<Button
variant="link"
color="secondary"
size="sm"
textOverflow="hidden"
onClick={onToggle}
aria-expanded={isOpen}
testId="saved-views-name-toggle"
>
{children}
</Button>
</span>
);
}
export default SavedViewsNameToggle;

View File

@@ -1,322 +0,0 @@
import {
act,
fireEvent,
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 { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewCreatedFrom, SavedViewEvents } from '../events';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
expectRowUsable,
expectUsable,
explorerUrl,
makeView,
MENU_CHECK_TIMEOUT,
queryWith,
renderWithExplorerProviders,
viewUrl,
} from './savedViewsTestUtils';
const logEventMock = jest.fn();
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: (...args: unknown[]): void => logEventMock(...args),
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
// copy-to-clipboard falls back to window.prompt in jsdom.
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: (): [unknown, jest.Mock] => [null, jest.fn()],
}));
const PATH = ROUTES.TRACES_EXPLORER;
const source = SavedviewtypesSourceDTO.traces;
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
function renderAll(
url = explorerUrl(PATH, {}),
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<>
<SavedViewsHeader source={source} onOpenViews={jest.fn()} />
<SavedViewsPanel source={source} onClose={jest.fn()} />
</>,
url,
);
}
const row = (name: string): HTMLElement =>
screen
.getAllByTestId('saved-views-row')
.find((element) => element.textContent?.includes(name)) as HTMLElement;
// Buttons and menu rows ignore clicks until their permission check settles.
async function clickWhenUsable(testId: string): Promise<void> {
await waitFor(() => expectUsable(screen.getByTestId(testId)));
await userEvent.click(screen.getByTestId(testId));
}
async function chooseMenuItem(testId: string): Promise<void> {
const item = await screen.findByTestId(testId);
await waitFor(() => expectRowUsable(item), MENU_CHECK_TIMEOUT);
fireEvent.click(item);
}
async function chooseFromRowMenu(viewName: string, key: string): Promise<void> {
await screen.findByTestId('saved-views-list');
fireEvent.click(within(row(viewName)).getByTestId('saved-views-row-menu'));
await chooseMenuItem(`saved-views-row-menu-item-${key}`);
}
async function chooseFromSaveMenu(key: string): Promise<void> {
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
await chooseMenuItem(`saved-views-save-changes-item-${key}`);
}
async function submitName(name: string): Promise<void> {
await userEvent.type(screen.getByTestId('save-view-name'), name);
await userEvent.click(screen.getByTestId('save-view-submit'));
}
async function waitForView(name: string): Promise<void> {
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(name),
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument(),
);
}
const callsOf = (event: SavedViewEvents): unknown[][] =>
logEventMock.mock.calls.filter(([name]) => name === event);
describe('SavedViews analytics', () => {
beforeEach(() => {
localStorage.clear();
logEventMock.mockClear();
jest.mocked(toast.error).mockClear();
});
it('logs a view created from the header', async () => {
mockSavedViewsApi([errors], { createdId: 'view-new' });
renderAll();
await clickWhenUsable('saved-views-create');
await submitName('Slow checkout');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
source,
viewId: 'view-new',
from: SavedViewCreatedFrom.Header,
}),
);
});
it('logs a view created from the empty state', async () => {
mockSavedViewsApi([], { createdId: 'view-new' });
renderAll();
await screen.findByTestId('saved-views-list-create');
await clickWhenUsable('saved-views-list-create');
await submitName('Slow checkout');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
source,
viewId: 'view-new',
from: SavedViewCreatedFrom.EmptyState,
}),
);
});
it('does not log a create that fails', async () => {
mockSavedViewsApi([errors], { createStatus: 500 });
renderAll();
await clickWhenUsable('saved-views-create');
await submitName('Slow checkout');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(callsOf(SavedViewEvents.ViewCreated)).toHaveLength(0);
});
describe('on a changed view', () => {
async function openDirtyView(): Promise<void> {
const { history } = renderAll(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 screen.findByTestId('saved-views-discard');
}
it('does not log an update that fails', async () => {
mockSavedViewsApi([errors], { updateStatus: 500 });
await openDirtyView();
await chooseFromSaveMenu('update');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(callsOf(SavedViewEvents.ViewUpdated)).toHaveLength(0);
});
it('logs an update', async () => {
mockSavedViewsApi([errors]);
await openDirtyView();
await chooseFromSaveMenu('update');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewUpdated, {
source,
viewId: errors.id,
}),
);
});
it('logs a view created with save as new', async () => {
mockSavedViewsApi([errors], { createdId: 'view-copy' });
await openDirtyView();
await chooseFromSaveMenu('save-as-new');
await submitName('Errors copy');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCreated, {
source,
viewId: 'view-copy',
from: SavedViewCreatedFrom.SaveAsNew,
}),
);
});
it('logs discarded changes', async () => {
mockSavedViewsApi([errors]);
await openDirtyView();
logEventMock.mockClear();
await userEvent.click(screen.getByTestId('saved-views-discard'));
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ChangesDiscarded, {
source,
viewId: errors.id,
});
});
});
it('logs a cleared view', async () => {
mockSavedViewsApi([errors]);
renderAll(viewUrl(PATH, errors));
await waitForView('Errors');
await userEvent.click(screen.getByTestId('saved-views-clear'));
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCleared, {
source,
viewId: errors.id,
});
});
it('logs a view cleared from its row', async () => {
mockSavedViewsApi([errors]);
renderAll(viewUrl(PATH, errors));
await waitForView('Errors');
await userEvent.click(await screen.findByTestId('saved-views-row-clear'));
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewCleared, {
source,
viewId: errors.id,
});
});
it('logs only the delete when the open view is deleted', async () => {
mockSavedViewsApi([errors]);
renderAll(viewUrl(PATH, errors));
await waitForView('Errors');
await chooseFromRowMenu('Errors', 'delete');
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.ViewDeleted, {
source,
viewId: errors.id,
}),
);
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('My view'),
);
expect(callsOf(SavedViewEvents.ViewCleared)).toHaveLength(0);
});
it('logs a copied link', async () => {
mockSavedViewsApi([errors]);
renderAll();
await chooseFromRowMenu('Errors', 'copyLink');
expect(logEventMock).toHaveBeenCalledWith(SavedViewEvents.LinkCopied, {
source,
viewId: errors.id,
});
});
it('logs edited view details', async () => {
mockSavedViewsApi([errors]);
renderAll();
await chooseFromRowMenu('Errors', 'editDetails');
await submitName(' renamed');
await waitFor(() =>
expect(logEventMock).toHaveBeenCalledWith(
SavedViewEvents.ViewDetailsEdited,
{ source, viewId: errors.id },
),
);
});
it('does not log edited view details that fail to save', async () => {
mockSavedViewsApi([errors], { updateStatus: 500 });
renderAll();
await chooseFromRowMenu('Errors', 'editDetails');
await submitName(' renamed');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(callsOf(SavedViewEvents.ViewDetailsEdited)).toHaveLength(0);
});
it('does not log a delete that fails', async () => {
mockSavedViewsApi([errors], { deleteStatus: 500 });
renderAll();
await chooseFromRowMenu('Errors', 'delete');
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(callsOf(SavedViewEvents.ViewDeleted)).toHaveLength(0);
});
});

View File

@@ -52,7 +52,6 @@ function renderHeader(
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
onToggleViews={jest.fn()}
/>,
url,
);
@@ -263,46 +262,4 @@ describe('SavedViews - AuthZ', () => {
expect(screen.queryAllByText(message).length).toBeGreaterThan(0),
);
});
describe('view name toggle', () => {
it('toggles the views from the name when list is allowed', async () => {
mockSavedViewsApi([]);
renderHeader(explorerUrl(PATH, {}));
await expect(
screen.findByTestId('saved-views-name-toggle'),
).resolves.toBeInTheDocument();
});
it('leaves the name plain without list, the All views button says why', async () => {
mockSavedViewsApi([]);
server.use(setupAuthzDeny(SavedViewListPermission));
renderHeader(explorerUrl(PATH, {}));
await waitFor(() =>
expect(screen.getByTestId('saved-views-open')).toHaveAttribute(
'aria-disabled',
'true',
),
);
expect(
screen.queryByTestId('saved-views-name-toggle'),
).not.toBeInTheDocument();
});
it('leaves the name plain on a view it cannot read', async () => {
mockSavedViewsApi([errors]);
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
renderHeader(viewUrl(PATH, errors));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
'No view access',
),
);
expect(
screen.queryByTestId('saved-views-name-toggle'),
).not.toBeInTheDocument();
});
});
});

View File

@@ -30,14 +30,11 @@ const PATH = ROUTES.TRACES_EXPLORER;
function renderHeader(
url: string,
isViewsOpen = false,
): ReturnType<typeof renderWithExplorerProviders> {
return renderWithExplorerProviders(
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
isViewsOpen={isViewsOpen}
onToggleViews={jest.fn()}
/>,
url,
);
@@ -48,9 +45,8 @@ const isModalOpen = (): boolean => !!screen.queryByTestId('save-view-modal');
async function openDirtyView(
view: ReturnType<typeof makeView>,
isViewsOpen = false,
): Promise<ReturnType<typeof renderWithExplorerProviders>> {
const rendered = renderHeader(viewUrl(PATH, view), isViewsOpen);
const rendered = renderHeader(viewUrl(PATH, view));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
view.spec.displayName,
@@ -230,36 +226,4 @@ describe('SavedViewsHeader saving', () => {
);
expect(requests.created).toHaveLength(0);
});
describe('view name toggle with unsaved changes', () => {
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
it('stops opening the views from the name once the view changes', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(viewUrl(PATH, errors));
await screen.findByTestId('saved-views-name-toggle');
act(() => {
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
);
});
await screen.findByTestId('saved-views-discard');
expect(
screen.queryByTestId('saved-views-name-toggle'),
).not.toBeInTheDocument();
});
it('still closes open views from the name', async () => {
mockSavedViewsApi([errors]);
await openDirtyView(errors, true);
expect(screen.getByTestId('saved-views-name-toggle')).toBeInTheDocument();
});
});
});

View File

@@ -19,15 +19,11 @@ export function mockSavedViewsApi(
views: SavedviewtypesSavedViewDTO[],
{
createStatus = 201,
updateStatus = 204,
deleteStatus = 204,
createdId = 'view-new',
getDelay = 0,
listFailures = 0,
}: {
createStatus?: number;
updateStatus?: number;
deleteStatus?: number;
createdId?: string;
getDelay?: number;
listFailures?: number;
@@ -89,38 +85,20 @@ export function mockSavedViewsApi(
const body = await req.json();
const id = String(req.params.id);
requests.updated.push({ id, body });
if (updateStatus >= 400) {
return res(
ctx.status(updateStatus),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'boom' },
}),
);
}
const index = views.findIndex((v) => v.id === id);
if (index >= 0) {
views[index] = { ...views[index], spec: body.spec };
}
return res(ctx.status(updateStatus));
return res(ctx.status(204));
}),
rest.delete(`${API}/:id`, (req, res, ctx) => {
const id = String(req.params.id);
requests.deleted.push(id);
if (deleteStatus >= 400) {
return res(
ctx.status(deleteStatus),
ctx.json({
status: 'error',
error: { code: 'internal', message: 'boom' },
}),
);
}
const index = views.findIndex((v) => v.id === id);
if (index >= 0) {
views.splice(index, 1);
}
return res(ctx.status(deleteStatus));
return res(ctx.status(204));
}),
);
return requests;

View File

@@ -1,23 +0,0 @@
export enum SavedViewEvents {
ViewCreated = 'Saved Views: View created',
ViewUpdated = 'Saved Views: View updated',
ViewDetailsEdited = 'Saved Views: View details edited',
ViewDeleted = 'Saved Views: View deleted',
LinkCopied = 'Saved Views: Link copied',
ViewCleared = 'Saved Views: View cleared',
ChangesDiscarded = 'Saved Views: Changes discarded',
}
export enum SavedViewEventKeys {
// Injected on every event by useSavedViewLogEvent
Source = 'source',
ViewId = 'viewId',
From = 'from',
}
export enum SavedViewCreatedFrom {
Header = 'header',
SaveAsNew = 'save_as_new',
EmptyState = 'empty_state',
}

View File

@@ -2,15 +2,14 @@ import { useCallback } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { SAVED_VIEW_TOAST_POSITION } from '../constants';
import { SavedViewCreatedFrom } from '../events';
import { UseSavedViewActionsResult } from '../types';
export function useSaveNewView(
createView: UseSavedViewActionsResult['createView'],
): (displayName: string, from: SavedViewCreatedFrom) => Promise<boolean> {
): (displayName: string) => Promise<boolean> {
return useCallback(
async (displayName: string, from: SavedViewCreatedFrom): Promise<boolean> => {
if (!(await createView(displayName, from))) {
async (displayName: string): Promise<boolean> => {
if (!(await createView(displayName))) {
return false;
}
toast.success('View created', {

View File

@@ -17,7 +17,6 @@ import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import useOptionsMenu from 'container/OptionsMenu/useOptionsMenu';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
@@ -31,18 +30,12 @@ import {
SAVED_VIEW_TOAST_POSITION,
SAVED_VIEW_URL_PARAMS,
} from '../constants';
import {
SavedViewCreatedFrom,
SavedViewEventKeys,
SavedViewEvents,
} from '../events';
import { UseSavedViewActionsResult } from '../types';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { getViewColumnsAndFormatting } from '../utils/getViewColumnsAndFormatting';
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
import { toSavedViewSpec } from '../utils/toSavedViewSpec';
import { useLastUsedView } from './useLastUsedView';
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
// 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
@@ -57,8 +50,6 @@ export function useSavedViewActions(
useQueryBuilder();
const { handleExplorerTabChange } = useHandleExplorerTabChange();
const { setLastUsedView, clearLastUsedView } = useLastUsedView(source);
const { viewKey } = useGetSavedViewParams();
const logSavedViewEvent = useSavedViewLogEvent(source);
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const { options } = useOptionsMenu({
@@ -116,16 +107,13 @@ export function useSavedViewActions(
}
}
selectView(view);
logSavedViewEvent(SavedViewEvents.ChangesDiscarded, {
[SavedViewEventKeys.ViewId]: view.id,
});
},
[optionsDataSource, preferencesSlice, selectView, logSavedViewEvent],
[optionsDataSource, preferencesSlice, 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 resetView = useCallback((): void => {
const clearView = useCallback((): void => {
clearLastUsedView();
const params = getUnstableCurrentSearchParams();
@@ -133,18 +121,8 @@ export function useSavedViewActions(
safeNavigate(`${pathname}?${params.toString()}`);
}, [clearLastUsedView, pathname, safeNavigate]);
const clearView = useCallback((): void => {
resetView();
logSavedViewEvent(SavedViewEvents.ViewCleared, {
[SavedViewEventKeys.ViewId]: viewKey,
});
}, [resetView, logSavedViewEvent, viewKey]);
const createView = useCallback(
async (
displayName: string,
from: SavedViewCreatedFrom,
): Promise<string | undefined> => {
async (displayName: string): Promise<string | undefined> => {
try {
const { data } = await createSavedView({
data: {
@@ -159,10 +137,6 @@ export function useSavedViewActions(
[QueryParams.viewKey]: data.id,
});
setLastUsedView(data.id, displayName);
logSavedViewEvent(SavedViewEvents.ViewCreated, {
[SavedViewEventKeys.ViewId]: data.id,
[SavedViewEventKeys.From]: from,
});
return data.id;
} catch (error) {
toast.error(
@@ -184,7 +158,6 @@ export function useSavedViewActions(
stagedQuery,
currentQuery,
setLastUsedView,
logSavedViewEvent,
],
);
@@ -200,9 +173,6 @@ export function useSavedViewActions(
},
});
await invalidateViews(view.id);
logSavedViewEvent(SavedViewEvents.ViewUpdated, {
[SavedViewEventKeys.ViewId]: view.id,
});
return true;
} catch (error) {
toast.error(
@@ -215,14 +185,13 @@ export function useSavedViewActions(
return false;
}
},
[updateSavedView, source, toCurrentSpec, invalidateViews, logSavedViewEvent],
[updateSavedView, source, toCurrentSpec, invalidateViews],
);
return {
selectView,
revertView,
clearView,
resetView,
createView,
updateView,
isSaving: isCreating || isUpdating,

View File

@@ -1,24 +0,0 @@
import { useCallback } from 'react';
import logEvent from 'api/common/logEvent';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { SavedViewEventKeys, SavedViewEvents } from '../events';
export type SavedViewLogEvent = (
event: SavedViewEvents,
attributes?: Record<string, unknown>,
) => void;
export function useSavedViewLogEvent(
source: SavedviewtypesSourceDTO,
): SavedViewLogEvent {
return useCallback(
(event: SavedViewEvents, attributes: Record<string, unknown> = {}): void => {
void logEvent(event, {
[SavedViewEventKeys.Source]: source,
...attributes,
});
},
[source],
);
}

View File

@@ -22,11 +22,9 @@ import {
SAVED_VIEW_SCHEMA_VERSION,
SAVED_VIEW_TOAST_POSITION,
} from '../constants';
import { SavedViewEventKeys, SavedViewEvents } from '../events';
import { UseSavedViewRowActionsResult } from '../types';
import { getSavedViewQuery } from '../utils/getSavedViewQuery';
import { invalidateSavedViews } from '../utils/invalidateSavedViews';
import { useSavedViewLogEvent } from './useSavedViewLogEvent';
function showErrorToast(error: unknown, fallback: string): void {
toast.error(
@@ -44,7 +42,6 @@ export function useSavedViewRowActions(
const queryClient = useQueryClient();
const { pathname } = useLocation();
const [, copyToClipboard] = useCopyToClipboard();
const logSavedViewEvent = useSavedViewLogEvent(source);
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
useUpdateSavedView();
const { mutateAsync: deleteSavedView, isLoading: isDeleting } =
@@ -58,11 +55,8 @@ export function useSavedViewRowActions(
});
copyToClipboard(getAbsoluteUrl(path));
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.LinkCopied, {
[SavedViewEventKeys.ViewId]: view.id,
});
},
[pathname, copyToClipboard, logSavedViewEvent],
[pathname, copyToClipboard],
);
const copyViewId = useCallback(
@@ -89,16 +83,13 @@ export function useSavedViewRowActions(
});
await invalidateSavedViews(queryClient, source, view.id);
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.ViewDetailsEdited, {
[SavedViewEventKeys.ViewId]: view.id,
});
return true;
} catch (error) {
showErrorToast(error, 'Could not update the view');
return false;
}
},
[updateSavedView, source, queryClient, logSavedViewEvent],
[updateSavedView, source, queryClient],
);
// The deleted view's own query is left alone: refetching it would fail
@@ -109,16 +100,13 @@ export function useSavedViewRowActions(
await deleteSavedView({ pathParams: { id: view.id } });
await invalidateSavedViews(queryClient, source);
toast.success('View deleted', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.ViewDeleted, {
[SavedViewEventKeys.ViewId]: view.id,
});
return true;
} catch (error) {
showErrorToast(error, 'Could not delete the view');
return false;
}
},
[deleteSavedView, source, queryClient, logSavedViewEvent],
[deleteSavedView, source, queryClient],
);
return {

View File

@@ -10,8 +10,6 @@ import { OptionsQuery } from 'container/OptionsMenu/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewCreatedFrom } from './events';
export type SavedViewSourcePage = DataSource | 'meter';
export type SavedViewOptions = Pick<
@@ -53,12 +51,7 @@ export interface UseSavedViewActionsResult {
selectView: (view: SavedviewtypesSavedViewDTO) => void;
revertView: (view: SavedviewtypesSavedViewDTO) => void;
clearView: () => void;
/** Clears the open view without logging it, for flows that are not a clear. */
resetView: () => void;
createView: (
displayName: string,
from: SavedViewCreatedFrom,
) => Promise<string | undefined>;
createView: (displayName: string) => Promise<string | undefined>;
updateView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
isSaving: boolean;
}

View File

@@ -8,7 +8,7 @@ import uPlot, { AlignedData, Options } from 'uplot';
import { usePlotContext } from '../../context/PlotContext';
import { UPlotChartProps } from '../types';
import { prepareAlignedData } from './utils';
import { prepareAlignedData, setInstanceHandle } from './utils';
/**
* Check if dimensions have changed
@@ -58,6 +58,7 @@ export default function UPlotChart({
onDestroy?.(plotInstanceRef.current);
plotInstanceRef.current.destroy();
plotInstanceRef.current = null;
setInstanceHandle(containerRef.current, null);
setPlotContextInitialState({ uPlotInstance: null });
plotRef?.(null);
}
@@ -101,6 +102,7 @@ export default function UPlotChart({
});
plotInstanceRef.current = plot;
setInstanceHandle(containerRef.current, plot);
}, [
config,
data,

View File

@@ -1,6 +1,6 @@
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { applySpanGapsToAlignedData } from 'lib/uPlotV2/utils/dataUtils';
import { AlignedData } from 'uplot';
import uPlot, { AlignedData } from 'uplot';
export function prepareAlignedData({
data,
@@ -14,3 +14,22 @@ export function prepareAlignedData({
? applySpanGapsToAlignedData(data as AlignedData, seriesSpanGaps)
: (data as AlignedData);
}
/** A chart container carrying its live uPlot instance, for tests and debugging. */
export interface UPlotHostElement extends HTMLDivElement {
__uplot?: uPlot;
}
export function setInstanceHandle(
node: HTMLDivElement | null,
plot: uPlot | null,
): void {
if (!node) {
return;
}
if (plot) {
(node as UPlotHostElement).__uplot = plot;
} else {
delete (node as UPlotHostElement).__uplot;
}
}

View File

@@ -87,7 +87,7 @@ function AddColumnDropdown({
value={field.name}
isSelected={selectedNames.has(field.name)}
onSelect={(): void => onToggle(field)}
data-testid="list-columns-suggestion"
data-testid={`list-columns-suggestion-${field.name}`}
>
{field.name}
</ComboboxItem>

View File

@@ -37,7 +37,12 @@ function SortableColumnChip({
};
return (
<div ref={setNodeRef} style={style} className={styles.chip}>
<div
ref={setNodeRef}
style={style}
className={styles.chip}
data-testid={`list-column-chip-${name}`}
>
<span {...listeners} className={styles.grip}>
<Button
type="button"
@@ -61,7 +66,7 @@ function SortableColumnChip({
size="sm"
icon
aria-label={`Remove ${name}`}
testId="list-column-remove"
testId={`list-column-remove-${name}`}
onClick={(): void => onRemove(name)}
>
<X size={12} />

View File

@@ -88,6 +88,7 @@ function PreviewPane(props: PreviewPaneProps): JSX.Element {
return (
<div
className={cx(styles.preview, { [styles.previewStatic]: !!staticDraft })}
data-testid="preview-pane"
>
{query && !query.hideHeader && (
<div className={styles.header}>

View File

@@ -16,6 +16,8 @@ export interface ResizableHeaderProps extends Omit<
> {
width?: number;
onResize?: (e: SyntheticEvent<Element>, data: ResizeCallbackData) => void;
/** Column key, used only to give the drag grip a stable test handle. */
columnKey?: string;
}
/**
@@ -27,6 +29,7 @@ export interface ResizableHeaderProps extends Omit<
function ResizableHeader({
width,
onResize,
columnKey,
...restProps
}: ResizableHeaderProps): JSX.Element {
const handle = useMemo(
@@ -34,13 +37,14 @@ function ResizableHeader({
<span
className={styles.handle}
role="presentation"
data-testid={columnKey ? `column-resize-${columnKey}` : undefined}
// Stop the grip's click from reaching the column sorter underneath.
// The grip is a pointer-only resize affordance, not keyboard-actionable.
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
onClick={(e): void => e.stopPropagation()}
/>
),
[],
[columnKey],
);
if (!width || !onResize) {
@@ -64,6 +68,7 @@ function ResizableHeader({
ResizableHeader.defaultProps = {
width: undefined,
onResize: undefined,
columnKey: undefined,
};
export default ResizableHeader;

View File

@@ -132,6 +132,7 @@ export function useResizableColumns<T>({
onHeaderCell: (): ResizableHeaderProps => ({
width,
onResize: key && width ? handleResize(key) : undefined,
columnKey: key,
}),
} as Column<T>;
}),

View File

@@ -104,7 +104,9 @@ function PanelHeader(props: PanelHeaderProps): JSX.Element {
return (
<div className={cx(styles.header, 'panel-drag-handle')}>
<div className={styles.headerLeft}>
<Typography.Text className={styles.headerTitle}>{name}</Typography.Text>
<Typography.Text className={styles.headerTitle} data-testid="panel-title">
{name}
</Typography.Text>
{description && (
<Tooltip title={description}>
<Info

View File

@@ -99,7 +99,7 @@ export const ListPanelAddColumnOpen: Story = {
timeout: 10_000,
}),
);
await screen.findAllByTestId('list-columns-suggestion');
await screen.findAllByTestId(/^list-columns-suggestion-/);
},
};

View File

@@ -25,6 +25,9 @@
.ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box
) {
@include custom-scrollbar;
max-height: 150px;
overflow-y: auto;
padding: var(--spacing-4) !important;
}
}

View File

@@ -5,6 +5,7 @@ import { Collapse } from 'antd';
import { AIThreadMessageProps } from '../../AIThreadMessage/AIThreadMessage';
import { ThreadMessage } from '../../AIThreadMessage/types';
import MessagesSection from '../../SpanMessages/MessagesSection';
import { RawMessagesValue } from '../../utils/spanMessages';
import { ThreadView } from '../../TraceDetailsThread/types';
import ThreadViewToggle from '../../TraceDetailsThread/ThreadViewToggle';
@@ -19,6 +20,7 @@ interface MessagesAccordionProps extends Pick<
icon: ReactNode;
title: string;
messages: ThreadMessage[];
raw?: RawMessagesValue;
testId: string;
}
@@ -26,6 +28,7 @@ function MessagesAccordion({
icon,
title,
messages,
raw,
testId,
messageType,
canLinkToToolCall,
@@ -33,8 +36,10 @@ function MessagesAccordion({
}: MessagesAccordionProps): JSX.Element | null {
const [isOpen, setIsOpen] = useState(false);
const [view, setView] = useState(ThreadView.Formatted);
const canFormat = messages.length > 0;
const effectiveView = canFormat ? view : ThreadView.Json;
if (messages.length === 0) {
if (!canFormat && !raw) {
return null;
}
@@ -61,12 +66,12 @@ function MessagesAccordion({
{title}
</span>
),
extra: isOpen && (
extra: isOpen && canFormat && (
// The view toggle lives in the header; its clicks must not fold the panel.
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<span onClick={stopToggle}>
<ThreadViewToggle
value={view}
value={effectiveView}
onChange={setView}
testId={`${testId}-view-toggle`}
/>
@@ -74,8 +79,9 @@ function MessagesAccordion({
),
children: (
<MessagesSection
view={view}
view={effectiveView}
messages={messages}
raw={raw}
testId={`${testId}-body`}
messageType={messageType}
canLinkToToolCall={canLinkToToolCall}
@@ -89,4 +95,8 @@ function MessagesAccordion({
);
}
MessagesAccordion.defaultProps = {
raw: undefined,
};
export default MessagesAccordion;

View File

@@ -3,10 +3,7 @@ import { ArrowDownRight, ArrowUpRight } from '@signozhq/icons';
import { MessageType } from '../../AIThreadMessage/types';
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
import {
getSpanMessages,
hasFormattedMessages,
} from '../../utils/spanMessages';
import { getSpanMessages, hasAnyMessages } from '../../utils/spanMessages';
import { ThreadSpan } from '../../TraceDetailsThread/types';
import { buildToolCallIndex } from '../../TraceDetailsThread/utils';
import MessagesAccordion from './MessagesAccordion';
@@ -28,7 +25,7 @@ function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
toolCallKeys,
);
if (!hasFormattedMessages(data)) {
if (!hasAnyMessages(data)) {
return null;
}
@@ -38,6 +35,7 @@ function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
icon={<ArrowUpRight size={14} />}
title="Input"
messages={data.formattedInput}
raw={data.rawInput}
messageType={MessageType.Input}
testId="span-ai-messages-input"
canLinkToToolCall={canLinkToToolCall}
@@ -47,6 +45,7 @@ function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
icon={<ArrowDownRight size={14} />}
title="Output"
messages={data.formattedOutput}
raw={data.rawOutput}
messageType={MessageType.Output}
testId="span-ai-messages-output"
canLinkToToolCall={canLinkToToolCall}

View File

@@ -47,7 +47,6 @@
border-radius: 50%;
background: var(--accent-forest);
flex-shrink: 0;
margin-right: 4px;
}
// Truncating text inside a badge (service name, status message).

View File

@@ -3,7 +3,8 @@ import AIThreadMessage, {
} from '../AIThreadMessage/AIThreadMessage';
import { ThreadMessage } from '../AIThreadMessage/types';
import { ThreadView } from '../TraceDetailsThread/types';
import JsonBlock from '../AIThreadMessage/JsonBlock';
import RawMessages from './RawMessages';
import { RawMessagesValue } from '../utils/spanMessages';
import styles from './SpanMessages.module.scss';
@@ -16,6 +17,7 @@ interface MessagesSectionProps extends MessageProps {
title?: string;
view: ThreadView;
messages: ThreadMessage[];
raw?: RawMessagesValue;
testId: string;
}
@@ -23,19 +25,21 @@ function MessagesSection({
title,
view,
messages,
raw,
testId,
messageType,
canLinkToToolCall,
onToolLinkClick,
}: MessagesSectionProps): JSX.Element | null {
if (messages.length === 0) {
const isFormatted = view === ThreadView.Formatted;
if (isFormatted ? messages.length === 0 : !raw) {
return null;
}
return (
<section className={styles.section} data-testid={testId}>
{title && <div className={styles.sectionTitle}>{title}</div>}
{view === ThreadView.Formatted ? (
{isFormatted ? (
<div className={styles.messages}>
{messages.map((message, index) => (
<AIThreadMessage
@@ -50,7 +54,7 @@ function MessagesSection({
))}
</div>
) : (
<JsonBlock data={messages} />
<RawMessages value={raw as RawMessagesValue} />
)}
</section>
);
@@ -58,6 +62,7 @@ function MessagesSection({
MessagesSection.defaultProps = {
title: undefined,
raw: undefined,
};
export default MessagesSection;

View File

@@ -0,0 +1,21 @@
import JsonBlock from '../AIThreadMessage/JsonBlock';
import { RawMessagesValue } from '../utils/spanMessages';
import styles from './SpanMessages.module.scss';
interface RawMessagesProps {
value: RawMessagesValue;
}
function RawMessages({ value }: RawMessagesProps): JSX.Element {
if (typeof value === 'object') {
return <JsonBlock data={value} />;
}
return (
<span className={styles.rawUnavailable} data-testid="span-raw-messages">
Unable to render messages
</span>
);
}
export default RawMessages;

View File

@@ -28,6 +28,12 @@
min-width: 0;
}
.rawUnavailable {
font-size: var(--periscope-font-size-base);
font-style: italic;
color: var(--l2-foreground);
}
.missing {
padding: var(--spacing-10) var(--spacing-6);
border-radius: var(--radius-2);

View File

@@ -6,7 +6,11 @@ import { ThreadSpan, ThreadView } from '../TraceDetailsThread/types';
import JsonBlock from '../AIThreadMessage/JsonBlock';
import { MessageType } from '../AIThreadMessage/types';
import MessagesSection from './MessagesSection';
import { getSpanMessages, hasFormattedMessages } from '../utils/spanMessages';
import {
getSpanMessages,
hasFormattedMessages,
hasRawMessages,
} from '../utils/spanMessages';
import styles from './SpanMessages.module.scss';
@@ -28,8 +32,9 @@ function SpanMessages({
onToolLinkClick,
}: SpanMessagesProps): JSX.Element {
const data = useMemo(() => getSpanMessages(span), [span]);
const isFormatted = view === ThreadView.Formatted;
if (!hasFormattedMessages(data)) {
if (isFormatted ? !hasFormattedMessages(data) : !hasRawMessages(data)) {
return (
<div
className={cx(styles.missing, className)}
@@ -40,11 +45,11 @@ function SpanMessages({
);
}
if (view === ThreadView.Json) {
if (!isFormatted) {
return (
<div className={cx(styles.root, className)} data-testid="span-messages-json">
<JsonBlock
data={{ input: data.formattedInput, output: data.formattedOutput }}
data={{ input: data.rawInput ?? [], output: data.rawOutput ?? [] }}
/>
</div>
);
@@ -55,6 +60,7 @@ function SpanMessages({
<MessagesSection
view={view}
messages={data.formattedInput}
raw={data.rawInput}
messageType={MessageType.Input}
testId="span-messages-input"
canLinkToToolCall={canLinkToToolCall}
@@ -63,6 +69,7 @@ function SpanMessages({
<MessagesSection
view={view}
messages={data.formattedOutput}
raw={data.rawOutput}
messageType={MessageType.Output}
testId="span-messages-output"
canLinkToToolCall={canLinkToToolCall}

View File

@@ -48,36 +48,41 @@ describe('SpanMessages', () => {
).toBeInTheDocument();
});
it('shows the empty state in JSON view when there are no formatted messages', () => {
const span = makeSpan({
attributes: { [GEN_AI_KEYS.inputMessages]: '[{"role":"user"}]' },
});
render(<SpanMessages span={span} view={ThreadView.Json} />);
it('shows the empty state in JSON view when there are no raw messages', () => {
render(<SpanMessages span={makeSpan({})} view={ThreadView.Json} />);
expect(screen.getByTestId('span-messages-empty')).toBeInTheDocument();
});
it('renders formatted messages in JSON view, ignoring the raw attribute', () => {
it('renders raw messages in JSON view', () => {
const span = makeSpan({
formattedInput: [
{ role: 'user', parts: [{ type: 'text', content: 'formatted-text' }] },
],
attributes: {
[GEN_AI_KEYS.inputMessages]: '[{"role":"user","content":"raw-text"}]',
},
attributes: { [GEN_AI_KEYS.outputMessages]: '[{"role":"assistant"}]' },
});
render(<SpanMessages span={span} view={ThreadView.Json} />);
expect(screen.getByTestId('span-messages-json')).toBeInTheDocument();
expect(screen.getByText(/formatted-text/)).toBeInTheDocument();
expect(screen.queryByText(/raw-text/)).not.toBeInTheDocument();
});
it('opens every key in JSON view', () => {
const span = makeSpan({
attributes: {
[GEN_AI_KEYS.inputMessages]: '[{"role":"user","content":"visible-text"}]',
},
});
render(<SpanMessages span={span} view={ThreadView.Json} />);
expect(screen.getByText(/visible-text/)).toBeInTheDocument();
});
it('collapses tall JSON behind Show more in JSON view', () => {
const span = makeSpan({
formattedInput: [{ role: 'user', parts: [{ type: 'text', content: 'hi' }] }],
attributes: {
[GEN_AI_KEYS.inputMessages]: JSON.stringify([
{ role: 'user', content: 'hi' },
]),
},
});
render(<SpanMessages span={span} view={ThreadView.Json} />);

View File

@@ -99,12 +99,10 @@ function TraceDetailsHeader({
const handlePreviousBtnClick = useCallback((): void => {
if (hasInAppHistory()) {
history.goBack();
} else if (!isOverview) {
history.push(ROUTES.AI_OBSERVABILITY_EXPLORER);
} else {
history.push(ROUTES.TRACES_EXPLORER);
}
}, [isOverview]);
}, []);
const handleToggleTraceDetails = useCallback((): void => {
setShowTraceDetails((prev) => !prev);

View File

@@ -22,8 +22,7 @@ function TraceDetailsTabs(): JSX.Element {
const [tab, setTab] = useTraceDetailsTab();
const handleChange = (value: string): void => {
// Pushed so the thread's "Go back" returns to the previous tab, not the previous page.
void setTab(value as TraceDetailsTab, { history: 'push' });
void setTab(value as TraceDetailsTab);
};
return (

View File

@@ -1,8 +1,10 @@
import { fireEvent, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import type { SpantypesGettableTraceSummaryDTO } from 'api/generated/services/sigNoz.schemas';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import ROUTES from 'constants/routes';
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
import { render } from 'tests/test-utils';
import TraceDetailsHeader from '../TraceDetailsHeader';
import { useTraceSummary } from '../useTraceSummary';
@@ -98,22 +100,6 @@ describe('TraceDetailsHeader – back button', () => {
expect(mockPush).toHaveBeenCalledWith(ROUTES.TRACES_EXPLORER);
expect(mockGoBack).not.toHaveBeenCalled();
});
it('pushes to the AI observability explorer when opened directly on another tab', async () => {
mockHasInAppHistory.mockReturnValue(false);
const user = userEvent.setup({ delay: null });
render(
<NuqsTestingAdapter searchParams="?tab=thread">
<TraceDetailsHeader {...baseProps} />
</NuqsTestingAdapter>,
);
await user.click(screen.getByRole('button', { name: /back/i }));
expect(mockPush).toHaveBeenCalledTimes(1);
expect(mockPush).toHaveBeenCalledWith(ROUTES.AI_OBSERVABILITY_EXPLORER);
expect(mockGoBack).not.toHaveBeenCalled();
});
});
describe('TraceDetailsHeader – action cluster', () => {

View File

@@ -1,31 +1,29 @@
.root {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
align-items: flex-start;
gap: var(--spacing-12);
padding: var(--spacing-20) var(--spacing-24);
}
.message {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-16);
text-align: center;
max-width: 428px;
}
.noDataImg {
height: 32px;
width: 32px;
.emoji {
font-size: 32px;
line-height: 1;
}
.title {
font-size: var(--periscope-font-size-medium);
font-weight: var(--font-weight-medium);
.paragraphs {
display: flex;
flex-direction: column;
gap: var(--paragraph-base-500-line-height);
}
.highlight {
color: var(--l1-foreground);
}
.description {
max-width: 420px;
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
}
.actions {
margin-top: var(--spacing-4);
}

View File

@@ -1,9 +1,8 @@
import { ArrowRight } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import { openInNewTab } from 'utils/navigation';
import noDataUrl from '@/assets/Icons/no-data.svg';
import { LLM_OBSERVABILITY_DOCS_URL } from '../constants';
import styles from './ThreadNoAiSpans.module.scss';
@@ -11,28 +10,32 @@ import styles from './ThreadNoAiSpans.module.scss';
function ThreadNoAiSpans(): JSX.Element {
return (
<div className={styles.root} data-testid="thread-empty">
<img src={noDataUrl} alt="" className={styles.noDataImg} />
<span className={styles.title}>
This trace has no spans from AI-related actions
</span>
<span className={styles.description}>
Thread view summarising your entire AI-journey history will appear here when
the spans have the necessary datapoints.
</span>
<div className={styles.actions}>
<Button
variant="solid"
color="secondary"
size="md"
suffix={<ArrowRight size={12} />}
onClick={(): void => {
openInNewTab(LLM_OBSERVABILITY_DOCS_URL);
}}
testId="thread-empty-learn-more"
>
Learn more
</Button>
<div className={styles.message}>
<span className={styles.emoji} aria-hidden>
🚏
</span>
<div className={styles.paragraphs}>
<Typography size="base" weight="medium" color="muted">
Looks like this trace does not have any spans from AI-related actions.
</Typography>
<Typography size="base" weight="medium" className={styles.highlight}>
Thread view summarising your entire AI-journey history will appear here
when the spans have the necessary datapoints.
</Typography>
</div>
</div>
<Button
variant="solid"
color="secondary"
size="md"
suffix={<ArrowRight size={12} />}
onClick={(): void => {
openInNewTab(LLM_OBSERVABILITY_DOCS_URL);
}}
testId="thread-empty-learn-more"
>
Learn more
</Button>
</div>
);
}

View File

@@ -40,7 +40,6 @@ function TraceDetailsThread(): JSX.Element {
spansAboveFirstPage,
isLoading,
isError,
isNotFound,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
@@ -53,7 +52,6 @@ function TraceDetailsThread(): JSX.Element {
interestedSpanId,
spans,
isLoading,
isNotFound,
);
const interestedSpanIndex = spans.findIndex(
(span) => span.span_id === interestedSpanId,

View File

@@ -1,7 +1,7 @@
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import { getTraceThread } from 'api/generated/services/tracedetail';
import { act, renderHook, waitFor } from 'tests/test-utils';
import { useTraceThread } from '../useTraceThread';
@@ -154,7 +154,6 @@ describe('useTraceThread', () => {
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockGetTraceThread).toHaveBeenCalledTimes(1);
expect(result.current.isNotFound).toBe(true);
});
it('retries other errors', async () => {
@@ -163,6 +162,5 @@ describe('useTraceThread', () => {
await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockGetTraceThread).toHaveBeenCalledTimes(4);
expect(result.current.isNotFound).toBe(false);
});
});

View File

@@ -25,8 +25,6 @@ interface UseTraceThreadResult {
spansAboveFirstPage: number;
isLoading: boolean;
isError: boolean;
/** The interested span (or trace) doesn't exist; an empty thread is not this. */
isNotFound: boolean;
hasNextPage: boolean;
hasPreviousPage: boolean;
isFetchingNextPage: boolean;
@@ -101,7 +99,6 @@ export function useTraceThread({
spansAboveFirstPage,
isLoading: query.isLoading,
isError: query.isError,
isNotFound: isNotFoundError(query.error),
hasNextPage: !!query.hasNextPage,
hasPreviousPage: !!query.hasPreviousPage,
isFetchingNextPage: query.isFetchingNextPage,

View File

@@ -28,7 +28,6 @@ export function getInterestedSpanStatus(
interestedSpanId: string | undefined,
spans: ThreadSpan[],
isLoading: boolean,
isNotFound: boolean,
): InterestedSpanStatus {
if (!interestedSpanId) {
return InterestedSpanStatus.None;
@@ -36,12 +35,8 @@ export function getInterestedSpanStatus(
if (isLoading) {
return InterestedSpanStatus.Loading;
}
if (isNotFound) {
return InterestedSpanStatus.NotFound;
}
// The span exists but the thread is empty, so the no-AI-spans state applies.
if (spans.length === 0) {
return InterestedSpanStatus.None;
return InterestedSpanStatus.NotFound;
}
return spans.some((span) => span.span_id === interestedSpanId)
? InterestedSpanStatus.Found

View File

@@ -60,4 +60,4 @@ export enum TraceDetailsTab {
}
export const LLM_OBSERVABILITY_DOCS_URL =
'https://signoz.io/docs/ai-observability/';
'https://signoz.io/docs/llm-observability/';

View File

@@ -1,19 +1,49 @@
import { parseJsonObject } from '../AIThreadMessage/utils';
import { ThreadMessage } from '../AIThreadMessage/types';
import { ThreadSpan } from '../TraceDetailsThread/types';
import { GEN_AI_KEYS } from './aiTraceUtils';
/** Parsed JSON, or the original string when it isn't JSON. */
export type RawMessagesValue = object | string;
export interface SpanMessagesData {
formattedInput: ThreadMessage[];
formattedOutput: ThreadMessage[];
rawInput?: RawMessagesValue;
rawOutput?: RawMessagesValue;
}
function toRawValue(value: unknown): RawMessagesValue | undefined {
if (value === undefined || value === null || value === '') {
return undefined;
}
if (typeof value === 'string') {
return parseJsonObject(value) ?? value;
}
return typeof value === 'object' ? value : JSON.stringify(value);
}
export function getSpanMessages(span: ThreadSpan): SpanMessagesData {
const rawInput = toRawValue(span.attributes?.[GEN_AI_KEYS.inputMessages]);
const rawOutput = toRawValue(span.attributes?.[GEN_AI_KEYS.outputMessages]);
// Spans from before the JSON column have no formatted messages.
return {
formattedInput: span.formattedInput ?? [],
formattedOutput: span.formattedOutput ?? [],
rawInput,
rawOutput,
};
}
export function hasFormattedMessages(data: SpanMessagesData): boolean {
return data.formattedInput.length > 0 || data.formattedOutput.length > 0;
}
export function hasRawMessages(data: SpanMessagesData): boolean {
return !!data.rawInput || !!data.rawOutput;
}
export function hasAnyMessages(data: SpanMessagesData): boolean {
return hasFormattedMessages(data) || hasRawMessages(data);
}

View File

@@ -140,7 +140,6 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
Description: "This endpoint creates a notification channel",
Request: new(alertmanagertypes.PostableNotificationChannel),
RequestContentType: "application/json",
RequestExamples: createNotificationChannelExamples(),
Response: new(alertmanagertypes.GettableNotificationChannel),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
@@ -225,7 +224,6 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
Description: "This endpoint replaces a notification channel's configuration in full. Neither name is part of the request body: both are immutable. The kind may change, which replaces the channel's notifier configuration.",
Request: new(alertmanagertypes.UpdatableNotificationChannel),
RequestContentType: "application/json",
RequestExamples: updateNotificationChannelExamples(),
Response: new(alertmanagertypes.GettableNotificationChannel),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
@@ -311,7 +309,6 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
Description: "This endpoint sends a test notification for the configuration in the request body. The channel need not exist and nothing is persisted, so the body carries a configuration only.",
Request: new(alertmanagertypes.TestableNotificationChannel),
RequestContentType: "application/json",
RequestExamples: testNotificationChannelExamples(),
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,

View File

@@ -1,201 +0,0 @@
package signozapiserver
import "github.com/SigNoz/signoz/pkg/http/handler"
// channelConfig wraps one provider spec in the {kind, spec} envelope every
// channel write carries.
func channelConfig(kind string, spec map[string]any) map[string]any {
return map[string]any{"kind": kind, "spec": spec}
}
func slackChannelSpec() map[string]any {
return map[string]any{
"apiUrl": "https://hooks.slack.com/services/T000/B000/XXXXXXXX",
"channel": "#alerts-platform",
"sendResolved": true,
}
}
func pagerdutyChannelSpec() map[string]any {
return map[string]any{
"routingKey": "R0000000000000000000000000000000",
"severity": "critical",
}
}
func webhookChannelSpec() map[string]any {
return map[string]any{
"url": "https://alerts.example.com/signoz",
"bearerToken": "00000000-0000-0000-0000-000000000000",
}
}
// createNotificationChannelExamples returns one create payload per channel
// kind. Each carries the kind's required fields plus one or two optional ones
// that show a default being overridden.
func createNotificationChannelExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "slack",
Summary: "Slack incoming webhook",
Description: "Posts to a Slack channel through an incoming webhook. `sendResolved` is off by default for Slack, so it is turned on here.",
Value: map[string]any{
"name": "slack-platform",
"displayName": "Slack platform",
"config": channelConfig("slack", slackChannelSpec()),
},
},
{
Name: "email",
Summary: "Email recipients",
Description: "Sends to a comma-separated recipient list. SMTP transport comes from the deployment's global config, so only recipients and body are configurable.",
Value: map[string]any{
"name": "email-oncall",
"displayName": "Email on-call",
"config": channelConfig("email", map[string]any{
"to": "oncall@example.com, platform@example.com",
"headers": map[string]any{"Subject": "[SigNoz] {{ .CommonLabels.alertname }}"},
}),
},
},
{
Name: "webhook_bearer",
Summary: "Webhook with a bearer token",
Description: "Delivers the Alertmanager webhook payload to an HTTP endpoint. Use either `bearerToken` or `username`/`password`, never both.",
Value: map[string]any{
"name": "webhook-incident-bot",
"displayName": "Incident bot webhook",
"config": channelConfig("webhook", webhookChannelSpec()),
},
},
{
Name: "pagerduty",
Summary: "PagerDuty Events API v2",
Description: "Creates PagerDuty incidents through an Events API v2 integration key.",
Value: map[string]any{
"name": "pagerduty-oncall",
"displayName": "PagerDuty on-call",
"config": channelConfig("pagerduty", pagerdutyChannelSpec()),
},
},
{
Name: "opsgenie",
Summary: "Opsgenie API integration",
Description: "Creates Opsgenie alerts through an API integration key, pinned to priority P2.",
Value: map[string]any{
"name": "opsgenie-sre",
"displayName": "Opsgenie SRE",
"config": channelConfig("opsgenie", map[string]any{
"apiKey": "00000000-0000-0000-0000-000000000000",
"priority": "P2",
}),
},
},
{
Name: "msteams",
Summary: "Microsoft Teams incoming webhook",
Description: "Posts an adaptive card to a Teams channel through an incoming webhook.",
Value: map[string]any{
"name": "msteams-platform",
"displayName": "Teams platform",
"config": channelConfig("msteams", map[string]any{
"webhookUrl": "https://example.webhook.office.com/webhookb2/00000000-0000-0000-0000-000000000000",
}),
},
},
{
Name: "googlechat",
Summary: "Google Chat space webhook",
Description: "Posts to a Google Chat space through its webhook URL.",
Value: map[string]any{
"name": "googlechat-platform",
"displayName": "Google Chat platform",
"config": channelConfig("googlechat", map[string]any{
"webhookUrl": "https://chat.googleapis.com/v1/spaces/AAAA0000000/messages?key=XXXX&token=XXXX",
}),
},
},
{
Name: "jira",
Summary: "Jira Cloud issues",
Description: "Creates one Jira Cloud issue per alert group and reopens it if the alert fires again within `reopenDuration`. Authenticates with an Atlassian account email and API token.",
Value: map[string]any{
"name": "jira-platform",
"displayName": "Jira platform",
"config": channelConfig("jira", map[string]any{
"site": "https://example.atlassian.net",
"project": "OPS",
"issueType": "Bug",
"email": "oncall@example.com",
"apiToken": "XXXXXXXXXXXXXXXXXXXXXXXX",
"labels": []any{"signoz", "alert"},
"reopenDuration": "3d",
}),
},
},
{
Name: "jsmops",
Summary: "Jira Service Management Ops",
Description: "Creates JSM Ops alerts through an integration API key. JSM Ops speaks the Opsgenie alert API, so `priority` takes P1 to P5.",
Value: map[string]any{
"name": "jsmops-oncall",
"displayName": "JSM Ops on-call",
"config": channelConfig("jsmops", map[string]any{
"apiKey": "00000000-0000-0000-0000-000000000000",
"priority": "P1",
"tags": "signoz,platform",
}),
},
},
{
Name: "incidentio",
Summary: "incident.io HTTP alert source",
Description: "Sends alert events to an incident.io HTTP alert source. `url` and `token` are copied from the source's setup page; the token goes without the Bearer prefix.",
Value: map[string]any{
"name": "incidentio-platform",
"displayName": "incident.io platform",
"config": channelConfig("incidentio", map[string]any{
"url": "https://api.incident.io/v2/alert_events/http/00000000-0000-0000-0000-000000000000",
"token": "XXXXXXXXXXXXXXXXXXXXXXXX",
"metadata": map[string]any{"team": "platform"},
}),
},
},
}
}
// updateNotificationChannelExamples shows that an update carries only config:
// name and displayName are immutable, and the kind may change.
func updateNotificationChannelExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "slack_rotate_webhook",
Summary: "Replace a Slack webhook URL",
Description: "Replaces the stored configuration in full; every field not sent falls back to its default.",
Value: map[string]any{"config": channelConfig("slack", slackChannelSpec())},
},
{
Name: "switch_to_pagerduty",
Summary: "Change the kind to PagerDuty",
Description: "The kind may change on update, replacing the channel's notifier configuration while its name and references stay.",
Value: map[string]any{"config": channelConfig("pagerduty", pagerdutyChannelSpec())},
},
}
}
// testNotificationChannelExamples sends one test notification; the body is a
// configuration only because nothing is persisted.
func testNotificationChannelExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "slack",
Summary: "Test a Slack webhook",
Value: map[string]any{"config": channelConfig("slack", slackChannelSpec())},
},
{
Name: "webhook",
Summary: "Test a webhook endpoint",
Value: map[string]any{"config": channelConfig("webhook", webhookChannelSpec())},
},
}
}

View File

@@ -1,39 +0,0 @@
package signozapiserver
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/stretchr/testify/require"
)
func TestNotificationChannelExamplesAreValidPayloads(t *testing.T) {
for _, ex := range createNotificationChannelExamples() {
t.Run("create/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var postable alertmanagertypes.PostableNotificationChannel
require.NoError(t, json.Unmarshal(raw, &postable))
require.NoError(t, postable.Validate())
})
}
for _, ex := range updateNotificationChannelExamples() {
t.Run("update/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var updatable alertmanagertypes.UpdatableNotificationChannel
require.NoError(t, json.Unmarshal(raw, &updatable))
require.NoError(t, updatable.Validate())
})
}
for _, ex := range testNotificationChannelExamples() {
t.Run("test/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var testable alertmanagertypes.TestableNotificationChannel
require.NoError(t, json.Unmarshal(raw, &testable))
require.NoError(t, testable.Validate())
})
}
}

View File

@@ -77,7 +77,6 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
Description: "This endpoint creates a dashboard in the v2 format that follows Perses spec.",
Request: new(dashboardtypes.PostableDashboardV2),
RequestContentType: "application/json",
RequestExamples: createDashboardExamples(),
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
@@ -201,7 +200,6 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
Description: "This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.",
Request: new(dashboardtypes.UpdatableDashboardV2),
RequestContentType: "application/json",
RequestExamples: updateDashboardExamples(),
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
@@ -235,7 +233,6 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
// the array-of-ops schema visible to spec consumers; the runtime decoder
// parses JSON regardless of the request's actual Content-Type header.
RequestContentType: "application/json",
RequestExamples: patchDashboardExamples(),
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,

View File

@@ -1,268 +0,0 @@
package signozapiserver
import "github.com/SigNoz/signoz/pkg/http/handler"
// gridItem places a panel on the 12-column grid of a Grid layout.
func gridItem(panelID string, x, y, width, height int) map[string]any {
return map[string]any{
"x": x, "y": y, "width": width, "height": height,
"content": map[string]any{"$ref": "#/spec/panels/" + panelID},
}
}
func gridLayout(items ...map[string]any) map[string]any {
list := make([]any, 0, len(items))
for _, item := range items {
list = append(list, item)
}
return map[string]any{"kind": "Grid", "spec": map[string]any{"items": list}}
}
// builderQuery wraps a builder query spec in the panel query envelope.
// requestType is the result shape the panel renders: time_series, scalar or raw.
func builderQuery(requestType string, spec map[string]any) map[string]any {
return map[string]any{
"kind": requestType,
"spec": map[string]any{
"name": spec["name"],
"plugin": map[string]any{"kind": "signoz/BuilderQuery", "spec": spec},
},
}
}
func panel(name, description string, plugin map[string]any, queries ...map[string]any) map[string]any {
list := make([]any, 0, len(queries))
for _, q := range queries {
list = append(list, q)
}
return map[string]any{
"kind": "Panel",
"spec": map[string]any{
"display": map[string]any{"name": name, "description": description},
"links": []any{},
"plugin": plugin,
"queries": list,
},
}
}
func serviceNameGroupBy() []any {
return []any{map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"}}
}
// createDashboardExamples returns example payloads for the dashboard create
// endpoint. They cover a variable-driven service overview and a logs dashboard
// that pairs a query-less text panel with a table.
func createDashboardExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "service_overview",
Summary: "Service overview with a variable, a time series panel and a number panel",
Description: "A `$service` list variable fed by the observed `service.name` values filters both panels. The time series panel counts spans per service; the number panel renders a single scalar and turns red above 100 errors. `generateName` lets the server derive the name from the display name.",
Value: map[string]any{
"schemaVersion": "v6",
"generateName": true,
"tags": []any{map[string]any{"key": "team", "value": "platform"}},
"spec": map[string]any{
"display": map[string]any{"name": "Service Overview", "description": "Request rate and errors per service"},
"variables": []any{
map[string]any{
"kind": "ListVariable",
"spec": map[string]any{
"name": "service",
"display": map[string]any{"name": "Service"},
"allowMultiple": true,
"allowAllValue": true,
"sort": "alphabetical-asc",
"plugin": map[string]any{
"kind": "signoz/DynamicVariable",
"spec": map[string]any{"name": "service.name", "signal": "traces"},
},
},
},
},
"panels": map[string]any{
"request-rate": panel("Requests", "Spans per service",
map[string]any{
"kind": "signoz/TimeSeriesPanel",
"spec": map[string]any{
"formatting": map[string]any{"decimalPrecision": "0"},
"legend": map[string]any{"position": "bottom"},
},
},
builderQuery("time_series", map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{map[string]any{"expression": "count()", "alias": "requests"}},
"filter": map[string]any{"expression": "service.name IN $service"},
"groupBy": serviceNameGroupBy(),
"legend": "{{service.name}}",
}),
),
"error-count": panel("Errors", "Spans with an error status",
map[string]any{
"kind": "signoz/NumberPanel",
"spec": map[string]any{
"thresholds": []any{
map[string]any{"value": 100, "operator": "above", "color": "#F1575F", "format": "background"},
},
},
},
builderQuery("scalar", map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{map[string]any{"expression": "count()", "alias": "errors"}},
"filter": map[string]any{"expression": "has_error = true AND service.name IN $service"},
}),
),
},
"layouts": []any{gridLayout(
gridItem("request-rate", 0, 0, 8, 6),
gridItem("error-count", 8, 0, 4, 6),
)},
"duration": "1h",
"links": []any{},
},
},
},
{
Name: "logs_table_with_runbook",
Summary: "Logs dashboard with a text panel and a table panel",
Description: "A `signoz/TextPanel` renders markdown and must send `queries: []`. The table counts error logs per service as a scalar result, orders by the `errors` alias and colors cells above 1000 errors; the threshold names the column by its key `A` (the query name), not by the alias.",
Value: map[string]any{
"schemaVersion": "v6",
"name": "error-logs-by-service",
"tags": []any{},
"spec": map[string]any{
"display": map[string]any{"name": "Error Logs by Service", "description": ""},
"variables": []any{},
"panels": map[string]any{
"runbook": panel("Runbook", "What to do when errors spike",
map[string]any{
"kind": "signoz/TextPanel",
"spec": map[string]any{
"mode": "markdown",
"text": "## Error logs\n\n1. Find the service with the most errors in the table.\n2. Open its logs and group by `code.function`.",
"presentation": map[string]any{"textAlign": "left", "verticalAlign": "top"},
"headerOptions": map[string]any{"hide": false},
},
},
),
"errors-by-service": panel("Error logs per service", "",
map[string]any{
"kind": "signoz/TablePanel",
"spec": map[string]any{
"formatting": map[string]any{"decimalPrecision": "0"},
"thresholds": []any{
map[string]any{"columnName": "A", "value": 1000, "operator": "above", "color": "#F1575F", "format": "background"},
},
},
},
builderQuery("scalar", map[string]any{
"name": "A",
"signal": "logs",
"aggregations": []any{map[string]any{"expression": "count()", "alias": "errors"}},
"filter": map[string]any{"expression": "severity_text = 'ERROR'"},
"groupBy": serviceNameGroupBy(),
"order": []any{map[string]any{"key": map[string]any{"name": "errors"}, "direction": "desc"}},
"limit": 20,
}),
),
},
"layouts": []any{gridLayout(
gridItem("runbook", 0, 0, 4, 6),
gridItem("errors-by-service", 4, 0, 8, 6),
)},
"links": []any{},
},
},
},
}
}
// updateDashboardExamples returns the example payload for the dashboard update
// endpoint: the full replacement shape, including the immutable name.
func updateDashboardExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "replace_dashboard",
Summary: "Replace a dashboard with a single CPU time series panel",
Description: "Update is a full replacement: every field is written as sent, so fetch the dashboard first and resend the unchanged parts. `name` must equal the dashboard's current name.",
Value: map[string]any{
"schemaVersion": "v6",
"name": "host-cpu",
"tags": []any{map[string]any{"key": "team", "value": "infra"}},
"spec": map[string]any{
"display": map[string]any{"name": "Host CPU", "description": "CPU time by state"},
"variables": []any{},
"panels": map[string]any{
"cpu-time": panel("CPU time", "Rate of system.cpu.time by state",
map[string]any{
"kind": "signoz/TimeSeriesPanel",
"spec": map[string]any{
"chartAppearance": map[string]any{"fillMode": "gradient"},
"legend": map[string]any{"position": "right"},
},
},
builderQuery("time_series", map[string]any{
"name": "A",
"signal": "metrics",
"aggregations": []any{map[string]any{
"metricName": "system.cpu.time",
"timeAggregation": "rate",
"spaceAggregation": "sum",
}},
"groupBy": []any{map[string]any{"name": "state", "fieldContext": "attribute", "fieldDataType": "string"}},
"legend": "{{state}}",
}),
),
},
"layouts": []any{gridLayout(gridItem("cpu-time", 0, 0, 12, 7))},
"duration": "3h",
"refreshInterval": "30s",
"links": []any{},
},
},
},
}
}
// patchDashboardExamples returns example payloads for the dashboard patch
// endpoint. The request body is the RFC 6902 operation array itself.
func patchDashboardExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "rename",
Summary: "Rename a dashboard",
Description: "Replaces the display name without resending anything else.",
Value: []any{
map[string]any{"op": "replace", "path": "/spec/display/name", "value": "Service Overview (production)"},
},
},
{
Name: "add_panel",
Summary: "Add a panel and place it",
Description: "Adding a panel takes two operations: one adds the panel under `/spec/panels/<id>`, the other appends a grid item that references it to the first layout. A panel that no layout places is accepted but not shown.",
Value: []any{
map[string]any{
"op": "add",
"path": "/spec/panels/error-rate",
"value": panel("Error count", "Errored spans per time bucket",
map[string]any{"kind": "signoz/TimeSeriesPanel", "spec": map[string]any{}},
builderQuery("time_series", map[string]any{
"name": "A",
"signal": "traces",
"aggregations": []any{map[string]any{"expression": "count()"}},
"filter": map[string]any{"expression": "has_error = true"},
}),
),
},
map[string]any{
"op": "add",
"path": "/spec/layouts/0/spec/items/-",
"value": gridItem("error-rate", 0, 7, 12, 6),
},
},
},
}
}

View File

@@ -1,49 +0,0 @@
package signozapiserver
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
func TestDashboardExamplesAreValidPayloads(t *testing.T) {
for _, ex := range createDashboardExamples() {
t.Run("create/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var postable dashboardtypes.PostableDashboardV2
require.NoError(t, json.Unmarshal(raw, &postable))
})
}
var base dashboardtypes.UpdatableDashboardV2
for _, ex := range updateDashboardExamples() {
t.Run("update/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(raw, &base))
})
}
existing := &dashboardtypes.DashboardV2{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
Source: dashboardtypes.SourceUser,
DashboardV2MetadataBase: base.DashboardV2MetadataBase,
Name: base.Name,
Spec: base.Spec,
}
for _, ex := range patchDashboardExamples() {
t.Run("patch/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var patch dashboardtypes.PatchableDashboardV2
require.NoError(t, json.Unmarshal(raw, &patch))
_, err = patch.Apply(existing)
require.NoError(t, err)
})
}
}

View File

@@ -70,7 +70,6 @@ func (provider *provider) addMetricsExplorerRoutes(router *mux.Router) error {
Description: "This endpoint returns a treemap visualization showing the proportional distribution of metrics by sample count or time series count",
Request: new(metricsexplorertypes.TreemapRequest),
RequestContentType: "application/json",
RequestExamples: treemapRequestExamples(),
Response: new(metricsexplorertypes.TreemapResponse),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,

View File

@@ -1,33 +0,0 @@
package signozapiserver
import "github.com/SigNoz/signoz/pkg/http/handler"
// treemapRequestExamples returns example payloads for the metrics treemap
// endpoint, one per mode.
func treemapRequestExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "treemap_timeseries",
Summary: "Top metrics by time series count",
Description: "Sizes each metric by the number of distinct time series it produced in the time range, limited to the 50 largest. Only the `timeseries` array of the response is populated.",
Value: map[string]any{
"start": 1640995200000,
"end": 1640998800000,
"limit": 50,
"mode": "timeseries",
},
},
{
Name: "treemap_samples_filtered",
Summary: "Top metrics by sample count for one host",
Description: "Sizes each metric by the number of samples ingested, restricted with a query builder filter on a metric attribute. Only the `samples` array of the response is populated.",
Value: map[string]any{
"start": 1640995200000,
"end": 1640998800000,
"limit": 20,
"mode": "samples",
"filter": map[string]any{"expression": "host.name = 'web-1'"},
},
},
}
}

View File

@@ -1,21 +0,0 @@
package signozapiserver
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/types/metricsexplorertypes"
"github.com/stretchr/testify/require"
)
func TestTreemapExamplesAreValidPayloads(t *testing.T) {
for _, ex := range treemapRequestExamples() {
t.Run(ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var req metricsexplorertypes.TreemapRequest
require.NoError(t, json.Unmarshal(raw, &req))
require.NoError(t, req.Validate())
})
}
}

View File

@@ -48,7 +48,6 @@ func (provider *provider) addSavedViewRoutes(router *mux.Router) error {
Description: "Persists a saved view for the explore page. Returns the id of the created view.",
Request: new(savedviewtypes.PostableSavedView),
RequestContentType: "application/json",
RequestExamples: postableSavedViewExamples(),
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
@@ -103,7 +102,6 @@ func (provider *provider) addSavedViewRoutes(router *mux.Router) error {
Description: "Replaces a saved view's name and query.",
Request: new(savedviewtypes.UpdatableSavedView),
RequestContentType: "application/json",
RequestExamples: updatableSavedViewExamples(),
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,

View File

@@ -1,176 +0,0 @@
package signozapiserver
import "github.com/SigNoz/signoz/pkg/http/handler"
// postableSavedViewExamples returns example payloads attached to the saved view
// create endpoint. They cover each explorer source and both list and graph panels.
func postableSavedViewExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "traces_list",
Summary: "Traces list view with filter and columns",
Description: "A list panel over spans slower than 500ms in one service, ordered newest first, with three explorer columns.",
Value: map[string]any{
"name": "slow-checkout-traces",
"source": "traces",
"schemaVersion": "v2",
"spec": map[string]any{
"displayName": "Slow checkout traces",
"panelType": "list",
"requestType": "raw",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"filter": map[string]any{"expression": "service.name = 'checkoutservice' AND duration_nano > 500000000"},
"order": []any{map[string]any{"key": map[string]any{"name": "timestamp"}, "direction": "desc"}},
"limit": 100,
},
},
},
"selectedFields": []any{
map[string]any{"name": "service.name", "signal": "traces", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "name", "signal": "traces", "fieldContext": "span", "fieldDataType": "string"},
map[string]any{"name": "duration_nano", "signal": "traces", "fieldContext": "span", "fieldDataType": "float64"},
},
},
},
},
{
Name: "logs_list_generated_name",
Summary: "Logs list view with a generated name",
Description: "A list panel over error logs of one service. generateName lets the server derive the machine name from displayName; the display preferences set the row format and font size.",
Value: map[string]any{
"generateName": true,
"source": "logs",
"schemaVersion": "v2",
"spec": map[string]any{
"displayName": "Payment errors",
"panelType": "list",
"requestType": "raw",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "logs",
"filter": map[string]any{"expression": "service.name = 'paymentservice' AND severity_text = 'ERROR'"},
"order": []any{
map[string]any{"key": map[string]any{"name": "timestamp"}, "direction": "desc"},
map[string]any{"key": map[string]any{"name": "id"}, "direction": "desc"},
},
"limit": 100,
},
},
},
"selectedFields": []any{
map[string]any{"name": "service.name", "signal": "logs", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "severity_text", "signal": "logs", "fieldContext": "log", "fieldDataType": "string"},
},
"display": map[string]any{
"format": "list",
"fontSize": "small",
"maxLines": 2,
},
},
},
},
{
Name: "metrics_time_series",
Summary: "Metrics graph view",
Description: "A graph panel plotting the per-second request rate of a counter, summed across series at one minute resolution.",
Value: map[string]any{
"name": "http-request-rate",
"source": "metrics",
"schemaVersion": "v2",
"spec": map[string]any{
"displayName": "HTTP request rate",
"panelType": "graph",
"requestType": "time_series",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"stepInterval": 60,
"aggregations": []any{map[string]any{"metricName": "http_requests_total", "timeAggregation": "rate", "spaceAggregation": "sum"}},
"groupBy": []any{map[string]any{"name": "service.name", "fieldContext": "resource", "fieldDataType": "string"}},
},
},
},
"selectedFields": []any{},
},
},
},
{
Name: "meter_time_series",
Summary: "Cost meter graph view",
Description: "A graph panel on the meter explorer. Meter views are queried as metrics with the builder spec source set to meter; the meter rolls up hourly, so the step is 3600 seconds.",
Value: map[string]any{
"name": "log-ingestion-volume",
"source": "meter",
"schemaVersion": "v2",
"spec": map[string]any{
"displayName": "Log ingestion volume",
"panelType": "graph",
"requestType": "time_series",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "metrics",
"source": "meter",
"stepInterval": 3600,
"aggregations": []any{map[string]any{"metricName": "signoz.meter.log.size", "timeAggregation": "increase", "spaceAggregation": "sum"}},
},
},
},
"selectedFields": []any{},
},
},
},
}
}
// updatableSavedViewExamples returns example payloads attached to the saved view
// update endpoint. The update replaces the whole spec; the machine name is immutable.
func updatableSavedViewExamples() []handler.OpenAPIExample {
return []handler.OpenAPIExample{
{
Name: "traces_list_widen_filter",
Summary: "Replace a traces list view",
Description: "Replaces the spec of an existing traces list view with a wider latency filter and one more column. Every field of spec must be sent again; the machine name stays as created.",
Value: map[string]any{
"source": "traces",
"schemaVersion": "v2",
"spec": map[string]any{
"displayName": "Slow checkout traces (over 250ms)",
"panelType": "list",
"requestType": "raw",
"queries": []any{
map[string]any{
"type": "builder_query",
"spec": map[string]any{
"name": "A",
"signal": "traces",
"filter": map[string]any{"expression": "service.name = 'checkoutservice' AND duration_nano > 250000000"},
"order": []any{map[string]any{"key": map[string]any{"name": "timestamp"}, "direction": "desc"}},
"limit": 100,
},
},
},
"selectedFields": []any{
map[string]any{"name": "service.name", "signal": "traces", "fieldContext": "resource", "fieldDataType": "string"},
map[string]any{"name": "name", "signal": "traces", "fieldContext": "span", "fieldDataType": "string"},
map[string]any{"name": "duration_nano", "signal": "traces", "fieldContext": "span", "fieldDataType": "float64"},
map[string]any{"name": "http.status_code", "signal": "traces", "fieldContext": "attribute", "fieldDataType": "string"},
},
},
},
},
}
}

View File

@@ -1,30 +0,0 @@
package signozapiserver
import (
"encoding/json"
"testing"
"github.com/SigNoz/signoz/pkg/types/savedviewtypes"
"github.com/stretchr/testify/require"
)
func TestSavedViewExamplesAreValidPayloads(t *testing.T) {
for _, ex := range postableSavedViewExamples() {
t.Run("create/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var postable savedviewtypes.PostableSavedView
require.NoError(t, json.Unmarshal(raw, &postable))
require.NoError(t, postable.Validate())
})
}
for _, ex := range updatableSavedViewExamples() {
t.Run("update/"+ex.Name, func(t *testing.T) {
raw, err := json.Marshal(ex.Value)
require.NoError(t, err)
var updatable savedviewtypes.UpdatableSavedView
require.NoError(t, json.Unmarshal(raw, &updatable))
require.NoError(t, updatable.Validate())
})
}
}

View File

@@ -47,9 +47,9 @@ const (
type DeprecatedGettableAlert struct {
*model.Alert
Status types.AlertStatus `json:"status" description:"The alert state and the silences and inhibitions suppressing it."`
Receivers []string `json:"receivers" description:"The display names of the channels the alert is routed to."`
Fingerprint string `json:"fingerprint" description:"The alert fingerprint, a hash of its label set."`
Status types.AlertStatus `json:"status"`
Receivers []string `json:"receivers"`
Fingerprint string `json:"fingerprint"`
}
type DeprecatedGettableAlerts = []*DeprecatedGettableAlert

View File

@@ -36,11 +36,11 @@ type Channel struct {
// DisplayName is the receiver name inside Data and what policies and rules
// reference, so it keeps the v1 wire tag and Name stays off the v1 contract.
Name string `json:"-" bun:"name"`
DisplayName string `json:"name" required:"true" bun:"display_name" description:"The channel name. Alert rules and routing policies reference it."`
DisplayName string `json:"name" required:"true" bun:"display_name"`
// TODO: type this as ChannelKind once v1 is gone.
Type string `json:"type" required:"true" bun:"type" description:"The channel kind, such as slack."`
Data string `json:"data" required:"true" bun:"data" description:"The channel configuration as a JSON document."`
OrgID string `json:"orgId" required:"true" bun:"org_id" description:"The organization the channel belongs to."`
Type string `json:"type" required:"true" bun:"type"`
Data string `json:"data" required:"true" bun:"data"`
OrgID string `json:"orgId" required:"true" bun:"org_id"`
// Spec is the v2 spec a read returns, of the kind Type names. A v2 write
// stores it as the caller wrote it and a v1 write derives it from the

View File

@@ -14,10 +14,10 @@ import (
// credentials and TLS settings come from the deployment's global config, so a
// channel can only choose recipients and body.
type ChannelEmailConfig struct {
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to false."`
To string `json:"to" required:"true" description:"The recipient addresses, comma separated."`
HTML valuer.UnsetOrNonEmptyString `json:"html,omitzero" description:"Alertmanager template for the HTML body; defaults to the Alertmanager email default."`
Headers map[string]string `json:"headers,omitzero" description:"Extra SMTP headers keyed by canonical MIME header name, such as Subject."`
SendResolved *bool `json:"sendResolved,omitempty"`
To string `json:"to" required:"true"`
HTML valuer.UnsetOrNonEmptyString `json:"html,omitzero"`
Headers map[string]string `json:"headers,omitzero"`
}
func (c *ChannelEmailConfig) UnmarshalJSON(data []byte) error {

View File

@@ -8,10 +8,10 @@ import (
)
type ChannelGoogleChatConfig struct {
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to false."`
WebhookURL string `json:"webhookUrl" required:"true" format:"password" description:"The Google Chat space webhook URL. A secret; returned by create, update and fetch by ID, never by list."`
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero" description:"Alertmanager template for the message title; defaults to the SigNoz Google Chat default."`
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero" description:"Alertmanager template for the message body; defaults to the SigNoz Google Chat default."`
SendResolved *bool `json:"sendResolved,omitempty"`
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero"`
}
func (c *ChannelGoogleChatConfig) UnmarshalJSON(data []byte) error {
@@ -71,11 +71,11 @@ func newChannelGoogleChatConfigFromReceiver(name string, receiver *Receiver) (Ch
type GoogleChatReceiverConfig struct {
config.NotifierConfig `yaml:",inline" json:",inline"`
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty" description:"HTTP client settings for the webhook call."`
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`
WebhookURL *config.SecretURL `yaml:"webhook_url,omitempty" json:"webhook_url,omitempty" description:"The Google Chat space webhook URL."`
Title string `yaml:"title,omitempty" json:"title,omitempty" description:"Alertmanager template for the message title."`
Text string `yaml:"text,omitempty" json:"text,omitempty" description:"Alertmanager template for the message body."`
WebhookURL *config.SecretURL `yaml:"webhook_url,omitempty" json:"webhook_url,omitempty"`
Title string `yaml:"title,omitempty" json:"title,omitempty"`
Text string `yaml:"text,omitempty" json:"text,omitempty"`
}
var DefaultGoogleChatReceiverConfig = GoogleChatReceiverConfig{

View File

@@ -10,12 +10,12 @@ import (
)
type ChannelIncidentIOConfig struct {
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to false."`
URL string `json:"url" required:"true" description:"The alert source HTTP alert events endpoint copied from its setup page."`
Token string `json:"token" required:"true" format:"password" description:"The alert source secret token, without the Bearer prefix. A secret; returned by create, update and fetch by ID, never by list."`
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero" description:"Alertmanager template for the alert title; defaults to the SigNoz incident.io default."`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero" description:"Alertmanager template for the alert description, rendered as markdown; defaults to the SigNoz incident.io default."`
Metadata map[string]string `json:"metadata,omitzero" description:"Extra event metadata keyed by name, merged over the group's common labels; values are Alertmanager templates."`
SendResolved *bool `json:"sendResolved,omitempty"`
URL string `json:"url" required:"true"`
Token string `json:"token" required:"true" format:"password"`
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
Metadata map[string]string `json:"metadata,omitzero"`
}
func (c *ChannelIncidentIOConfig) UnmarshalJSON(data []byte) error {
@@ -102,15 +102,15 @@ const (
type IncidentIOReceiverConfig struct {
config.NotifierConfig `yaml:",inline" json:",inline"`
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty" description:"HTTP client settings for the API call."`
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`
URL string `yaml:"url,omitempty" json:"url,omitempty" description:"The alert source HTTP alert events endpoint."`
Token config.Secret `yaml:"token,omitempty" json:"token,omitempty" description:"The alert source secret token, without the Bearer prefix."`
Title string `yaml:"title,omitempty" json:"title,omitempty" description:"Alertmanager template for the alert title."`
Description string `yaml:"description,omitempty" json:"description,omitempty" description:"Alertmanager template for the alert description."`
URL string `yaml:"url,omitempty" json:"url,omitempty"`
Token config.Secret `yaml:"token,omitempty" json:"token,omitempty"`
Title string `yaml:"title,omitempty" json:"title,omitempty"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
// Metadata is merged into the event's metadata on top of the group's common
// labels (channel wins on key clash). Values are template-expanded.
Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty" description:"Extra event metadata keyed by name, merged over the group's common labels; values are Alertmanager templates."`
Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
}
// send_resolved has no omitempty upstream, so a var default here is overwritten

View File

@@ -13,22 +13,24 @@ import (
)
type ChannelJiraConfig struct {
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to false."`
Site string `json:"site" required:"true" description:"The base URL of the Jira Cloud site, for example https://example.atlassian.net. Only Jira Cloud is supported."`
Project string `json:"project" required:"true" description:"The key of the project issues are created in."`
IssueType string `json:"issueType" required:"true" description:"The issue type name, such as Bug."`
Summary valuer.UnsetOrNonEmptyString `json:"summary,omitzero" description:"Alertmanager template for the issue summary; defaults to the SigNoz Jira default."`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero" description:"Alertmanager template for the issue description, rendered as markdown; defaults to the SigNoz Jira default."`
Priority string `json:"priority" description:"The priority name assigned to new issues."`
Labels []string `json:"labels,omitzero" description:"Labels added to every issue."`
ResolveTransition string `json:"resolveTransition" description:"The transition applied to the issue when the alert resolves."`
ReopenTransition string `json:"reopenTransition" description:"The transition applied when an alert fires again on a resolved issue."`
ReopenDuration valuer.UnsetOrNonEmptyString `json:"reopenDuration,omitzero" description:"How long after resolution a firing alert reopens the issue instead of creating a new one, as a Prometheus duration in canonical form such as 3d; defaults to 3d."`
WontFixResolution string `json:"wontFixResolution" description:"The resolution name that stops an issue from being reopened."`
CustomFields map[string]any `json:"customFields,omitzero" description:"Extra issue fields keyed by Jira field ID. Stored, but not currently included in the issue Jira receives."`
SendResolved *bool `json:"sendResolved,omitempty"`
// Site is the Jira Cloud base URL, https://<site>.atlassian.net. Only Jira
// Cloud is supported; the REST base is derived from it.
Site string `json:"site" required:"true"`
Project string `json:"project" required:"true"`
IssueType string `json:"issueType" required:"true"`
Summary valuer.UnsetOrNonEmptyString `json:"summary,omitzero"`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
Priority string `json:"priority"`
Labels []string `json:"labels,omitzero"`
ResolveTransition string `json:"resolveTransition"`
ReopenTransition string `json:"reopenTransition"`
ReopenDuration valuer.UnsetOrNonEmptyString `json:"reopenDuration,omitzero"`
WontFixResolution string `json:"wontFixResolution"`
CustomFields map[string]any `json:"customFields,omitzero"`
Email string `json:"email" required:"true" description:"The Atlassian account email used for basic auth."`
APIToken string `json:"apiToken" required:"true" format:"password" description:"The Atlassian API token paired with email. A secret; returned by create, update and fetch by ID, never by list."`
Email string `json:"email" required:"true"`
APIToken string `json:"apiToken" required:"true" format:"password"`
}
// UnmarshalJSON seeds send_resolved off, as JiraReceiverConfig does.
@@ -191,19 +193,19 @@ const (
type JiraReceiverConfig struct {
config.NotifierConfig `yaml:",inline"`
Site string `json:"site,omitempty" yaml:"site,omitempty" description:"The Jira Cloud base URL."`
Project string `json:"project,omitempty" yaml:"project,omitempty" description:"The key of the project issues are created in."`
IssueType string `json:"issue_type,omitempty" yaml:"issue_type,omitempty" description:"The issue type name."`
Summary string `json:"summary,omitempty" yaml:"summary,omitempty" description:"Alertmanager template for the issue summary."`
Description string `json:"description,omitempty" yaml:"description,omitempty" description:"Alertmanager template for the issue description."`
Priority string `json:"priority,omitempty" yaml:"priority,omitempty" description:"The priority name assigned to new issues."`
Labels []string `json:"labels,omitempty" yaml:"labels,omitempty" description:"Labels added to every issue."`
ResolveTransition string `json:"resolve_transition,omitempty" yaml:"resolve_transition,omitempty" description:"The transition applied when the alert resolves."`
ReopenTransition string `json:"reopen_transition,omitempty" yaml:"reopen_transition,omitempty" description:"The transition applied when an alert fires again on a resolved issue."`
ReopenDuration model.Duration `json:"reopen_duration" yaml:"reopen_duration" description:"How long after resolution a firing alert reopens the issue; a Prometheus duration, at least 1m."`
WontFixResolution string `json:"wont_fix_resolution,omitempty" yaml:"wont_fix_resolution,omitempty" description:"The resolution name that stops an issue from being reopened."`
CustomFields map[string]any `json:"custom_fields,omitempty" yaml:"custom_fields,omitempty" description:"Extra issue fields keyed by Jira field ID. Stored, but not currently included in the issue Jira receives."`
HTTPConfig *commoncfg.HTTPClientConfig `json:"http_config,omitempty" yaml:"http_config,omitempty" description:"HTTP client settings; basic auth carries the account email and API token."`
Site string `json:"site,omitempty" yaml:"site,omitempty"`
Project string `json:"project,omitempty" yaml:"project,omitempty"`
IssueType string `json:"issue_type,omitempty" yaml:"issue_type,omitempty"`
Summary string `json:"summary,omitempty" yaml:"summary,omitempty"`
Description string `json:"description,omitempty" yaml:"description,omitempty"`
Priority string `json:"priority,omitempty" yaml:"priority,omitempty"`
Labels []string `json:"labels,omitempty" yaml:"labels,omitempty"`
ResolveTransition string `json:"resolve_transition,omitempty" yaml:"resolve_transition,omitempty"`
ReopenTransition string `json:"reopen_transition,omitempty" yaml:"reopen_transition,omitempty"`
ReopenDuration model.Duration `json:"reopen_duration" yaml:"reopen_duration"`
WontFixResolution string `json:"wont_fix_resolution,omitempty" yaml:"wont_fix_resolution,omitempty"`
CustomFields map[string]any `json:"custom_fields,omitempty" yaml:"custom_fields,omitempty"`
HTTPConfig *commoncfg.HTTPClientConfig `json:"http_config,omitempty" yaml:"http_config,omitempty"`
}
func (c *JiraReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error {

View File

@@ -10,12 +10,13 @@ import (
// ChannelJSMOpsConfig carries no API URL: JSM Ops is a single global gateway
// keyed by the integration API key, which the notifier pins itself.
type ChannelJSMOpsConfig struct {
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to false."`
APIKey string `json:"apiKey" required:"true" format:"password" description:"The JSM Ops integration API key. A secret; returned by create, update and fetch by ID, never by list."`
Message valuer.UnsetOrNonEmptyString `json:"message,omitzero" description:"Alertmanager template for the alert message; defaults to the SigNoz JSM Ops default."`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero" description:"Alertmanager template for the alert description; defaults to the SigNoz JSM Ops default."`
Priority string `json:"priority" description:"The alert priority, P1 to P5."`
Tags valuer.UnsetOrNonEmptyString `json:"tags,omitzero" description:"Comma-separated tags attached to the alert; defaults to signoz."`
SendResolved *bool `json:"sendResolved,omitempty"`
APIKey string `json:"apiKey" required:"true" format:"password"`
Message valuer.UnsetOrNonEmptyString `json:"message,omitzero"`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
Priority string `json:"priority"`
// Tags is the comma-separated list JSM Ops attaches to the alert.
Tags valuer.UnsetOrNonEmptyString `json:"tags,omitzero"`
}
func (c *ChannelJSMOpsConfig) UnmarshalJSON(data []byte) error {
@@ -107,13 +108,13 @@ const (
type JSMOpsReceiverConfig struct {
config.NotifierConfig `yaml:",inline" json:",inline"`
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty" description:"HTTP client settings for the API call."`
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`
APIKey config.Secret `yaml:"api_key,omitempty" json:"api_key,omitempty" description:"The JSM Ops integration API key."`
Message string `yaml:"message,omitempty" json:"message,omitempty" description:"Alertmanager template for the alert message."`
Description string `yaml:"description,omitempty" json:"description,omitempty" description:"Alertmanager template for the alert description."`
Priority string `yaml:"priority,omitempty" json:"priority,omitempty" description:"The alert priority, P1 to P5."`
Tags string `yaml:"tags,omitempty" json:"tags,omitempty" description:"Comma-separated tags attached to the alert."`
APIKey config.Secret `yaml:"api_key,omitempty" json:"api_key,omitempty"`
Message string `yaml:"message,omitempty" json:"message,omitempty"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
Priority string `yaml:"priority,omitempty" json:"priority,omitempty"`
Tags string `yaml:"tags,omitempty" json:"tags,omitempty"`
}
// send_resolved has no omitempty upstream, so a var default here is overwritten

View File

@@ -7,10 +7,10 @@ import (
)
type ChannelMSTeamsConfig struct {
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to true."`
WebhookURL string `json:"webhookUrl" required:"true" format:"password" description:"The Teams incoming webhook URL. A secret; returned by create, update and fetch by ID, never by list."`
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero" description:"Alertmanager template for the card title; defaults to the Alertmanager Teams default."`
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero" description:"Alertmanager template for the card body; defaults to the Alertmanager Teams default."`
SendResolved *bool `json:"sendResolved,omitempty"`
WebhookURL string `json:"webhookUrl" required:"true" format:"password"`
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero"`
}
func (c *ChannelMSTeamsConfig) UnmarshalJSON(data []byte) error {

View File

@@ -7,14 +7,14 @@ import (
)
type ChannelOpsgenieConfig struct {
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to true."`
APIKey string `json:"apiKey" required:"true" format:"password" description:"The Opsgenie API integration key. A secret; returned by create, update and fetch by ID, never by list."`
APIURL string `json:"apiUrl" description:"The Opsgenie API host; empty uses the global Alertmanager default."`
Message valuer.UnsetOrNonEmptyString `json:"message,omitzero" description:"Alertmanager template for the alert message; defaults to the Alertmanager Opsgenie default."`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero" description:"Alertmanager template for the alert description; defaults to the Alertmanager Opsgenie default."`
Source valuer.UnsetOrNonEmptyString `json:"source,omitzero" description:"Alertmanager template for the alert source; defaults to the Alertmanager Opsgenie default."`
Details map[string]string `json:"details,omitzero" description:"Custom alert details keyed by name, each an Alertmanager template."`
Priority string `json:"priority" description:"The alert priority, P1 to P5."`
SendResolved *bool `json:"sendResolved,omitempty"`
APIKey string `json:"apiKey" required:"true" format:"password"`
APIURL string `json:"apiUrl"`
Message valuer.UnsetOrNonEmptyString `json:"message,omitzero"`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
Source valuer.UnsetOrNonEmptyString `json:"source,omitzero"`
Details map[string]string `json:"details,omitzero"`
Priority string `json:"priority"`
}
func (c *ChannelOpsgenieConfig) UnmarshalJSON(data []byte) error {

View File

@@ -7,18 +7,18 @@ import (
)
type ChannelPagerdutyConfig struct {
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to true."`
RoutingKey string `json:"routingKey" required:"true" format:"password" description:"The Events API v2 integration key. A secret; returned by create, update and fetch by ID, never by list."`
URL string `json:"url" description:"The Events API endpoint; empty uses the PagerDuty default."`
Source valuer.UnsetOrNonEmptyString `json:"source,omitzero" description:"Alertmanager template for the unique location of the affected system; defaults to client."`
Client valuer.UnsetOrNonEmptyString `json:"client,omitzero" description:"Alertmanager template for the client identifier; defaults to the Alertmanager PagerDuty default."`
ClientURL valuer.UnsetOrNonEmptyString `json:"clientUrl,omitzero" description:"Alertmanager template for the client URL; defaults to the Alertmanager PagerDuty default."`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero" description:"Alertmanager template for the incident summary; defaults to the Alertmanager PagerDuty default."`
Severity string `json:"severity" description:"The incident severity: critical, error, warning or info."`
Component string `json:"component" description:"The component of the source machine that is responsible for the event."`
Group string `json:"group" description:"The logical grouping of components the event belongs to."`
Class string `json:"class" description:"The class or type of the event."`
Details map[string]string `json:"details,omitzero" description:"Custom details keyed by name, each an Alertmanager template; defaults to the Alertmanager firing and resolved summary set."`
SendResolved *bool `json:"sendResolved,omitempty"`
RoutingKey string `json:"routingKey" required:"true" format:"password"`
URL string `json:"url"`
Source valuer.UnsetOrNonEmptyString `json:"source,omitzero"`
Client valuer.UnsetOrNonEmptyString `json:"client,omitzero"`
ClientURL valuer.UnsetOrNonEmptyString `json:"clientUrl,omitzero"`
Description valuer.UnsetOrNonEmptyString `json:"description,omitzero"`
Severity string `json:"severity"`
Component string `json:"component"`
Group string `json:"group"`
Class string `json:"class"`
Details map[string]string `json:"details,omitzero"`
}
func (c *ChannelPagerdutyConfig) UnmarshalJSON(data []byte) error {

View File

@@ -7,43 +7,43 @@ import (
)
type ChannelSlackConfig struct {
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to false."`
APIURL string `json:"apiUrl" required:"true" format:"password" description:"The Slack incoming webhook URL. A secret; returned by create, update and fetch by ID, never by list."`
Channel string `json:"channel" description:"The channel or user to post to, such as #alerts; empty uses the webhook default."`
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero" description:"Alertmanager template for the message title; defaults to the Alertmanager Slack default."`
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero" description:"Alertmanager template for the message body; defaults to the Alertmanager Slack default."`
Color valuer.UnsetOrNonEmptyString `json:"color,omitzero" description:"Alertmanager template for the attachment color; defaults to the Alertmanager Slack default."`
TitleLink valuer.UnsetOrNonEmptyString `json:"titleLink,omitzero" description:"Alertmanager template for the link the title opens; defaults to the Alertmanager Slack default."`
Pretext valuer.UnsetOrNonEmptyString `json:"pretext,omitzero" description:"Alertmanager template for the text shown above the attachment; defaults to the Alertmanager Slack default."`
Fallback valuer.UnsetOrNonEmptyString `json:"fallback,omitzero" description:"Alertmanager template for the plain-text summary shown where attachments cannot render; defaults to the Alertmanager Slack default."`
Footer valuer.UnsetOrNonEmptyString `json:"footer,omitzero" description:"Alertmanager template for the attachment footer; defaults to the Alertmanager Slack default."`
Fields []ChannelSlackField `json:"fields,omitzero" description:"Attachment fields appended to the message; each requires title and value."`
Actions []ChannelSlackAction `json:"actions,omitzero" description:"Attachment buttons; each requires type and text plus url or name."`
SendResolved *bool `json:"sendResolved,omitempty"`
APIURL string `json:"apiUrl" required:"true" format:"password"`
Channel string `json:"channel"`
Title valuer.UnsetOrNonEmptyString `json:"title,omitzero"`
Text valuer.UnsetOrNonEmptyString `json:"text,omitzero"`
Color valuer.UnsetOrNonEmptyString `json:"color,omitzero"`
TitleLink valuer.UnsetOrNonEmptyString `json:"titleLink,omitzero"`
Pretext valuer.UnsetOrNonEmptyString `json:"pretext,omitzero"`
Fallback valuer.UnsetOrNonEmptyString `json:"fallback,omitzero"`
Footer valuer.UnsetOrNonEmptyString `json:"footer,omitzero"`
Fields []ChannelSlackField `json:"fields,omitzero"`
Actions []ChannelSlackAction `json:"actions,omitzero"`
}
type ChannelSlackField struct {
Title string `json:"title" required:"true" description:"The field title."`
Value string `json:"value" required:"true" description:"The field value; an Alertmanager template."`
Short *bool `json:"short,omitempty" description:"Render the field side by side with other short fields."`
Title string `json:"title" required:"true"`
Value string `json:"value" required:"true"`
Short *bool `json:"short,omitempty"`
}
// ChannelSlackAction is a link button when URL is set, otherwise a message
// button that needs Name. Upstream clears whichever side is not in use.
type ChannelSlackAction struct {
Type string `json:"type" required:"true" description:"The Slack action type, normally button."`
Text string `json:"text" required:"true" description:"The button label."`
URL string `json:"url" description:"The link the button opens; required unless name is set."`
Style string `json:"style" description:"The button style: default, primary or danger."`
Name string `json:"name" description:"The action name Slack posts back for a message button; required unless url is set."`
Value string `json:"value" description:"The value Slack posts back with a message button."`
Confirm *ChannelSlackConfirmation `json:"confirm,omitempty" description:"A confirmation dialog shown before the action runs."`
Type string `json:"type" required:"true"`
Text string `json:"text" required:"true"`
URL string `json:"url"`
Style string `json:"style"`
Name string `json:"name"`
Value string `json:"value"`
Confirm *ChannelSlackConfirmation `json:"confirm,omitempty"`
}
type ChannelSlackConfirmation struct {
Text string `json:"text" required:"true" description:"The dialog body."`
Title string `json:"title" description:"The dialog title."`
OkText string `json:"okText" description:"The label of the confirm button."`
DismissText string `json:"dismissText" description:"The label of the cancel button."`
Text string `json:"text" required:"true"`
Title string `json:"title"`
OkText string `json:"okText"`
DismissText string `json:"dismissText"`
}
func (c *ChannelSlackConfig) UnmarshalJSON(data []byte) error {

View File

@@ -16,11 +16,11 @@ const bearerAuthorizationType = "Bearer"
// was really a bearer token. Username or Password may be set without the other,
// as upstream allows, but not together with BearerToken.
type ChannelWebhookConfig struct {
SendResolved *bool `json:"sendResolved,omitempty" description:"Also notify when the alert resolves. Defaults to true."`
URL string `json:"url" required:"true" format:"password" description:"The endpoint that receives the Alertmanager webhook payload. A secret; returned by create, update and fetch by ID, never by list."`
Username string `json:"username" description:"The basic auth user; cannot be combined with bearerToken."`
Password string `json:"password" format:"password" description:"The basic auth password; cannot be combined with bearerToken."`
BearerToken string `json:"bearerToken" format:"password" description:"The bearer token sent in the Authorization header; cannot be combined with username or password."`
SendResolved *bool `json:"sendResolved,omitempty"`
URL string `json:"url" required:"true" format:"password"`
Username string `json:"username"`
Password string `json:"password" format:"password"`
BearerToken string `json:"bearerToken" format:"password"`
}
func (c *ChannelWebhookConfig) UnmarshalJSON(data []byte) error {

View File

@@ -18,10 +18,10 @@ import (
// Name is the immutable DNS1123 identity references will point at; DisplayName is the
// free-text label.
type PostableNotificationChannel struct {
Name string `json:"name" description:"The immutable DNS-1123 identifier of the channel (lowercase letters, digits and hyphens, at most 63 characters). Required unless generateName is true, in which case it must be empty. Alert rules and routing policies reference displayName, not name."`
GenerateName bool `json:"generateName" description:"Derive name from displayName plus a random suffix instead of supplying it. Defaults to false."`
DisplayName string `json:"displayName" description:"The label shown in the UI. Alert rules and routing policies reference it, not name. Defaults to name; required when generateName is true."`
Config ChannelConfig `json:"config" required:"true" description:"The provider kind and its spec."`
Name string `json:"name"`
GenerateName bool `json:"generateName"`
DisplayName string `json:"displayName"`
Config ChannelConfig `json:"config" required:"true"`
}
func (p *PostableNotificationChannel) UnmarshalJSON(data []byte) error {
@@ -93,7 +93,7 @@ func (p *PostableNotificationChannel) validateName() error {
// Name is immutable, and DisplayName cannot be edited until the routing
// policies and rules referencing it migrate onto Name.
type UpdatableNotificationChannel struct {
Config ChannelConfig `json:"config" required:"true" description:"The replacement provider kind and spec. Name and displayName are immutable and not part of the body."`
Config ChannelConfig `json:"config" required:"true"`
}
func (u *UpdatableNotificationChannel) UnmarshalJSON(data []byte) error {
@@ -115,7 +115,7 @@ func (u *UpdatableNotificationChannel) Validate() error {
// It carries no name because nothing is persisted: the receiver a test builds
// is thrown away once the notification is delivered.
type TestableNotificationChannel struct {
Config ChannelConfig `json:"config" required:"true" description:"The replacement provider kind and spec. Name and displayName are immutable and not part of the body."`
Config ChannelConfig `json:"config" required:"true"`
}
func (t *TestableNotificationChannel) UnmarshalJSON(data []byte) error {
@@ -166,12 +166,12 @@ func decodeStrict(data []byte, target any) error {
// ════════════════════════════════════════════════════════════════════════
type GettableNotificationChannel struct {
Name string `json:"name" required:"true" description:"The immutable DNS-1123 identifier of the channel. Alert rules and routing policies reference displayName, not name."`
DisplayName string `json:"displayName" required:"true" description:"The label shown in the UI. Alert rules and routing policies reference it, not name."`
Config ChannelConfig `json:"config" required:"true" description:"The provider kind and spec, credentials included."`
ID valuer.UUID `json:"id" required:"true" description:"The channel UUID."`
CreatedAt time.Time `json:"createdAt" required:"true" description:"When the channel was created, as an RFC 3339 timestamp."`
UpdatedAt time.Time `json:"updatedAt" required:"true" description:"When the channel was last updated, as an RFC 3339 timestamp."`
Name string `json:"name" required:"true"`
DisplayName string `json:"displayName" required:"true"`
Config ChannelConfig `json:"config" required:"true"`
ID valuer.UUID `json:"id" required:"true"`
CreatedAt time.Time `json:"createdAt" required:"true"`
UpdatedAt time.Time `json:"updatedAt" required:"true"`
}
// ════════════════════════════════════════════════════════════════════════
@@ -179,19 +179,19 @@ type GettableNotificationChannel struct {
// ════════════════════════════════════════════════════════════════════════
// ListedNotificationChannel carries no configuration. The spec, credentials
// included, is returned by create, update and fetch by ID, never by list.
// included, is only returned by a fetch by ID.
type ListedNotificationChannel struct {
ID valuer.UUID `json:"id" required:"true" description:"The channel UUID."`
Name string `json:"name" required:"true" description:"The immutable DNS-1123 identifier of the channel. Alert rules and routing policies reference displayName, not name."`
DisplayName string `json:"displayName" required:"true" description:"The label shown in the UI. Alert rules and routing policies reference it, not name."`
Kind ChannelKind `json:"kind" required:"true" description:"The channel provider kind."`
CreatedAt time.Time `json:"createdAt" required:"true" description:"When the channel was created, as an RFC 3339 timestamp."`
UpdatedAt time.Time `json:"updatedAt" required:"true" description:"When the channel was last updated, as an RFC 3339 timestamp."`
ID valuer.UUID `json:"id" required:"true"`
Name string `json:"name" required:"true"`
DisplayName string `json:"displayName" required:"true"`
Kind ChannelKind `json:"kind" required:"true"`
CreatedAt time.Time `json:"createdAt" required:"true"`
UpdatedAt time.Time `json:"updatedAt" required:"true"`
}
// ListableNotificationChannel is one page of the channel list. Total counts every
// channel the filter matches, not just the ones on the page.
type ListableNotificationChannel struct {
Channels []*ListedNotificationChannel `json:"channels" required:"true" nullable:"false" description:"The channels on this page, without their configuration; fetch a channel by ID for that."`
Total int64 `json:"total" required:"true" description:"The number of channels the filter matches across all pages."`
Channels []*ListedNotificationChannel `json:"channels" required:"true" nullable:"false"`
Total int64 `json:"total" required:"true"`
}

View File

@@ -9,8 +9,8 @@ import (
)
type ChannelConfig struct {
Kind ChannelKind `json:"kind" required:"true" description:"The channel provider kind; one of the supported kinds listed in the enum."`
Spec any `json:"spec" required:"true" description:"The provider-specific configuration; its shape is selected by kind."`
Kind ChannelKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
func (c *ChannelConfig) UnmarshalJSON(data []byte) error {
@@ -71,8 +71,8 @@ func (c ChannelConfig) Validate() error {
// ChannelConfigVariant names one branch of the union. Each instantiation becomes
// its own OpenAPI component with kind pinned to the one value it accepts.
type ChannelConfigVariant[S any] struct {
Kind string `json:"kind" required:"true" description:"The channel provider kind this variant accepts."`
Spec S `json:"spec" required:"true" description:"The provider-specific configuration for this kind."`
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v ChannelConfigVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {

View File

@@ -61,12 +61,12 @@ func (o ChannelListOrder) IsValid() bool {
// ListChannelsParams filters and orders the channel list. Query matches the
// display name case-insensitively.
type ListChannelsParams struct {
Query string `query:"query" json:"query" description:"Case-insensitive match on the display name; at most 1024 characters."`
Kind ChannelKind `query:"kind" json:"kind" description:"Only channels of this provider kind."`
Sort ChannelListSort `query:"sort" json:"sort" description:"The sort column: updated_at (default), created_at or name."`
Order ChannelListOrder `query:"order" json:"order" description:"The sort order: asc or desc (default)."`
Limit int `query:"limit" json:"limit" description:"The page size; defaults to 20 and is capped at 200."`
Offset int `query:"offset" json:"offset" description:"The number of channels to skip; defaults to 0."`
Query string `query:"query" json:"query"`
Kind ChannelKind `query:"kind" json:"kind"`
Sort ChannelListSort `query:"sort" json:"sort"`
Order ChannelListOrder `query:"order" json:"order"`
Limit int `query:"limit" json:"limit"`
Offset int `query:"offset" json:"offset"`
}
func (p *ListChannelsParams) Validate() error {

View File

@@ -45,7 +45,7 @@ func (ChannelRepairAction) Enum() []any {
// RepairChannelParams defaults to a dry run; only apply=true writes anything.
type RepairChannelParams struct {
Apply bool `query:"apply" json:"apply" description:"Apply the diagnosed action; defaults to false, a dry run that only reports it."`
Apply bool `query:"apply" json:"apply"`
}
// ChannelRepair is one channel's diagnosis and the action that makes it
@@ -53,13 +53,13 @@ type RepairChannelParams struct {
// channel itself for none and retype, every part of a split, nothing for a
// delete. Blockers explain why an action was not, or would not be, applied.
type ChannelRepair struct {
ID valuer.UUID `json:"id" required:"true" description:"The UUID of the diagnosed channel."`
Defect ChannelDefect `json:"defect" required:"true" description:"Why v2 cannot read the channel; none when it is readable."`
Detail string `json:"detail" description:"A human-readable explanation of the defect."`
Action ChannelRepairAction `json:"action" required:"true" description:"The repair that makes the channel readable: none, retype, split or delete."`
Blockers []string `json:"blockers,omitempty" description:"Why the action was not, or would not be, applied."`
Channels []*ListedNotificationChannel `json:"channels" description:"The channels that exist once the action is applied."`
Applied bool `json:"applied" required:"true" description:"Whether the action was written; false on a dry run."`
ID valuer.UUID `json:"id" required:"true"`
Defect ChannelDefect `json:"defect" required:"true"`
Detail string `json:"detail"`
Action ChannelRepairAction `json:"action" required:"true"`
Blockers []string `json:"blockers,omitempty"`
Channels []*ListedNotificationChannel `json:"channels"`
Applied bool `json:"applied" required:"true"`
}
// Diagnose reads the channel the way v2 does and reports the first defect in

View File

@@ -29,13 +29,13 @@ type DashboardView struct {
types.Identifiable
types.TimeAuditable
Name string `bun:"name,type:text,notnull" json:"name" required:"true" description:"The view name."`
Data DashboardViewData `bun:"data,type:text,notnull" json:"data" required:"true" description:"The saved list filter and sort."`
OrgID valuer.UUID `bun:"org_id,type:text,notnull" json:"orgId" required:"true" description:"The organization that owns the view."`
Name string `bun:"name,type:text,notnull" json:"name" required:"true"`
Data DashboardViewData `bun:"data,type:text,notnull" json:"data" required:"true"`
OrgID valuer.UUID `bun:"org_id,type:text,notnull" json:"orgId" required:"true"`
}
type DashboardViewData struct {
Version string `json:"version" required:"true" description:"The view data schema version. Must be v1."`
Version string `json:"version" required:"true"`
ListFilter
}
@@ -52,8 +52,8 @@ func (d *DashboardViewData) Validate() error {
// ════════════════════════════════════════════════════════════════════════
type PostableDashboardView struct {
Name string `json:"name" required:"true" maxLength:"64" description:"The view name, at most 64 characters."`
Data DashboardViewData `json:"data" required:"true" description:"The list filter and sort to save."`
Name string `json:"name" required:"true"`
Data DashboardViewData `json:"data" required:"true"`
}
func (p *PostableDashboardView) UnmarshalJSON(data []byte) error {
@@ -103,7 +103,7 @@ func (v *DashboardView) Update(updateable UpdatableDashboardView) {
// ════════════════════════════════════════════════════════════════════════
type ListableDashboardView struct {
Views []*DashboardView `json:"views" required:"true" nullable:"false" description:"The saved dashboard list views."`
Views []*DashboardView `json:"views" required:"true" nullable:"false"`
}
// ════════════════════════════════════════════════════════════════════════

View File

@@ -4,10 +4,10 @@ package dashboardtypes
// references a given metric, along with the group-by and filter labels used for
// that metric so callers can reason about the impact of dropping its labels.
type DashboardPanelRef struct {
DashboardID string `json:"dashboardId" required:"true" description:"The id of the dashboard containing the panel."`
DashboardName string `json:"dashboardName" required:"true" description:"The dashboard name."`
PanelID string `json:"panelId" required:"true" description:"The panel id within the dashboard."`
PanelName string `json:"panelName" required:"true" description:"The panel title."`
GroupBy []string `json:"groupBy,omitempty" description:"The metric labels the panel groups by."`
FilterBy []string `json:"filterBy,omitempty" description:"The metric labels the panel filters on."`
DashboardID string `json:"dashboardId" required:"true"`
DashboardName string `json:"dashboardName" required:"true"`
PanelID string `json:"panelId" required:"true"`
PanelName string `json:"panelName" required:"true"`
GroupBy []string `json:"groupBy,omitempty"`
FilterBy []string `json:"filterBy,omitempty"`
}

View File

@@ -52,9 +52,9 @@ func (o ListOrder) IsValid() bool {
var ErrCodeDashboardListInvalid = errors.MustNewCode("dashboard_list_invalid")
type ListFilter struct {
Query string `query:"query" json:"query" description:"A filter expression over the reserved keys name, description, created_at, updated_at, created_by and locked, and over tag keys, using the SigNoz filter grammar: comparisons such as =, !=, CONTAINS, LIKE, IN, < and >, single-quoted strings, RFC3339 timestamps in quotes for created_at and updated_at, combined with AND, OR and NOT, for example name CONTAINS 'overview' AND created_at >= '2026-10-21T00:00:00Z'. At most 1024 characters. Empty matches every dashboard."`
Sort ListSort `query:"sort" json:"sort" description:"The sort field: updated_at (default), created_at or name."`
Order ListOrder `query:"order" json:"order" description:"The sort direction: asc or desc (default)."`
Query string `query:"query" json:"query"`
Sort ListSort `query:"sort" json:"sort"`
Order ListOrder `query:"order" json:"order"`
}
func (f *ListFilter) Validate() error {
@@ -75,8 +75,8 @@ func (f *ListFilter) Validate() error {
type ListDashboardsV2Params struct {
ListFilter
Limit int `query:"limit" description:"The page size. Defaults to 20 and is capped at 200."`
Offset int `query:"offset" description:"The number of dashboards to skip. Defaults to 0."`
Limit int `query:"limit"`
Offset int `query:"offset"`
}
func (p *ListDashboardsV2Params) Validate() error {
@@ -114,22 +114,22 @@ type listedDashboardV2 struct {
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `json:"orgId" required:"true" description:"The organization that owns the dashboard."`
Locked bool `json:"locked" required:"true" description:"Whether the dashboard is locked against update and patch."`
Source Source `json:"source" required:"true" description:"Where the dashboard came from: user or integration. System dashboards are not listed."`
SchemaVersion string `json:"schemaVersion" required:"true" description:"The dashboard schema version, v6 for v2 dashboards."`
Name string `json:"name" required:"true" description:"The dashboard's unique, immutable name."`
Image string `json:"image,omitempty" description:"Icon or logo shown in the dashboard list: the path of an icon or logo bundled with the UI under /assets/Icons/ or /assets/Logos/, or a base64 image data URI. Omitted when none is set."`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false" description:"The tags attached to the dashboard."`
Spec listedDashboardV2Spec `json:"spec" required:"true" description:"The display part of the dashboard spec. Panels, variables and layouts are not included in list rows."`
OrgID valuer.UUID `json:"orgId" required:"true"`
Locked bool `json:"locked" required:"true"`
Source Source `json:"source" required:"true"`
SchemaVersion string `json:"schemaVersion" required:"true"`
Name string `json:"name" required:"true"`
Image string `json:"image,omitempty"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
Spec listedDashboardV2Spec `json:"spec" required:"true"`
// Legacy marks a dashboard whose stored data is not yet in the v2 (perses)
// schema. Such rows are extracted best-effort from the v1 shape so the list
// still surfaces them; callers should route them to the legacy view.
Legacy bool `json:"legacy" required:"true" description:"Whether the dashboard is still in the v1 schema. It is listed but cannot be fetched or edited through the v2 endpoints."`
Legacy bool `json:"legacy" required:"true"`
}
type listedDashboardV2Spec struct {
Display Display `json:"display,omitempty" description:"The dashboard name and description."`
Display Display `json:"display,omitempty"`
}
func newListedDashboardV2(v2 *DashboardV2) *listedDashboardV2 {
@@ -184,10 +184,10 @@ func newLegacyListedDashboardV2(storable *StorableDashboard, tags []*tagtypes.Ta
}
type ListableDashboardV2 struct {
Dashboards []*listedDashboardV2 `json:"dashboards" required:"true" nullable:"false" description:"The dashboards on this page."`
Total int64 `json:"total" required:"true" description:"The number of dashboards matching the filter across every page."`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false" description:"Every dashboard tag in the organization, for building filters."`
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false" description:"The filter keys that address dashboard columns rather than tags."`
Dashboards []*listedDashboardV2 `json:"dashboards" required:"true" nullable:"false"`
Total int64 `json:"total" required:"true"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false"`
}
func NewListableDashboardV2(dashboards []*StorableDashboard, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) *ListableDashboardV2 {
@@ -207,14 +207,14 @@ func NewListableDashboardV2(dashboards []*StorableDashboard, total int64, tagsBy
// state. Only the per-user list endpoint emits this; the pure list omits pins.
type listedDashboardForUserV2 struct {
listedDashboardV2
Pinned bool `json:"pinned" required:"true" description:"Whether the calling user has pinned the dashboard."`
Pinned bool `json:"pinned" required:"true"`
}
type ListableDashboardForUserV2 struct {
Dashboards []*listedDashboardForUserV2 `json:"dashboards" required:"true" nullable:"false" description:"The dashboards on this page, each with the calling user's pin state."`
Total int64 `json:"total" required:"true" description:"The number of dashboards matching the filter across every page."`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false" description:"Every dashboard tag in the organization, for building filters."`
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false" description:"The filter keys that address dashboard columns rather than tags."`
Dashboards []*listedDashboardForUserV2 `json:"dashboards" required:"true" nullable:"false"`
Total int64 `json:"total" required:"true"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false"`
}
// StorableDashboardWithPinInfo is the per-row shape Store.ListForUser returns: the dashboard

View File

@@ -68,14 +68,14 @@ type DashboardV2 struct {
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `json:"orgId" required:"true" description:"The organization that owns the dashboard."`
Locked bool `json:"locked" required:"true" description:"Whether the dashboard is locked. A locked dashboard rejects update and patch until it is unlocked."`
Source Source `json:"source" required:"true" description:"Where the dashboard came from: user, integration or system. Only user dashboards can be modified."`
OrgID valuer.UUID `json:"orgId" required:"true"`
Locked bool `json:"locked" required:"true"`
Source Source `json:"source" required:"true"`
DashboardV2MetadataBase
Name string `json:"name" required:"true" description:"The dashboard's unique, immutable name, a DNS-1123 label."`
Tags []*tagtypes.Tag `json:"tags" required:"true" description:"The key/value tags attached to the dashboard, for example key team with value payments."`
Spec DashboardSpec `json:"spec" required:"true" description:"The dashboard content: display, variables, panels, layouts and links."`
Name string `json:"name" required:"true"`
Tags []*tagtypes.Tag `json:"tags" required:"true"`
Spec DashboardSpec `json:"spec" required:"true"`
}
func (d *DashboardV2) ErrIfNotMutable() error {
@@ -198,8 +198,8 @@ func nextCloneDisplayName(name string) string {
}
type DashboardV2MetadataBase struct {
SchemaVersion string `json:"schemaVersion" required:"true" pattern:"^v6$" description:"The dashboard schema version. Must be v6."`
Image string `json:"image" description:"Icon or logo shown in the dashboard list: the path of an icon or logo bundled with the UI under /assets/Icons/ or /assets/Logos/ (only the prefix is validated), or a base64 image data URI for png, jpeg, gif, webp, avif or svg. Empty for none."`
SchemaVersion string `json:"schemaVersion" required:"true"`
Image string `json:"image"`
}
func (m DashboardV2MetadataBase) validateImage() error {
@@ -222,10 +222,10 @@ func (m DashboardV2MetadataBase) validateImage() error {
type PostableDashboardV2 struct {
DashboardV2MetadataBase
Name string `json:"name" description:"The dashboard's unique name, a DNS-1123 label (lowercase letters, digits and hyphens, at most 63 characters). Immutable after creation. Must be empty when generateName is true."`
GenerateName bool `json:"generateName" description:"When true the server derives name from spec.display.name plus a random suffix. Requires an empty name and a non-empty spec.display.name."`
Tags []tagtypes.PostableTag `json:"tags" required:"true" maxItems:"10" description:"Up to 10 key/value tags, for example key team with value payments. Keys must not be one of the reserved list filter keys: name, description, created_at, updated_at, created_by, locked, source. Use [] for none."`
Spec DashboardSpec `json:"spec" required:"true" description:"The dashboard content: display, variables, panels, layouts and links."`
Name string `json:"name"`
GenerateName bool `json:"generateName"`
Tags []tagtypes.PostableTag `json:"tags" required:"true"`
Spec DashboardSpec `json:"spec" required:"true"`
}
func (postable PostableDashboardV2) NewDashboardV2(orgID valuer.UUID, createdBy string, source Source) (*DashboardV2, error) {
@@ -371,14 +371,14 @@ type GettableDashboardV2 struct {
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `json:"orgId" required:"true" description:"The organization that owns the dashboard."`
Locked bool `json:"locked" required:"true" description:"Whether the dashboard is locked. A locked dashboard rejects update and patch until it is unlocked."`
Source Source `json:"source" required:"true" description:"Where the dashboard came from: user, integration or system. Only user dashboards can be modified."`
OrgID valuer.UUID `json:"orgId" required:"true"`
Locked bool `json:"locked" required:"true"`
Source Source `json:"source" required:"true"`
DashboardV2MetadataBase
Name string `json:"name" required:"true" description:"The dashboard's unique, immutable name, a DNS-1123 label."`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" description:"The key/value tags attached to the dashboard, for example key team with value payments."`
Spec DashboardSpec `json:"spec" required:"true" description:"The dashboard content: display, variables, panels, layouts and links."`
Name string `json:"name" required:"true"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true"`
Spec DashboardSpec `json:"spec" required:"true"`
}
func (d DashboardV2) ToGettableDashboardV2() GettableDashboardV2 {
@@ -402,14 +402,14 @@ type GettableSystemDashboard struct {
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `json:"orgId" required:"true" description:"The organization that owns the dashboard."`
Locked bool `json:"locked" required:"true" description:"Whether the dashboard is locked. A locked dashboard rejects update and patch until it is unlocked."`
Source Source `json:"source" required:"true" description:"Where the dashboard came from: user, integration or system. Only user dashboards can be modified."`
OrgID valuer.UUID `json:"orgId" required:"true"`
Locked bool `json:"locked" required:"true"`
Source Source `json:"source" required:"true"`
DashboardV2MetadataBase
Name string `json:"name" required:"true" description:"The dashboard's unique, immutable name, a DNS-1123 label."`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" description:"The key/value tags attached to the dashboard, for example key team with value payments."`
Spec DashboardSpec `json:"spec" required:"true" description:"The dashboard content: display, variables, panels, layouts and links."`
Name string `json:"name" required:"true"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true"`
Spec DashboardSpec `json:"spec" required:"true"`
}
func (d DashboardV2) ToGettableSystemDashboard() GettableSystemDashboard {
@@ -456,9 +456,9 @@ type StorableDashboardV2Metadata = DashboardV2MetadataBase
type UpdatableDashboardV2 struct {
DashboardV2MetadataBase
Name string `json:"name" required:"true" description:"The dashboard's current name. The name is immutable, so a different value is rejected."`
Tags []tagtypes.PostableTag `json:"tags" required:"true" maxItems:"10" description:"The full replacement tag set, up to 10 key/value tags, for example key team with value payments. Keys must not be one of the reserved list filter keys: name, description, created_at, updated_at, created_by, locked, source."`
Spec DashboardSpec `json:"spec" required:"true" description:"The full replacement dashboard content: display, variables, panels, layouts and links."`
Name string `json:"name" required:"true"`
Tags []tagtypes.PostableTag `json:"tags" required:"true"`
Spec DashboardSpec `json:"spec" required:"true"`
}
func (u *UpdatableDashboardV2) UnmarshalJSON(data []byte) error {

View File

@@ -21,13 +21,13 @@ import (
// dropped: SigNoz never reads it (queries carry their own signal/source), so
// the drift test allowlists it as an intentional omission.
type DashboardSpec struct {
Display Display `json:"display" required:"true" description:"The dashboard title and description."`
Variables []Variable `json:"variables" required:"true" nullable:"false" description:"The dashboard variables, each a ListVariable or TextVariable envelope, referenced in queries as $name. Names must be unique. Use [] for none."`
Panels map[string]*Panel `json:"panels" required:"true" nullable:"false" description:"The panels keyed by panel id. An id has up to 75 characters from letters, digits, dot, underscore and hyphen. A panel is shown only where a layout item references it as #/spec/panels/<id>. Use {} for none."`
Layouts []Layout `json:"layouts" required:"true" nullable:"false" description:"The Grid layouts that place panels, at most 500. Each panel can be placed at most once. Use [] for none."`
Duration common.DurationString `json:"duration" description:"The default time range, as a Prometheus duration such as 1h or 7d. Stored, but not applied by the UI."`
RefreshInterval common.DurationString `json:"refreshInterval" description:"The auto refresh interval, as a Prometheus duration such as 30s. Stored, but not applied by the UI."`
Links []Link `json:"links,omitzero" description:"External links stored with the dashboard. Not shown by the UI."`
Display Display `json:"display" required:"true"`
Variables []Variable `json:"variables" required:"true" nullable:"false"`
Panels map[string]*Panel `json:"panels" required:"true" nullable:"false"`
Layouts []Layout `json:"layouts" required:"true" nullable:"false"`
Duration common.DurationString `json:"duration"`
RefreshInterval common.DurationString `json:"refreshInterval"`
Links []Link `json:"links,omitzero"`
}
// ══════════════════════════════════════════════

View File

@@ -12,7 +12,7 @@ import (
// PatchableDashboardV2 is an RFC 6902 patch request.
type PatchableDashboardV2 struct {
// Ops shapes the OpenAPI schema; tagged so swaggest reflects it.
Ops []JSONPatchOperation `json:"ops" description:"The RFC 6902 operations, applied in order against the dashboard's postable shape."`
Ops []JSONPatchOperation `json:"ops"`
// patch holds the decoded payload, set by UnmarshalJSON.
patch jsonpatch.Patch
}
@@ -29,7 +29,7 @@ func (PatchableDashboardV2) PrepareJSONSchema(s *jsonschema.Schema) error {
}
type JSONPatchOperation struct {
Op PatchOp `json:"op" required:"true" description:"The RFC 6902 verb: add, remove, replace, move, copy or test. remove on a missing path is a no-op and add creates missing parents."`
Op PatchOp `json:"op" required:"true"`
Path string `json:"path" required:"true" description:"JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0, /tags/-."`
// `value` is required for add/replace/test.
Value any `json:"value" description:"Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable; /spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /spec/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy."`

View File

@@ -19,8 +19,8 @@ import (
// ══════════════════════════════════════════════
type PanelPlugin struct {
Kind PanelPluginKind `json:"kind" required:"true" description:"The panel kind. It decides the shape of spec and which query kinds the panel accepts."`
Spec any `json:"spec" required:"true" description:"The options for this panel kind. {} accepts every default."`
Kind PanelPluginKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
// PrepareJSONSchema marks the envelope with x-signoz-discriminator;
@@ -75,8 +75,8 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
}
type PanelPluginVariant[S any] struct {
Kind string `json:"kind" required:"true" description:"The panel kind."`
Spec S `json:"spec" required:"true" description:"The options for this panel kind. {} accepts every default."`
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v PanelPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
@@ -88,8 +88,8 @@ func (v PanelPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
// ══════════════════════════════════════════════
type QueryPlugin struct {
Kind QueryPluginKind `json:"kind" required:"true" description:"The query kind. It decides the shape of spec."`
Spec any `json:"spec" required:"true" description:"The kind-specific query spec. A builder query's shape depends on its signal."`
Kind QueryPluginKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
func (QueryPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
@@ -168,8 +168,8 @@ func wrapEnvelope(queryType qb.QueryType, spec any) qb.CompositeQuery {
}
type QueryPluginVariant[S any] struct {
Kind string `json:"kind" required:"true" description:"The query kind."`
Spec S `json:"spec" required:"true" description:"The kind-specific query spec."`
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v QueryPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
@@ -181,8 +181,8 @@ func (v QueryPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
// ══════════════════════════════════════════════
type VariablePlugin struct {
Kind VariablePluginKind `json:"kind" required:"true" description:"The value source kind. It decides the shape of spec."`
Spec any `json:"spec" required:"true" description:"The kind-specific value source spec."`
Kind VariablePluginKind `json:"kind" required:"true"`
Spec any `json:"spec" required:"true"`
}
func (VariablePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
@@ -220,8 +220,8 @@ func (VariablePlugin) JSONSchemaOneOf() []any {
}
type VariablePluginVariant[S any] struct {
Kind string `json:"kind" required:"true" description:"The value source kind."`
Spec S `json:"spec" required:"true" description:"The kind-specific value source spec."`
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {

Some files were not shown because too many files have changed in this diff Show More