mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-27 11:30:41 +00:00
Compare commits
4 Commits
test/dashb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
070343a6c0 | ||
|
|
19af7782d9 | ||
|
|
119ab97240 | ||
|
|
e163ec3b1b |
3675
docs/api/openapi.yml
3675
docs/api/openapi.yml
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
export const DEFAULT_VISIBLE_ITEMS_COUNT = 10;
|
||||
@@ -2,10 +2,9 @@ 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;
|
||||
|
||||
@@ -6,44 +6,6 @@
|
||||
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);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
QuickFiltersSource,
|
||||
} from '../../../types';
|
||||
import CheckboxFilterV2 from './CheckboxFilterV2';
|
||||
import { DEFAULT_VISIBLE_ITEMS_COUNT } from '../constants';
|
||||
|
||||
export const DEFAULT_FILTER: IQuickFiltersConfig = {
|
||||
type: FiltersType.CHECKBOX,
|
||||
@@ -30,6 +31,13 @@ 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)[];
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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 {
|
||||
@@ -17,6 +16,7 @@ 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,14 +45,8 @@ 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);
|
||||
|
||||
@@ -121,6 +115,14 @@ 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
|
||||
@@ -166,19 +168,22 @@ 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}
|
||||
actionsClassName={classNames(styles.sectionActions, {
|
||||
[styles.sectionActionsPinned]: isSearchOpen,
|
||||
})}
|
||||
resetActionClassName={
|
||||
isSearchOpen ? styles.sectionActionHoverOnly : undefined
|
||||
}
|
||||
onToggleOpen={onToggleOpen}
|
||||
onToggleSearch={handleToggleSearch}
|
||||
canReset={isSomeFilterPresentForCurrentAttribute}
|
||||
onToggleOpen={handleToggleOpen}
|
||||
onClear={onClear}
|
||||
/>
|
||||
{isOpen && isLoading && !hasLoadedOnce.current && (
|
||||
@@ -188,10 +193,9 @@ export default function CheckboxFilterV2(
|
||||
)}
|
||||
{isOpen && (!isLoading || hasLoadedOnce.current) && (
|
||||
<>
|
||||
{isSearchOpen && (
|
||||
{isSearchShown && (
|
||||
<section className={styles.search}>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Filter values"
|
||||
onChange={(e): void => setSearchTextDebounced(e.target.value)}
|
||||
disabled={isFilterDisabled}
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
import { useState } from 'react';
|
||||
import { Tooltip } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ChevronDown, ChevronRight, Search, Undo2 } from '@signozhq/icons';
|
||||
import { ChevronDown, ChevronRight, 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;
|
||||
actionsClassName?: string;
|
||||
resetActionClassName?: string;
|
||||
canReset: boolean;
|
||||
onToggleOpen: () => void;
|
||||
onToggleSearch: () => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function CheckboxFilterV2Header({
|
||||
title,
|
||||
isOpen,
|
||||
actionsClassName,
|
||||
resetActionClassName,
|
||||
canReset,
|
||||
onToggleOpen,
|
||||
onToggleSearch,
|
||||
onClear,
|
||||
}: CheckboxFilterHeaderProps): JSX.Element {
|
||||
const [isTitleTruncated, setIsTitleTruncated] = useState(false);
|
||||
@@ -64,21 +58,14 @@ export function CheckboxFilterV2Header({
|
||||
)}
|
||||
<Tooltip title={isTitleTruncated ? title : undefined}>{titleText}</Tooltip>
|
||||
</section>
|
||||
{isOpen && (
|
||||
<section className={classNames(styles.rightAction, actionsClassName)}>
|
||||
{isOpen && canReset && (
|
||||
<section className={styles.rightAction}>
|
||||
<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>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
buildQueryBuilderOverrides,
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
EXTRA_VALUES,
|
||||
getFilterFromCall,
|
||||
mockFieldsValuesAPI,
|
||||
renderWithFilter,
|
||||
@@ -49,7 +50,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
const values =
|
||||
searchTextReceived === ''
|
||||
? ['production', 'staging', 'development']
|
||||
? ['production', 'staging', 'development', ...EXTRA_VALUES]
|
||||
: ['production'];
|
||||
|
||||
return res(
|
||||
@@ -79,7 +80,6 @@ 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,6 +92,41 @@ 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 () => {
|
||||
@@ -111,7 +146,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: ['production'],
|
||||
stringValues: ['staging', 'development'],
|
||||
stringValues: ['staging', 'development', ...EXTRA_VALUES],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
@@ -163,7 +198,6 @@ 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');
|
||||
|
||||
@@ -191,7 +225,10 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: searchText === '' ? ['prod', 'staging'] : ['prod-match'],
|
||||
stringValues:
|
||||
searchText === ''
|
||||
? ['prod', 'staging', ...EXTRA_VALUES]
|
||||
: ['prod-match'],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
@@ -213,7 +250,6 @@ 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');
|
||||
|
||||
@@ -239,7 +275,8 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: searchText === '' ? ['prod', 'staging'] : [],
|
||||
stringValues:
|
||||
searchText === '' ? ['prod', 'staging', ...EXTRA_VALUES] : [],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
@@ -258,7 +295,6 @@ 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');
|
||||
|
||||
@@ -335,7 +371,9 @@ 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'] : ['pod-a-v2']; // filtered to match 'pod-a'
|
||||
searchText === ''
|
||||
? ['pod-a-v2', 'pod-b-v2', 'pod-c-v2', ...EXTRA_VALUES]
|
||||
: ['pod-a-v2']; // filtered to match 'pod-a'
|
||||
|
||||
return res(
|
||||
ctx.status(200),
|
||||
@@ -366,7 +404,6 @@ 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');
|
||||
|
||||
@@ -501,6 +538,56 @@ 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', () => {
|
||||
@@ -596,7 +683,7 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
expect(screen.getByTestId('checkbox-filter-clear-all')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the reset action when expanded even with no active filter', async () => {
|
||||
it('hides reset when the section has no filter applied', async () => {
|
||||
mockFieldsValuesAPI({
|
||||
stringValues: ['production'],
|
||||
});
|
||||
@@ -611,45 +698,9 @@ describe('CheckboxFilterV2 - interactions', () => {
|
||||
|
||||
await screen.findByTestId('checkbox-value-row-production');
|
||||
|
||||
// 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();
|
||||
expect(
|
||||
screen.queryByTestId('checkbox-filter-clear-all'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onFilterChange when clear clicked', async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import CheckboxFilterV2 from '../CheckboxFilterV2';
|
||||
import {
|
||||
DEFAULT_FILTER,
|
||||
DEFAULT_USE_FIELD_APIS,
|
||||
EXTRA_VALUES,
|
||||
mockFieldsValuesAPI,
|
||||
mockFieldsValuesAPILoading,
|
||||
setupServer,
|
||||
@@ -89,7 +90,7 @@ describe('CheckboxFilterV2 - states', () => {
|
||||
data: {
|
||||
values: {
|
||||
relatedValues: [],
|
||||
stringValues: ['production', 'staging'],
|
||||
stringValues: ['production', 'staging', ...EXTRA_VALUES],
|
||||
numberValues: [],
|
||||
},
|
||||
},
|
||||
@@ -110,7 +111,6 @@ 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');
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ describe('CheckboxFilterV2Header', () => {
|
||||
const defaultProps = {
|
||||
title: 'Environment',
|
||||
isOpen: false,
|
||||
canReset: true,
|
||||
onToggleOpen: jest.fn(),
|
||||
onToggleSearch: jest.fn(),
|
||||
onClear: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -30,12 +30,9 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(header).toHaveAttribute('data-state', 'closed');
|
||||
});
|
||||
|
||||
it('does not render the section actions when collapsed', () => {
|
||||
it('does not render reset 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();
|
||||
@@ -50,14 +47,19 @@ describe('CheckboxFilterV2Header', () => {
|
||||
expect(header).toHaveAttribute('data-state', 'open');
|
||||
});
|
||||
|
||||
it('renders both search and reset actions when expanded', () => {
|
||||
it('renders reset when the section has a filter applied', () => {
|
||||
render(<CheckboxFilterV2Header {...defaultProps} isOpen />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('checkbox-filter-search-toggle'),
|
||||
).toBeInTheDocument();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('interactions', () => {
|
||||
@@ -99,25 +101,6 @@ 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();
|
||||
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
|
||||
@@ -61,12 +62,20 @@ 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 : (): void => setIsViewsListOpen(true)
|
||||
}
|
||||
onOpenViews={isViewsListVisible ? undefined : openViews}
|
||||
isViewsOpen={isViewsListOpen}
|
||||
onToggleViews={isViewsListPinned ? undefined : toggleViews}
|
||||
onCollapse={hasQuickFilters ? undefined : onToggleFilters}
|
||||
/>
|
||||
) : undefined;
|
||||
@@ -77,6 +86,7 @@ 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}>
|
||||
@@ -98,9 +108,7 @@ function QuickFiltersLayout({
|
||||
{hasSavedViews && isViewsListVisible && (
|
||||
<SavedViewsPanel
|
||||
{...savedViewProps}
|
||||
onClose={
|
||||
isViewsListPinned ? undefined : (): void => setIsViewsListOpen(false)
|
||||
}
|
||||
onClose={isViewsListPinned ? undefined : closeViews}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -218,6 +218,62 @@ 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
|
||||
@@ -258,6 +314,9 @@ 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();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
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;
|
||||
}, []);
|
||||
}
|
||||
@@ -71,6 +71,7 @@ export const AIAssistantOpenSource = {
|
||||
Shortcut: 'shortcut',
|
||||
Cmdk: 'cmdk',
|
||||
TraceDetails: 'trace_details',
|
||||
BottomStrip: 'bottom_strip',
|
||||
} as const;
|
||||
export type AIAssistantOpenSource =
|
||||
(typeof AIAssistantOpenSource)[keyof typeof AIAssistantOpenSource];
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
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,
|
||||
@@ -37,6 +44,14 @@ 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;
|
||||
}
|
||||
@@ -57,7 +72,7 @@ function AskNoz(): JSX.Element | null {
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Noz size={16} />}
|
||||
onClick={(): void => openAIAssistant()}
|
||||
onClick={handleOpen}
|
||||
aria-label={
|
||||
showPendingBadge
|
||||
? `Ask Noz, ${pendingUserInputCount} ${
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
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';
|
||||
@@ -6,6 +11,10 @@ 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(),
|
||||
@@ -91,6 +100,17 @@ 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', () => {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
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] =
|
||||
@@ -14,7 +21,13 @@ function SupportButton(): JSX.Element | null {
|
||||
}
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (chatSupport === ChatSupportState.NeedsCard) {
|
||||
const needsCard = chatSupport === ChatSupportState.NeedsCard;
|
||||
void logEvent(BottomStripEvents.SupportClicked, {
|
||||
[BottomStripEventKeys.Type]: needsCard
|
||||
? BottomStripSupportType.AddCard
|
||||
: BottomStripSupportType.Pylon,
|
||||
});
|
||||
if (needsCard) {
|
||||
setIsAddCreditCardModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
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(),
|
||||
@@ -37,6 +44,9 @@ 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', () => {
|
||||
@@ -64,6 +74,9 @@ describe('SupportButton', () => {
|
||||
|
||||
expect(getByText(MODAL_TITLE)).toBeInTheDocument();
|
||||
expect(window.Pylon).not.toHaveBeenCalled();
|
||||
expect(logEvent).toHaveBeenCalledWith(BottomStripEvents.SupportClicked, {
|
||||
type: BottomStripSupportType.AddCard,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
12
frontend/src/container/BottomStrip/events.ts
Normal file
12
frontend/src/container/BottomStrip/events.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export enum BottomStripEvents {
|
||||
SupportClicked = 'Bottom Strip: Support clicked',
|
||||
}
|
||||
|
||||
export enum BottomStripEventKeys {
|
||||
Type = 'type',
|
||||
}
|
||||
|
||||
export enum BottomStripSupportType {
|
||||
Pylon = 'pylon',
|
||||
AddCard = 'add_card',
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SavedViewCreatePermission,
|
||||
SavedViewListPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
import {
|
||||
MY_VIEW_NAME,
|
||||
@@ -19,11 +20,13 @@ 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';
|
||||
|
||||
@@ -32,11 +35,16 @@ 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 {
|
||||
@@ -49,12 +57,27 @@ 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 handleSave = useSaveNewView(createView);
|
||||
const saveNewView = useSaveNewView(createView);
|
||||
const handleSave = useCallback(
|
||||
(displayName: string): Promise<boolean> =>
|
||||
saveNewView(
|
||||
displayName,
|
||||
modalMode === 'saveAsNew'
|
||||
? SavedViewCreatedFrom.SaveAsNew
|
||||
: SavedViewCreatedFrom.Header,
|
||||
),
|
||||
[saveNewView, modalMode],
|
||||
);
|
||||
|
||||
const handleUpdate = useCallback(async (): Promise<void> => {
|
||||
if (view && (await updateView(view))) {
|
||||
@@ -72,6 +95,9 @@ 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
|
||||
@@ -112,10 +138,17 @@ function SavedViewsHeader({
|
||||
className={styles.skeleton}
|
||||
testId="saved-views-loading"
|
||||
/>
|
||||
) : isReadDenied ? (
|
||||
<AuthZTooltip checks={[readPermission]}>{nameChip}</AuthZTooltip>
|
||||
) : (
|
||||
<Tooltip title={errorMessage ?? name}>{nameChip}</Tooltip>
|
||||
<SavedViewsNameToggle
|
||||
isOpen={isViewsOpen}
|
||||
onToggle={canToggleViews ? onToggleViews : undefined}
|
||||
>
|
||||
{isReadDenied ? (
|
||||
<AuthZTooltip checks={[readPermission]}>{nameChip}</AuthZTooltip>
|
||||
) : (
|
||||
<Tooltip title={errorMessage ?? name}>{nameChip}</Tooltip>
|
||||
)}
|
||||
</SavedViewsNameToggle>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
@@ -35,7 +36,7 @@ function SavedViewsList({
|
||||
search: string;
|
||||
}): JSX.Element {
|
||||
const { viewKey } = useGetSavedViewParams();
|
||||
const { selectView, clearView, createView, isSaving } =
|
||||
const { selectView, clearView, resetView, createView, isSaving } =
|
||||
useSavedViewActions(source);
|
||||
const saveNewView = useSaveNewView(createView);
|
||||
const { createdByMe, createdByOthers, isLoading, isError, refetch } =
|
||||
@@ -102,10 +103,10 @@ function SavedViewsList({
|
||||
return;
|
||||
}
|
||||
if (pendingAction.view.id === viewKey) {
|
||||
clearView();
|
||||
resetView();
|
||||
}
|
||||
setPendingAction(null);
|
||||
}, [pendingAction, deleteView, viewKey, clearView]);
|
||||
}, [pendingAction, deleteView, viewKey, resetView]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -141,7 +142,9 @@ function SavedViewsList({
|
||||
<SavedViewsEmptyState
|
||||
isSearching={search.trim().length > 0}
|
||||
isSaving={isSaving}
|
||||
onSave={saveNewView}
|
||||
onSave={(displayName): Promise<boolean> =>
|
||||
saveNewView(displayName, SavedViewCreatedFrom.EmptyState)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
.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;
|
||||
}
|
||||
36
frontend/src/container/SavedViews/SavedViewsNameToggle.tsx
Normal file
36
frontend/src/container/SavedViews/SavedViewsNameToggle.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
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;
|
||||
@@ -0,0 +1,322 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,7 @@ function renderHeader(
|
||||
<SavedViewsHeader
|
||||
source={SavedviewtypesSourceDTO.traces}
|
||||
onOpenViews={jest.fn()}
|
||||
onToggleViews={jest.fn()}
|
||||
/>,
|
||||
url,
|
||||
);
|
||||
@@ -262,4 +263,46 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,11 +30,14 @@ 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,
|
||||
);
|
||||
@@ -45,8 +48,9 @@ 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));
|
||||
const rendered = renderHeader(viewUrl(PATH, view), isViewsOpen);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
|
||||
view.spec.displayName,
|
||||
@@ -226,4 +230,36 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,11 +19,15 @@ 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;
|
||||
@@ -85,20 +89,38 @@ 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(204));
|
||||
return res(ctx.status(updateStatus));
|
||||
}),
|
||||
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(204));
|
||||
return res(ctx.status(deleteStatus));
|
||||
}),
|
||||
);
|
||||
return requests;
|
||||
|
||||
23
frontend/src/container/SavedViews/events.ts
Normal file
23
frontend/src/container/SavedViews/events.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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',
|
||||
}
|
||||
@@ -2,14 +2,15 @@ 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) => Promise<boolean> {
|
||||
): (displayName: string, from: SavedViewCreatedFrom) => Promise<boolean> {
|
||||
return useCallback(
|
||||
async (displayName: string): Promise<boolean> => {
|
||||
if (!(await createView(displayName))) {
|
||||
async (displayName: string, from: SavedViewCreatedFrom): Promise<boolean> => {
|
||||
if (!(await createView(displayName, from))) {
|
||||
return false;
|
||||
}
|
||||
toast.success('View created', {
|
||||
|
||||
@@ -17,6 +17,7 @@ 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';
|
||||
@@ -30,12 +31,18 @@ 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
|
||||
@@ -50,6 +57,8 @@ 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({
|
||||
@@ -107,13 +116,16 @@ export function useSavedViewActions(
|
||||
}
|
||||
}
|
||||
selectView(view);
|
||||
logSavedViewEvent(SavedViewEvents.ChangesDiscarded, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
},
|
||||
[optionsDataSource, preferencesSlice, selectView],
|
||||
[optionsDataSource, preferencesSlice, selectView, logSavedViewEvent],
|
||||
);
|
||||
|
||||
// The query builder redirects only ever add params, so the url is rebuilt
|
||||
// here; the time range and everything else a view does not set stays.
|
||||
const clearView = useCallback((): void => {
|
||||
const resetView = useCallback((): void => {
|
||||
clearLastUsedView();
|
||||
|
||||
const params = getUnstableCurrentSearchParams();
|
||||
@@ -121,8 +133,18 @@ 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): Promise<string | undefined> => {
|
||||
async (
|
||||
displayName: string,
|
||||
from: SavedViewCreatedFrom,
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const { data } = await createSavedView({
|
||||
data: {
|
||||
@@ -137,6 +159,10 @@ 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(
|
||||
@@ -158,6 +184,7 @@ export function useSavedViewActions(
|
||||
stagedQuery,
|
||||
currentQuery,
|
||||
setLastUsedView,
|
||||
logSavedViewEvent,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -173,6 +200,9 @@ export function useSavedViewActions(
|
||||
},
|
||||
});
|
||||
await invalidateViews(view.id);
|
||||
logSavedViewEvent(SavedViewEvents.ViewUpdated, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
@@ -185,13 +215,14 @@ export function useSavedViewActions(
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[updateSavedView, source, toCurrentSpec, invalidateViews],
|
||||
[updateSavedView, source, toCurrentSpec, invalidateViews, logSavedViewEvent],
|
||||
);
|
||||
|
||||
return {
|
||||
selectView,
|
||||
revertView,
|
||||
clearView,
|
||||
resetView,
|
||||
createView,
|
||||
updateView,
|
||||
isSaving: isCreating || isUpdating,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -22,9 +22,11 @@ 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(
|
||||
@@ -42,6 +44,7 @@ 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 } =
|
||||
@@ -55,8 +58,11 @@ export function useSavedViewRowActions(
|
||||
});
|
||||
copyToClipboard(getAbsoluteUrl(path));
|
||||
toast.success('Link copied', { position: SAVED_VIEW_TOAST_POSITION });
|
||||
logSavedViewEvent(SavedViewEvents.LinkCopied, {
|
||||
[SavedViewEventKeys.ViewId]: view.id,
|
||||
});
|
||||
},
|
||||
[pathname, copyToClipboard],
|
||||
[pathname, copyToClipboard, logSavedViewEvent],
|
||||
);
|
||||
|
||||
const copyViewId = useCallback(
|
||||
@@ -83,13 +89,16 @@ 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],
|
||||
[updateSavedView, source, queryClient, logSavedViewEvent],
|
||||
);
|
||||
|
||||
// The deleted view's own query is left alone: refetching it would fail
|
||||
@@ -100,13 +109,16 @@ 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],
|
||||
[deleteSavedView, source, queryClient, logSavedViewEvent],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,8 @@ 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<
|
||||
@@ -51,7 +53,12 @@ export interface UseSavedViewActionsResult {
|
||||
selectView: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
revertView: (view: SavedviewtypesSavedViewDTO) => void;
|
||||
clearView: () => void;
|
||||
createView: (displayName: string) => Promise<string | undefined>;
|
||||
/** Clears the open view without logging it, for flows that are not a clear. */
|
||||
resetView: () => void;
|
||||
createView: (
|
||||
displayName: string,
|
||||
from: SavedViewCreatedFrom,
|
||||
) => Promise<string | undefined>;
|
||||
updateView: (view: SavedviewtypesSavedViewDTO) => Promise<boolean>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
@@ -25,9 +25,6 @@
|
||||
.ant-collapse-item > .ant-collapse-content > .ant-collapse-content-box
|
||||
) {
|
||||
@include custom-scrollbar;
|
||||
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-4) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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';
|
||||
|
||||
@@ -20,7 +19,6 @@ interface MessagesAccordionProps extends Pick<
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
messages: ThreadMessage[];
|
||||
raw?: RawMessagesValue;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
@@ -28,7 +26,6 @@ function MessagesAccordion({
|
||||
icon,
|
||||
title,
|
||||
messages,
|
||||
raw,
|
||||
testId,
|
||||
messageType,
|
||||
canLinkToToolCall,
|
||||
@@ -36,10 +33,8 @@ 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 (!canFormat && !raw) {
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -66,12 +61,12 @@ function MessagesAccordion({
|
||||
{title}
|
||||
</span>
|
||||
),
|
||||
extra: isOpen && canFormat && (
|
||||
extra: isOpen && (
|
||||
// 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={effectiveView}
|
||||
value={view}
|
||||
onChange={setView}
|
||||
testId={`${testId}-view-toggle`}
|
||||
/>
|
||||
@@ -79,9 +74,8 @@ function MessagesAccordion({
|
||||
),
|
||||
children: (
|
||||
<MessagesSection
|
||||
view={effectiveView}
|
||||
view={view}
|
||||
messages={messages}
|
||||
raw={raw}
|
||||
testId={`${testId}-body`}
|
||||
messageType={messageType}
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
@@ -95,8 +89,4 @@ function MessagesAccordion({
|
||||
);
|
||||
}
|
||||
|
||||
MessagesAccordion.defaultProps = {
|
||||
raw: undefined,
|
||||
};
|
||||
|
||||
export default MessagesAccordion;
|
||||
|
||||
@@ -3,7 +3,10 @@ import { ArrowDownRight, ArrowUpRight } from '@signozhq/icons';
|
||||
|
||||
import { MessageType } from '../../AIThreadMessage/types';
|
||||
import { useToolCallLinks } from '../../AIThreadMessage/useToolCallLinks';
|
||||
import { getSpanMessages, hasAnyMessages } from '../../utils/spanMessages';
|
||||
import {
|
||||
getSpanMessages,
|
||||
hasFormattedMessages,
|
||||
} from '../../utils/spanMessages';
|
||||
import { ThreadSpan } from '../../TraceDetailsThread/types';
|
||||
import { buildToolCallIndex } from '../../TraceDetailsThread/utils';
|
||||
import MessagesAccordion from './MessagesAccordion';
|
||||
@@ -25,7 +28,7 @@ function SpanAIMessages({ span }: SpanAIMessagesProps): JSX.Element | null {
|
||||
toolCallKeys,
|
||||
);
|
||||
|
||||
if (!hasAnyMessages(data)) {
|
||||
if (!hasFormattedMessages(data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -35,7 +38,6 @@ 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}
|
||||
@@ -45,7 +47,6 @@ 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}
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
border-radius: 50%;
|
||||
background: var(--accent-forest);
|
||||
flex-shrink: 0;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
// Truncating text inside a badge (service name, status message).
|
||||
|
||||
@@ -3,8 +3,7 @@ import AIThreadMessage, {
|
||||
} from '../AIThreadMessage/AIThreadMessage';
|
||||
import { ThreadMessage } from '../AIThreadMessage/types';
|
||||
import { ThreadView } from '../TraceDetailsThread/types';
|
||||
import RawMessages from './RawMessages';
|
||||
import { RawMessagesValue } from '../utils/spanMessages';
|
||||
import JsonBlock from '../AIThreadMessage/JsonBlock';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
@@ -17,7 +16,6 @@ interface MessagesSectionProps extends MessageProps {
|
||||
title?: string;
|
||||
view: ThreadView;
|
||||
messages: ThreadMessage[];
|
||||
raw?: RawMessagesValue;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
@@ -25,21 +23,19 @@ function MessagesSection({
|
||||
title,
|
||||
view,
|
||||
messages,
|
||||
raw,
|
||||
testId,
|
||||
messageType,
|
||||
canLinkToToolCall,
|
||||
onToolLinkClick,
|
||||
}: MessagesSectionProps): JSX.Element | null {
|
||||
const isFormatted = view === ThreadView.Formatted;
|
||||
if (isFormatted ? messages.length === 0 : !raw) {
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.section} data-testid={testId}>
|
||||
{title && <div className={styles.sectionTitle}>{title}</div>}
|
||||
{isFormatted ? (
|
||||
{view === ThreadView.Formatted ? (
|
||||
<div className={styles.messages}>
|
||||
{messages.map((message, index) => (
|
||||
<AIThreadMessage
|
||||
@@ -54,7 +50,7 @@ function MessagesSection({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<RawMessages value={raw as RawMessagesValue} />
|
||||
<JsonBlock data={messages} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
@@ -62,7 +58,6 @@ function MessagesSection({
|
||||
|
||||
MessagesSection.defaultProps = {
|
||||
title: undefined,
|
||||
raw: undefined,
|
||||
};
|
||||
|
||||
export default MessagesSection;
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
@@ -28,12 +28,6 @@
|
||||
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);
|
||||
|
||||
@@ -6,11 +6,7 @@ import { ThreadSpan, ThreadView } from '../TraceDetailsThread/types';
|
||||
import JsonBlock from '../AIThreadMessage/JsonBlock';
|
||||
import { MessageType } from '../AIThreadMessage/types';
|
||||
import MessagesSection from './MessagesSection';
|
||||
import {
|
||||
getSpanMessages,
|
||||
hasFormattedMessages,
|
||||
hasRawMessages,
|
||||
} from '../utils/spanMessages';
|
||||
import { getSpanMessages, hasFormattedMessages } from '../utils/spanMessages';
|
||||
|
||||
import styles from './SpanMessages.module.scss';
|
||||
|
||||
@@ -32,9 +28,8 @@ function SpanMessages({
|
||||
onToolLinkClick,
|
||||
}: SpanMessagesProps): JSX.Element {
|
||||
const data = useMemo(() => getSpanMessages(span), [span]);
|
||||
const isFormatted = view === ThreadView.Formatted;
|
||||
|
||||
if (isFormatted ? !hasFormattedMessages(data) : !hasRawMessages(data)) {
|
||||
if (!hasFormattedMessages(data)) {
|
||||
return (
|
||||
<div
|
||||
className={cx(styles.missing, className)}
|
||||
@@ -45,11 +40,11 @@ function SpanMessages({
|
||||
);
|
||||
}
|
||||
|
||||
if (!isFormatted) {
|
||||
if (view === ThreadView.Json) {
|
||||
return (
|
||||
<div className={cx(styles.root, className)} data-testid="span-messages-json">
|
||||
<JsonBlock
|
||||
data={{ input: data.rawInput ?? [], output: data.rawOutput ?? [] }}
|
||||
data={{ input: data.formattedInput, output: data.formattedOutput }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -60,7 +55,6 @@ function SpanMessages({
|
||||
<MessagesSection
|
||||
view={view}
|
||||
messages={data.formattedInput}
|
||||
raw={data.rawInput}
|
||||
messageType={MessageType.Input}
|
||||
testId="span-messages-input"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
@@ -69,7 +63,6 @@ function SpanMessages({
|
||||
<MessagesSection
|
||||
view={view}
|
||||
messages={data.formattedOutput}
|
||||
raw={data.rawOutput}
|
||||
messageType={MessageType.Output}
|
||||
testId="span-messages-output"
|
||||
canLinkToToolCall={canLinkToToolCall}
|
||||
|
||||
@@ -48,41 +48,36 @@ describe('SpanMessages', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty state in JSON view when there are no raw messages', () => {
|
||||
render(<SpanMessages span={makeSpan({})} view={ThreadView.Json} />);
|
||||
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} />);
|
||||
|
||||
expect(screen.getByTestId('span-messages-empty')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders raw messages in JSON view', () => {
|
||||
it('renders formatted messages in JSON view, ignoring the raw attribute', () => {
|
||||
const span = makeSpan({
|
||||
attributes: { [GEN_AI_KEYS.outputMessages]: '[{"role":"assistant"}]' },
|
||||
formattedInput: [
|
||||
{ role: 'user', parts: [{ type: 'text', content: 'formatted-text' }] },
|
||||
],
|
||||
attributes: {
|
||||
[GEN_AI_KEYS.inputMessages]: '[{"role":"user","content":"raw-text"}]',
|
||||
},
|
||||
});
|
||||
|
||||
render(<SpanMessages span={span} view={ThreadView.Json} />);
|
||||
|
||||
expect(screen.getByTestId('span-messages-json')).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();
|
||||
expect(screen.getByText(/formatted-text/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/raw-text/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses tall JSON behind Show more in JSON view', () => {
|
||||
const span = makeSpan({
|
||||
attributes: {
|
||||
[GEN_AI_KEYS.inputMessages]: JSON.stringify([
|
||||
{ role: 'user', content: 'hi' },
|
||||
]),
|
||||
},
|
||||
formattedInput: [{ role: 'user', parts: [{ type: 'text', content: 'hi' }] }],
|
||||
});
|
||||
|
||||
render(<SpanMessages span={span} view={ThreadView.Json} />);
|
||||
|
||||
@@ -99,10 +99,12 @@ 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);
|
||||
|
||||
@@ -22,7 +22,8 @@ function TraceDetailsTabs(): JSX.Element {
|
||||
const [tab, setTab] = useTraceDetailsTab();
|
||||
|
||||
const handleChange = (value: string): void => {
|
||||
void setTab(value as TraceDetailsTab);
|
||||
// Pushed so the thread's "Go back" returns to the previous tab, not the previous page.
|
||||
void setTab(value as TraceDetailsTab, { history: 'push' });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
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 { render } from 'tests/test-utils';
|
||||
import { fireEvent, render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import TraceDetailsHeader from '../TraceDetailsHeader';
|
||||
import { useTraceSummary } from '../useTraceSummary';
|
||||
@@ -100,6 +98,22 @@ 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', () => {
|
||||
|
||||
@@ -1,29 +1,31 @@
|
||||
.root {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-12);
|
||||
padding: var(--spacing-20) var(--spacing-24);
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-4);
|
||||
max-width: 428px;
|
||||
padding: var(--spacing-16);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
.noDataImg {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.paragraphs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--paragraph-base-500-line-height);
|
||||
}
|
||||
|
||||
.highlight {
|
||||
.title {
|
||||
font-size: var(--periscope-font-size-medium);
|
||||
font-weight: var(--font-weight-medium);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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';
|
||||
@@ -10,32 +11,28 @@ import styles from './ThreadNoAiSpans.module.scss';
|
||||
function ThreadNoAiSpans(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.root} data-testid="thread-empty">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ function TraceDetailsThread(): JSX.Element {
|
||||
spansAboveFirstPage,
|
||||
isLoading,
|
||||
isError,
|
||||
isNotFound,
|
||||
hasNextPage,
|
||||
hasPreviousPage,
|
||||
isFetchingNextPage,
|
||||
@@ -52,6 +53,7 @@ function TraceDetailsThread(): JSX.Element {
|
||||
interestedSpanId,
|
||||
spans,
|
||||
isLoading,
|
||||
isNotFound,
|
||||
);
|
||||
const interestedSpanIndex = spans.findIndex(
|
||||
(span) => span.span_id === interestedSpanId,
|
||||
|
||||
@@ -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,6 +154,7 @@ 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 () => {
|
||||
@@ -162,5 +163,6 @@ describe('useTraceThread', () => {
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
expect(mockGetTraceThread).toHaveBeenCalledTimes(4);
|
||||
expect(result.current.isNotFound).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,8 @@ 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;
|
||||
@@ -99,6 +101,7 @@ export function useTraceThread({
|
||||
spansAboveFirstPage,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
isNotFound: isNotFoundError(query.error),
|
||||
hasNextPage: !!query.hasNextPage,
|
||||
hasPreviousPage: !!query.hasPreviousPage,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
|
||||
@@ -28,6 +28,7 @@ export function getInterestedSpanStatus(
|
||||
interestedSpanId: string | undefined,
|
||||
spans: ThreadSpan[],
|
||||
isLoading: boolean,
|
||||
isNotFound: boolean,
|
||||
): InterestedSpanStatus {
|
||||
if (!interestedSpanId) {
|
||||
return InterestedSpanStatus.None;
|
||||
@@ -35,9 +36,13 @@ export function getInterestedSpanStatus(
|
||||
if (isLoading) {
|
||||
return InterestedSpanStatus.Loading;
|
||||
}
|
||||
if (spans.length === 0) {
|
||||
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 spans.some((span) => span.span_id === interestedSpanId)
|
||||
? InterestedSpanStatus.Found
|
||||
: InterestedSpanStatus.NoIOMessages;
|
||||
|
||||
@@ -60,4 +60,4 @@ export enum TraceDetailsTab {
|
||||
}
|
||||
|
||||
export const LLM_OBSERVABILITY_DOCS_URL =
|
||||
'https://signoz.io/docs/llm-observability/';
|
||||
'https://signoz.io/docs/ai-observability/';
|
||||
|
||||
@@ -1,49 +1,19 @@
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ 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,
|
||||
@@ -224,6 +225,7 @@ 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,
|
||||
@@ -309,6 +311,7 @@ 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,
|
||||
|
||||
201
pkg/apiserver/signozapiserver/alertmanager_examples.go
Normal file
201
pkg/apiserver/signozapiserver/alertmanager_examples.go
Normal file
@@ -0,0 +1,201 @@
|
||||
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())},
|
||||
},
|
||||
}
|
||||
}
|
||||
39
pkg/apiserver/signozapiserver/alertmanager_examples_test.go
Normal file
39
pkg/apiserver/signozapiserver/alertmanager_examples_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ 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,
|
||||
@@ -200,6 +201,7 @@ 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,
|
||||
@@ -233,6 +235,7 @@ 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,
|
||||
|
||||
268
pkg/apiserver/signozapiserver/dashboard_examples.go
Normal file
268
pkg/apiserver/signozapiserver/dashboard_examples.go
Normal file
@@ -0,0 +1,268 @@
|
||||
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),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
49
pkg/apiserver/signozapiserver/dashboard_examples_test.go
Normal file
49
pkg/apiserver/signozapiserver/dashboard_examples_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,7 @@ 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,
|
||||
|
||||
33
pkg/apiserver/signozapiserver/metricsexplorer_examples.go
Normal file
33
pkg/apiserver/signozapiserver/metricsexplorer_examples.go
Normal file
@@ -0,0 +1,33 @@
|
||||
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'"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,7 @@ 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,
|
||||
@@ -102,6 +103,7 @@ 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,
|
||||
|
||||
176
pkg/apiserver/signozapiserver/savedview_examples.go
Normal file
176
pkg/apiserver/signozapiserver/savedview_examples.go
Normal file
@@ -0,0 +1,176 @@
|
||||
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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
30
pkg/apiserver/signozapiserver/savedview_examples_test.go
Normal file
30
pkg/apiserver/signozapiserver/savedview_examples_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -47,9 +47,9 @@ const (
|
||||
|
||||
type DeprecatedGettableAlert struct {
|
||||
*model.Alert
|
||||
Status types.AlertStatus `json:"status"`
|
||||
Receivers []string `json:"receivers"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
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."`
|
||||
}
|
||||
|
||||
type DeprecatedGettableAlerts = []*DeprecatedGettableAlert
|
||||
|
||||
@@ -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"`
|
||||
DisplayName string `json:"name" required:"true" bun:"display_name" description:"The channel name. Alert rules and routing policies reference it."`
|
||||
// TODO: type this as ChannelKind once v1 is gone.
|
||||
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"`
|
||||
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."`
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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"`
|
||||
To string `json:"to" required:"true"`
|
||||
HTML valuer.UnsetOrNonEmptyString `json:"html,omitzero"`
|
||||
Headers map[string]string `json:"headers,omitzero"`
|
||||
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."`
|
||||
}
|
||||
|
||||
func (c *ChannelEmailConfig) UnmarshalJSON(data []byte) error {
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
)
|
||||
|
||||
type ChannelGoogleChatConfig struct {
|
||||
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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
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"`
|
||||
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty" description:"HTTP client settings for the webhook call."`
|
||||
|
||||
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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
var DefaultGoogleChatReceiverConfig = GoogleChatReceiverConfig{
|
||||
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
)
|
||||
|
||||
type ChannelIncidentIOConfig struct {
|
||||
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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
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"`
|
||||
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty" description:"HTTP client settings for the API call."`
|
||||
|
||||
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"`
|
||||
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."`
|
||||
// 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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
// send_resolved has no omitempty upstream, so a var default here is overwritten
|
||||
|
||||
@@ -13,24 +13,22 @@ import (
|
||||
)
|
||||
|
||||
type ChannelJiraConfig struct {
|
||||
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"`
|
||||
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."`
|
||||
|
||||
Email string `json:"email" required:"true"`
|
||||
APIToken string `json:"apiToken" required:"true" format:"password"`
|
||||
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."`
|
||||
}
|
||||
|
||||
// UnmarshalJSON seeds send_resolved off, as JiraReceiverConfig does.
|
||||
@@ -193,19 +191,19 @@ const (
|
||||
type JiraReceiverConfig struct {
|
||||
config.NotifierConfig `yaml:",inline"`
|
||||
|
||||
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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
func (c *JiraReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
|
||||
@@ -10,13 +10,12 @@ 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"`
|
||||
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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
func (c *ChannelJSMOpsConfig) UnmarshalJSON(data []byte) error {
|
||||
@@ -108,13 +107,13 @@ const (
|
||||
type JSMOpsReceiverConfig struct {
|
||||
config.NotifierConfig `yaml:",inline" json:",inline"`
|
||||
|
||||
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`
|
||||
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty" description:"HTTP client settings for the API call."`
|
||||
|
||||
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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
// send_resolved has no omitempty upstream, so a var default here is overwritten
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
)
|
||||
|
||||
type ChannelMSTeamsConfig struct {
|
||||
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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
func (c *ChannelMSTeamsConfig) UnmarshalJSON(data []byte) error {
|
||||
|
||||
@@ -7,14 +7,14 @@ import (
|
||||
)
|
||||
|
||||
type ChannelOpsgenieConfig struct {
|
||||
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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
func (c *ChannelOpsgenieConfig) UnmarshalJSON(data []byte) error {
|
||||
|
||||
@@ -7,18 +7,18 @@ import (
|
||||
)
|
||||
|
||||
type ChannelPagerdutyConfig struct {
|
||||
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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
func (c *ChannelPagerdutyConfig) UnmarshalJSON(data []byte) error {
|
||||
|
||||
@@ -7,43 +7,43 @@ import (
|
||||
)
|
||||
|
||||
type ChannelSlackConfig struct {
|
||||
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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
type ChannelSlackField struct {
|
||||
Title string `json:"title" required:"true"`
|
||||
Value string `json:"value" required:"true"`
|
||||
Short *bool `json:"short,omitempty"`
|
||||
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."`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
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 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 ChannelSlackConfirmation struct {
|
||||
Text string `json:"text" required:"true"`
|
||||
Title string `json:"title"`
|
||||
OkText string `json:"okText"`
|
||||
DismissText string `json:"dismissText"`
|
||||
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."`
|
||||
}
|
||||
|
||||
func (c *ChannelSlackConfig) UnmarshalJSON(data []byte) error {
|
||||
|
||||
@@ -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"`
|
||||
URL string `json:"url" required:"true" format:"password"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password" format:"password"`
|
||||
BearerToken string `json:"bearerToken" format:"password"`
|
||||
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."`
|
||||
}
|
||||
|
||||
func (c *ChannelWebhookConfig) UnmarshalJSON(data []byte) error {
|
||||
|
||||
@@ -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"`
|
||||
GenerateName bool `json:"generateName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Config ChannelConfig `json:"config" required:"true"`
|
||||
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."`
|
||||
}
|
||||
|
||||
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"`
|
||||
Config ChannelConfig `json:"config" required:"true" description:"The replacement provider kind and spec. Name and displayName are immutable and not part of the body."`
|
||||
}
|
||||
|
||||
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"`
|
||||
Config ChannelConfig `json:"config" required:"true" description:"The replacement provider kind and spec. Name and displayName are immutable and not part of the body."`
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
@@ -179,19 +179,19 @@ type GettableNotificationChannel struct {
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ListedNotificationChannel carries no configuration. The spec, credentials
|
||||
// included, is only returned by a fetch by ID.
|
||||
// included, is returned by create, update and fetch by ID, never by list.
|
||||
type ListedNotificationChannel struct {
|
||||
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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Total int64 `json:"total" required:"true"`
|
||||
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."`
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
)
|
||||
|
||||
type ChannelConfig struct {
|
||||
Kind ChannelKind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
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."`
|
||||
}
|
||||
|
||||
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"`
|
||||
Spec S `json:"spec" required:"true"`
|
||||
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."`
|
||||
}
|
||||
|
||||
func (v ChannelConfigVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
|
||||
@@ -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"`
|
||||
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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
func (p *ListChannelsParams) Validate() error {
|
||||
|
||||
@@ -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"`
|
||||
Apply bool `query:"apply" json:"apply" description:"Apply the diagnosed action; defaults to false, a dry run that only reports it."`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
// Diagnose reads the channel the way v2 does and reports the first defect in
|
||||
|
||||
@@ -29,13 +29,13 @@ type DashboardView struct {
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
|
||||
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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
type DashboardViewData struct {
|
||||
Version string `json:"version" required:"true"`
|
||||
Version string `json:"version" required:"true" description:"The view data schema version. Must be v1."`
|
||||
ListFilter
|
||||
}
|
||||
|
||||
@@ -52,8 +52,8 @@ func (d *DashboardViewData) Validate() error {
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
type PostableDashboardView struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
Data DashboardViewData `json:"data" required:"true"`
|
||||
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."`
|
||||
}
|
||||
|
||||
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"`
|
||||
Views []*DashboardView `json:"views" required:"true" nullable:"false" description:"The saved dashboard list views."`
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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"`
|
||||
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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
Sort ListSort `query:"sort" json:"sort"`
|
||||
Order ListOrder `query:"order" json:"order"`
|
||||
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)."`
|
||||
}
|
||||
|
||||
func (f *ListFilter) Validate() error {
|
||||
@@ -75,8 +75,8 @@ func (f *ListFilter) Validate() error {
|
||||
|
||||
type ListDashboardsV2Params struct {
|
||||
ListFilter
|
||||
Limit int `query:"limit"`
|
||||
Offset int `query:"offset"`
|
||||
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."`
|
||||
}
|
||||
|
||||
func (p *ListDashboardsV2Params) Validate() error {
|
||||
@@ -114,22 +114,22 @@ type listedDashboardV2 struct {
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
|
||||
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"`
|
||||
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."`
|
||||
// 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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
type listedDashboardV2Spec struct {
|
||||
Display Display `json:"display,omitempty"`
|
||||
Display Display `json:"display,omitempty" description:"The dashboard name and description."`
|
||||
}
|
||||
|
||||
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"`
|
||||
Total int64 `json:"total" required:"true"`
|
||||
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
|
||||
ReservedKeywords []DSLKey `json:"reservedKeywords" required:"true" nullable:"false"`
|
||||
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."`
|
||||
}
|
||||
|
||||
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"`
|
||||
Pinned bool `json:"pinned" required:"true" description:"Whether the calling user has pinned the dashboard."`
|
||||
}
|
||||
|
||||
type ListableDashboardForUserV2 struct {
|
||||
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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
// StorableDashboardWithPinInfo is the per-row shape Store.ListForUser returns: the dashboard
|
||||
|
||||
@@ -68,14 +68,14 @@ type DashboardV2 struct {
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
|
||||
OrgID valuer.UUID `json:"orgId" required:"true"`
|
||||
Locked bool `json:"locked" required:"true"`
|
||||
Source Source `json:"source" required:"true"`
|
||||
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."`
|
||||
|
||||
DashboardV2MetadataBase
|
||||
Name string `json:"name" required:"true"`
|
||||
Tags []*tagtypes.Tag `json:"tags" required:"true"`
|
||||
Spec DashboardSpec `json:"spec" required:"true"`
|
||||
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."`
|
||||
}
|
||||
|
||||
func (d *DashboardV2) ErrIfNotMutable() error {
|
||||
@@ -198,8 +198,8 @@ func nextCloneDisplayName(name string) string {
|
||||
}
|
||||
|
||||
type DashboardV2MetadataBase struct {
|
||||
SchemaVersion string `json:"schemaVersion" required:"true"`
|
||||
Image string `json:"image"`
|
||||
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."`
|
||||
}
|
||||
|
||||
func (m DashboardV2MetadataBase) validateImage() error {
|
||||
@@ -222,10 +222,10 @@ func (m DashboardV2MetadataBase) validateImage() error {
|
||||
|
||||
type PostableDashboardV2 struct {
|
||||
DashboardV2MetadataBase
|
||||
Name string `json:"name"`
|
||||
GenerateName bool `json:"generateName"`
|
||||
Tags []tagtypes.PostableTag `json:"tags" required:"true"`
|
||||
Spec DashboardSpec `json:"spec" required:"true"`
|
||||
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."`
|
||||
}
|
||||
|
||||
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"`
|
||||
Locked bool `json:"locked" required:"true"`
|
||||
Source Source `json:"source" required:"true"`
|
||||
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."`
|
||||
|
||||
DashboardV2MetadataBase
|
||||
Name string `json:"name" required:"true"`
|
||||
Tags []*tagtypes.GettableTag `json:"tags" required:"true"`
|
||||
Spec DashboardSpec `json:"spec" required:"true"`
|
||||
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."`
|
||||
}
|
||||
|
||||
func (d DashboardV2) ToGettableDashboardV2() GettableDashboardV2 {
|
||||
@@ -402,14 +402,14 @@ type GettableSystemDashboard struct {
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
|
||||
OrgID valuer.UUID `json:"orgId" required:"true"`
|
||||
Locked bool `json:"locked" required:"true"`
|
||||
Source Source `json:"source" required:"true"`
|
||||
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."`
|
||||
|
||||
DashboardV2MetadataBase
|
||||
Name string `json:"name" required:"true"`
|
||||
Tags []*tagtypes.GettableTag `json:"tags" required:"true"`
|
||||
Spec DashboardSpec `json:"spec" required:"true"`
|
||||
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."`
|
||||
}
|
||||
|
||||
func (d DashboardV2) ToGettableSystemDashboard() GettableSystemDashboard {
|
||||
@@ -456,9 +456,9 @@ type StorableDashboardV2Metadata = DashboardV2MetadataBase
|
||||
|
||||
type UpdatableDashboardV2 struct {
|
||||
DashboardV2MetadataBase
|
||||
Name string `json:"name" required:"true"`
|
||||
Tags []tagtypes.PostableTag `json:"tags" required:"true"`
|
||||
Spec DashboardSpec `json:"spec" required:"true"`
|
||||
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."`
|
||||
}
|
||||
|
||||
func (u *UpdatableDashboardV2) UnmarshalJSON(data []byte) error {
|
||||
|
||||
@@ -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"`
|
||||
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"`
|
||||
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."`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
@@ -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"`
|
||||
Ops []JSONPatchOperation `json:"ops" description:"The RFC 6902 operations, applied in order against the dashboard's postable shape."`
|
||||
// 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"`
|
||||
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."`
|
||||
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."`
|
||||
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type PanelPlugin struct {
|
||||
Kind PanelPluginKind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
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."`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Spec S `json:"spec" required:"true"`
|
||||
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."`
|
||||
}
|
||||
|
||||
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"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
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."`
|
||||
}
|
||||
|
||||
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"`
|
||||
Spec S `json:"spec" required:"true"`
|
||||
Kind string `json:"kind" required:"true" description:"The query kind."`
|
||||
Spec S `json:"spec" required:"true" description:"The kind-specific query spec."`
|
||||
}
|
||||
|
||||
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"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
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."`
|
||||
}
|
||||
|
||||
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"`
|
||||
Spec S `json:"spec" required:"true"`
|
||||
Kind string `json:"kind" required:"true" description:"The value source kind."`
|
||||
Spec S `json:"spec" required:"true" description:"The kind-specific value source spec."`
|
||||
}
|
||||
|
||||
func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
|
||||
@@ -12,14 +12,14 @@ import (
|
||||
|
||||
// PublicWidgetQueryRangeParams are the query params of the public panel query-range endpoint.
|
||||
type PublicWidgetQueryRangeParams struct {
|
||||
StartTime string `query:"startTime" required:"false"`
|
||||
EndTime string `query:"endTime" required:"false"`
|
||||
StartTime string `query:"startTime" required:"false" description:"Start of the time range as a Unix timestamp in milliseconds. Honored only when the public dashboard has timeRangeEnabled."`
|
||||
EndTime string `query:"endTime" required:"false" description:"End of the time range as a Unix timestamp in milliseconds. Honored only when the public dashboard has timeRangeEnabled."`
|
||||
}
|
||||
|
||||
// GettablePublicDashboardDataV2 is the anonymous-facing payload of a v2 dashboard.
|
||||
type GettablePublicDashboardDataV2 struct {
|
||||
Dashboard *GettableDashboardV2 `json:"dashboard"`
|
||||
PublicDashboard *GettablePublicDasbhboard `json:"publicDashboard"`
|
||||
Dashboard *GettableDashboardV2 `json:"dashboard" description:"The dashboard with panel and variable queries redacted."`
|
||||
PublicDashboard *GettablePublicDasbhboard `json:"publicDashboard" description:"The sharing settings."`
|
||||
}
|
||||
|
||||
// NewPublicDashboardDataFromDashboardV2 builds the anonymous v2 payload: panel queries
|
||||
|
||||
@@ -25,10 +25,10 @@ const MaxDisplayNameLen = 128
|
||||
const MaxLayoutTitleLen = 256
|
||||
|
||||
type Display struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
Name string `json:"name" required:"true" maxLength:"128" description:"The human-readable title, at most 128 characters."`
|
||||
// Description always serializes ("" included) so a create -> GET round-trip
|
||||
// preserves what a typed client sent; omitempty would drop an explicit "".
|
||||
Description string `json:"description"`
|
||||
Description string `json:"description" description:"A free-text description."`
|
||||
}
|
||||
|
||||
func (d Display) Validate(label, path string) error {
|
||||
@@ -43,8 +43,8 @@ func (d Display) Validate(label, path string) error {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type Panel struct {
|
||||
Kind PanelKind `json:"kind" required:"true"`
|
||||
Spec PanelSpec `json:"spec" required:"true"`
|
||||
Kind PanelKind `json:"kind" required:"true" description:"The panel envelope kind. Always Panel."`
|
||||
Spec PanelSpec `json:"spec" required:"true" description:"The panel content: display, plugin, queries and links."`
|
||||
}
|
||||
|
||||
// PanelKind is the panel envelope discriminator. Perses leaves it a free
|
||||
@@ -69,10 +69,10 @@ func (k *PanelKind) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type PanelSpec struct {
|
||||
Display Display `json:"display" required:"true"`
|
||||
Plugin PanelPlugin `json:"plugin" required:"true"`
|
||||
Queries []Query `json:"queries" required:"true" nullable:"false"`
|
||||
Links []Link `json:"links,omitzero"`
|
||||
Display Display `json:"display" required:"true" description:"The panel title and description."`
|
||||
Plugin PanelPlugin `json:"plugin" required:"true" description:"The visualization: kind is one of signoz/TimeSeriesPanel, signoz/BarChartPanel, signoz/AreaChartPanel, signoz/NumberPanel, signoz/PieChartPanel, signoz/TablePanel, signoz/HistogramPanel, signoz/ListPanel, signoz/TextPanel or signoz/HeatmapPanel, and spec holds that kind's options ({} accepts every default)."`
|
||||
Queries []Query `json:"queries" required:"true" nullable:"false" description:"The queries the panel runs. Use exactly one entry for a data panel and [] for signoz/TextPanel. To run several named queries or formulas, use one entry whose spec.plugin.kind is signoz/CompositeQuery and put the constituent queries in its plugin spec."`
|
||||
Links []Link `json:"links,omitzero" description:"Context links offered in the data point drilldown menu of the panel, with dashboard variables resolved in the URL."`
|
||||
}
|
||||
|
||||
// Link replicates dashboard.Link (Perses) so its zero-valued fields survive the
|
||||
@@ -80,11 +80,11 @@ type PanelSpec struct {
|
||||
// omitempty, which drops "" and false; here every field always serializes so a
|
||||
// typed client reads back exactly what it sent.
|
||||
type Link struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Tooltip string `json:"tooltip"`
|
||||
RenderVariables bool `json:"renderVariables"`
|
||||
TargetBlank bool `json:"targetBlank"`
|
||||
Name string `json:"name" description:"The link label."`
|
||||
URL string `json:"url" description:"The link target. May reference dashboard variables as {{name}} when renderVariables is true."`
|
||||
Tooltip string `json:"tooltip" description:"Hover text for the link. Stored, but not shown by the UI."`
|
||||
RenderVariables bool `json:"renderVariables" description:"Whether dashboard variable values are substituted into url before opening it."`
|
||||
TargetBlank bool `json:"targetBlank" description:"Whether the link opens in a new tab."`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -92,13 +92,13 @@ type Link struct {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type Query struct {
|
||||
Kind qb.RequestType `json:"kind" required:"true"`
|
||||
Spec QuerySpec `json:"spec" required:"true"`
|
||||
Kind qb.RequestType `json:"kind" required:"true" description:"The result shape the panel requests: time_series for time series, bar, area and histogram panels, scalar for number, table and pie panels, raw for list panels, heatmap for heatmap panels, trace for trace panels."`
|
||||
Spec QuerySpec `json:"spec" required:"true" description:"The query content: an optional name and the query plugin."`
|
||||
}
|
||||
|
||||
type QuerySpec struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Plugin QueryPlugin `json:"plugin" required:"true"`
|
||||
Name string `json:"name,omitempty" description:"An optional label for the query. The name queries refer to each other by lives in the plugin spec."`
|
||||
Plugin QueryPlugin `json:"plugin" required:"true" description:"The query: kind is one of signoz/BuilderQuery, signoz/AIBuilderQuery (builder query over AI observability traces), signoz/CompositeQuery (several named queries and formulas), signoz/Formula, signoz/PromQLQuery, signoz/ClickHouseSQL or signoz/TraceOperator, and spec holds that kind's query. Pie and table panels reject signoz/PromQLQuery; heatmap panels reject signoz/AIBuilderQuery and signoz/TraceOperator; list panels accept only signoz/BuilderQuery and signoz/AIBuilderQuery."`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -109,8 +109,8 @@ type QuerySpec struct {
|
||||
// *TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
|
||||
// discriminated oneOf (see JSONSchemaOneOf).
|
||||
type Variable struct {
|
||||
Kind variable.Kind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
Kind variable.Kind `json:"kind" required:"true" description:"The variable kind: ListVariable (values from a plugin) or TextVariable (a free-text value)."`
|
||||
Spec any `json:"spec" required:"true" description:"The kind-specific variable spec."`
|
||||
}
|
||||
|
||||
func (Variable) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
@@ -154,8 +154,8 @@ func (Variable) JSONSchemaOneOf() []any {
|
||||
}
|
||||
|
||||
type VariableEnvelope[S any] struct {
|
||||
Kind string `json:"kind" required:"true"`
|
||||
Spec S `json:"spec" required:"true"`
|
||||
Kind string `json:"kind" required:"true" description:"The variable kind."`
|
||||
Spec S `json:"spec" required:"true" description:"The kind-specific variable spec."`
|
||||
}
|
||||
|
||||
func (v VariableEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
@@ -165,15 +165,15 @@ func (v VariableEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
// ListVariableSpec mirrors dashboard.ListVariableSpec (variable.ListSpec
|
||||
// fields + Name) but with a typed VariablePlugin replacing common.Plugin.
|
||||
type ListVariableSpec struct {
|
||||
Display Display `json:"display" required:"true"`
|
||||
DefaultValue *VariableDefaultValue `json:"defaultValue,omitempty"`
|
||||
AllowAllValue bool `json:"allowAllValue"`
|
||||
AllowMultiple bool `json:"allowMultiple"`
|
||||
CustomAllValue string `json:"customAllValue"`
|
||||
CapturingRegexp string `json:"capturingRegexp"`
|
||||
Sort ListVariableSpecSort `json:"sort"`
|
||||
Plugin VariablePlugin `json:"plugin"`
|
||||
Name string `json:"name" required:"true" minLength:"1"`
|
||||
Display Display `json:"display" required:"true" description:"The label shown for the variable control."`
|
||||
DefaultValue *VariableDefaultValue `json:"defaultValue,omitempty" description:"The initially selected value: a string, or a list of strings when allowMultiple is true."`
|
||||
AllowAllValue bool `json:"allowAllValue" description:"Whether an all option is offered. Requires allowMultiple."`
|
||||
AllowMultiple bool `json:"allowMultiple" description:"Whether several values can be selected at once."`
|
||||
CustomAllValue string `json:"customAllValue" description:"The value meant to replace every value when all is selected. Requires allowAllValue. Stored, but currently ignored by the UI."`
|
||||
CapturingRegexp string `json:"capturingRegexp" description:"A regular expression meant to keep only matching values, reduced to the capture group when one is present. Stored, but not applied by the UI."`
|
||||
Sort ListVariableSpecSort `json:"sort" description:"How the values are ordered: none (default), alphabetical-asc, alphabetical-desc, numerical-asc, numerical-desc, alphabetical-ci-asc or alphabetical-ci-desc."`
|
||||
Plugin VariablePlugin `json:"plugin" description:"Where the values come from: kind is signoz/DynamicVariable (observed attribute values), signoz/QueryVariable (a ClickHouse query) or signoz/CustomVariable (a fixed list), and spec holds that kind's settings."`
|
||||
Name string `json:"name" required:"true" minLength:"1" description:"The handle queries reference as $name. Up to 75 characters from letters, digits, dot, underscore and hyphen, not all digits, unique within the dashboard."`
|
||||
}
|
||||
|
||||
// VariableDefaultValue is a list variable's defaultValue: the string | []string
|
||||
@@ -294,10 +294,10 @@ func (s *ListVariableSpecSort) UnmarshalJSON(data []byte) error {
|
||||
// TextVariableSpec replicates dashboard.TextVariableSpec so name can carry the
|
||||
// required/non-empty schema tags perses leaves off.
|
||||
type TextVariableSpec struct {
|
||||
Display Display `json:"display" required:"true"`
|
||||
Value string `json:"value" required:"true"`
|
||||
Constant bool `json:"constant"`
|
||||
Name string `json:"name" required:"true" minLength:"1"`
|
||||
Display Display `json:"display" required:"true" description:"The label shown for the variable control."`
|
||||
Value string `json:"value" required:"true" description:"The default text. Must be non-empty when constant is true."`
|
||||
Constant bool `json:"constant" description:"Whether the value is meant to be fixed. Stored, but not applied by the UI."`
|
||||
Name string `json:"name" required:"true" minLength:"1" description:"The handle queries reference as $name. Up to 75 characters from letters, digits, dot, underscore and hyphen, not all digits, unique within the dashboard."`
|
||||
}
|
||||
|
||||
// validate mirrors perses TextVariableSpec validation. path is the JSON path to
|
||||
@@ -328,8 +328,8 @@ func (s *TextVariableSpec) validate(path string) error {
|
||||
// based on Kind. No plugin is involved, so we reuse the Perses spec types as
|
||||
// leaf imports.
|
||||
type Layout struct {
|
||||
Kind dashboard.LayoutKind `json:"kind" required:"true"`
|
||||
Spec any `json:"spec" required:"true"`
|
||||
Kind dashboard.LayoutKind `json:"kind" required:"true" description:"The layout kind. Always Grid."`
|
||||
Spec any `json:"spec" required:"true" description:"The grid section: an optional display title and up to 100 items that place panels. Each item has a content.$ref of #/spec/panels/<id> plus x, y, width and height on a 12-column grid: x and y at least 0, width and height at least 1, x plus width at most 12, and no two items overlapping."`
|
||||
}
|
||||
|
||||
// layoutSpecs is the layout sum type factory. Perses only defines
|
||||
@@ -419,8 +419,8 @@ func (Layout) JSONSchemaOneOf() []any {
|
||||
}
|
||||
|
||||
type LayoutEnvelope[S any] struct {
|
||||
Kind string `json:"kind" required:"true"`
|
||||
Spec S `json:"spec" required:"true"`
|
||||
Kind string `json:"kind" required:"true" description:"The layout kind."`
|
||||
Spec S `json:"spec" required:"true" description:"The grid section: an optional display title and up to 100 items that place panels. Each item has a content.$ref of #/spec/panels/<id> plus x, y, width and height on a 12-column grid: x and y at least 0, width and height at least 1, x plus width at most 12, and no two items overlapping."`
|
||||
}
|
||||
|
||||
func (v LayoutEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
|
||||
@@ -29,10 +29,9 @@ func (VariablePluginKind) Enum() []any {
|
||||
}
|
||||
|
||||
type DynamicVariableSpec struct {
|
||||
// Name is the name of the attribute being fetched dynamically from the
|
||||
// signal. This could be extended to a richer selector in the future.
|
||||
Name string `json:"name" validate:"required" required:"true"`
|
||||
Signal DynamicVariableSignal `json:"signal" required:"true" nullable:"false"`
|
||||
// This could be extended to a richer selector in the future.
|
||||
Name string `json:"name" validate:"required" required:"true" description:"The attribute key whose observed values populate the variable, such as service.name."`
|
||||
Signal DynamicVariableSignal `json:"signal" required:"true" nullable:"false" description:"The signal the values are read from: traces, logs, metrics or all (default)."`
|
||||
}
|
||||
|
||||
// DynamicVariableSignal is the telemetry signal a dynamic variable draws its
|
||||
@@ -78,11 +77,11 @@ func (s *DynamicVariableSignal) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type QueryVariableSpec struct {
|
||||
QueryValue string `json:"queryValue" validate:"required" required:"true"`
|
||||
QueryValue string `json:"queryValue" validate:"required" required:"true" description:"A ClickHouse SQL query returning one column of selectable values; every returned column is offered."`
|
||||
}
|
||||
|
||||
type CustomVariableSpec struct {
|
||||
CustomValue string `json:"customValue" validate:"required" required:"true"`
|
||||
CustomValue string `json:"customValue" validate:"required" required:"true" description:"The fixed values as a comma-separated list, such as 1,10,100."`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -208,37 +207,37 @@ func (k PanelPluginKind) rendersWithoutQuery() bool {
|
||||
}
|
||||
|
||||
type TimeSeriesPanelSpec struct {
|
||||
Visualization TimeSeriesVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
ChartAppearance TimeSeriesChartAppearance `json:"chartAppearance"`
|
||||
Axes Axes `json:"axes"`
|
||||
Legend Legend `json:"legend"`
|
||||
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive"`
|
||||
Visualization TimeSeriesVisualization `json:"visualization" description:"Time range and gap handling."`
|
||||
Formatting PanelFormatting `json:"formatting" description:"Unit and decimal precision of the values."`
|
||||
ChartAppearance TimeSeriesChartAppearance `json:"chartAppearance" description:"Line drawing options."`
|
||||
Axes Axes `json:"axes" description:"Y axis bounds and scale."`
|
||||
Legend Legend `json:"legend" description:"Legend placement and series colors."`
|
||||
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive" description:"Horizontal reference lines drawn at fixed values."`
|
||||
}
|
||||
|
||||
type TimeSeriesChartAppearance struct {
|
||||
LineInterpolation LineInterpolation `json:"lineInterpolation"`
|
||||
ShowPoints bool `json:"showPoints"`
|
||||
LineStyle LineStyle `json:"lineStyle"`
|
||||
FillMode FillMode `json:"fillMode"`
|
||||
SpanGaps SpanGaps `json:"spanGaps"`
|
||||
LineInterpolation LineInterpolation `json:"lineInterpolation" description:"How points are joined: linear, spline (default), step_after or step_before."`
|
||||
ShowPoints bool `json:"showPoints" description:"Whether each data point is marked on the line."`
|
||||
LineStyle LineStyle `json:"lineStyle" description:"The line stroke: solid (default) or dashed."`
|
||||
FillMode FillMode `json:"fillMode" description:"The fill under each line: none (default), solid or gradient."`
|
||||
SpanGaps SpanGaps `json:"spanGaps" description:"Whether lines connect across missing points."`
|
||||
}
|
||||
|
||||
type BarChartPanelSpec struct {
|
||||
Visualization BarChartVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
Axes Axes `json:"axes"`
|
||||
Legend Legend `json:"legend"`
|
||||
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive"`
|
||||
Visualization BarChartVisualization `json:"visualization" description:"Time range, gap handling and stacking."`
|
||||
Formatting PanelFormatting `json:"formatting" description:"Unit and decimal precision of the values."`
|
||||
Axes Axes `json:"axes" description:"Y axis bounds and scale."`
|
||||
Legend Legend `json:"legend" description:"Legend placement and series colors."`
|
||||
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive" description:"Horizontal reference lines drawn at fixed values."`
|
||||
}
|
||||
|
||||
type AreaChartPanelSpec struct {
|
||||
Visualization AreaChartVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
ChartAppearance AreaChartAppearance `json:"chartAppearance"`
|
||||
Axes Axes `json:"axes"`
|
||||
Legend Legend `json:"legend"`
|
||||
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive"`
|
||||
Visualization AreaChartVisualization `json:"visualization" description:"Time range, gap handling and stacking."`
|
||||
Formatting PanelFormatting `json:"formatting" description:"Unit and decimal precision of the values."`
|
||||
ChartAppearance AreaChartAppearance `json:"chartAppearance" description:"Line and fill drawing options."`
|
||||
Axes Axes `json:"axes" description:"Y axis bounds and scale."`
|
||||
Legend Legend `json:"legend" description:"Legend placement and series colors."`
|
||||
Thresholds []ThresholdWithLabel `json:"thresholds" validate:"dive" description:"Horizontal reference lines drawn at fixed values."`
|
||||
}
|
||||
|
||||
// AreaChartAppearance repeats the line-drawing fields rather than embedding
|
||||
@@ -246,83 +245,83 @@ type AreaChartPanelSpec struct {
|
||||
// a duplicated json tag across an embed boundary is resolved by depth, which the
|
||||
// schema reflector does not model.
|
||||
type AreaChartAppearance struct {
|
||||
LineInterpolation LineInterpolation `json:"lineInterpolation"`
|
||||
ShowPoints bool `json:"showPoints"`
|
||||
LineStyle LineStyle `json:"lineStyle"`
|
||||
FillMode AreaFillMode `json:"fillMode"`
|
||||
LineInterpolation LineInterpolation `json:"lineInterpolation" description:"How points are joined: linear, spline (default), step_after or step_before."`
|
||||
ShowPoints bool `json:"showPoints" description:"Whether each data point is marked on the line."`
|
||||
LineStyle LineStyle `json:"lineStyle" description:"The line stroke: solid (default) or dashed."`
|
||||
FillMode AreaFillMode `json:"fillMode" description:"The fill under each line: solid (default) or gradient."`
|
||||
// FillOpacity is a pointer so an omitted field resolves to the kind default at
|
||||
// render time; a plain value would make the Go zero value a transparent fill.
|
||||
FillOpacity *FillOpacity `json:"fillOpacity"`
|
||||
SpanGaps SpanGaps `json:"spanGaps"`
|
||||
FillOpacity *FillOpacity `json:"fillOpacity" description:"The fill opacity from 0 (transparent) to 1 (opaque). Null uses the chart default."`
|
||||
SpanGaps SpanGaps `json:"spanGaps" description:"Whether lines connect across missing points."`
|
||||
}
|
||||
|
||||
type NumberPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
Thresholds []ComparisonThreshold `json:"thresholds" validate:"dive"`
|
||||
Visualization BasicVisualization `json:"visualization" description:"The time range the value is computed over."`
|
||||
Formatting PanelFormatting `json:"formatting" description:"Unit and decimal precision of the value."`
|
||||
Thresholds []ComparisonThreshold `json:"thresholds" validate:"dive" description:"Rules that recolor the value when it compares to a threshold as specified."`
|
||||
}
|
||||
|
||||
type PieChartPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
Legend Legend `json:"legend"`
|
||||
Visualization BasicVisualization `json:"visualization" description:"The time range the slices are computed over."`
|
||||
Formatting PanelFormatting `json:"formatting" description:"Unit and decimal precision of the slice values."`
|
||||
Legend Legend `json:"legend" description:"Legend placement and slice colors."`
|
||||
}
|
||||
|
||||
type TablePanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Formatting TableFormatting `json:"formatting"`
|
||||
Thresholds []TableThreshold `json:"thresholds" validate:"dive"`
|
||||
Visualization BasicVisualization `json:"visualization" description:"The time range the rows are computed over."`
|
||||
Formatting TableFormatting `json:"formatting" description:"Per-column units and decimal precision."`
|
||||
Thresholds []TableThreshold `json:"thresholds" validate:"dive" description:"Rules that recolor a cell when its column value compares to a threshold as specified."`
|
||||
}
|
||||
|
||||
type HistogramPanelSpec struct {
|
||||
HistogramBuckets HistogramBuckets `json:"histogramBuckets"`
|
||||
Legend Legend `json:"legend"`
|
||||
HistogramBuckets HistogramBuckets `json:"histogramBuckets" description:"How values are grouped into buckets."`
|
||||
Legend Legend `json:"legend" description:"Legend placement and series colors."`
|
||||
}
|
||||
|
||||
type HistogramBuckets struct {
|
||||
BucketCount *float64 `json:"bucketCount"`
|
||||
BucketWidth *float64 `json:"bucketWidth"`
|
||||
MergeAllActiveQueries bool `json:"mergeAllActiveQueries"`
|
||||
BucketCount *float64 `json:"bucketCount" description:"The approximate target number of buckets. Ignored when bucketWidth is positive. Null uses the chart default."`
|
||||
BucketWidth *float64 `json:"bucketWidth" description:"The width of each bucket in the value unit. A positive value takes precedence over bucketCount; null or 0 lets the chart choose."`
|
||||
MergeAllActiveQueries bool `json:"mergeAllActiveQueries" description:"Whether every series is combined into a single histogram instead of one per series."`
|
||||
}
|
||||
|
||||
type ListPanelSpec struct {
|
||||
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitzero" validate:"dive"`
|
||||
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitzero" validate:"dive" description:"The columns shown for each row, as field keys with name and optional fieldContext, fieldDataType and signal. Empty shows the default columns."`
|
||||
}
|
||||
|
||||
type HeatmapPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Formatting PanelFormatting `json:"formatting"`
|
||||
Axes HeatmapAxes `json:"axes"`
|
||||
Legend Legend `json:"legend"`
|
||||
ChartAppearance HeatmapChartAppearance `json:"chartAppearance"`
|
||||
Visualization BasicVisualization `json:"visualization" description:"The time range the heatmap covers."`
|
||||
Formatting PanelFormatting `json:"formatting" description:"Unit and decimal precision of the bucket bounds."`
|
||||
Axes HeatmapAxes `json:"axes" description:"The Y axis scale."`
|
||||
Legend Legend `json:"legend" description:"Legend placement and series colors."`
|
||||
ChartAppearance HeatmapChartAppearance `json:"chartAppearance" description:"How bucket counts map to colors."`
|
||||
}
|
||||
|
||||
// HeatmapAxes carries only the Y scale. The shared Axes type models a value
|
||||
// axis with soft bounds, where a heatmap's Y axis is the bucket boundaries the
|
||||
// response already fixed.
|
||||
type HeatmapAxes struct {
|
||||
Y HeatmapAxis `json:"y"`
|
||||
Y HeatmapAxis `json:"y" description:"The bucket axis."`
|
||||
}
|
||||
|
||||
type HeatmapAxis struct {
|
||||
Scale HeatmapYScale `json:"scale"`
|
||||
Scale HeatmapYScale `json:"scale" description:"How bucket bounds are spaced on the axis: auto (default), linear, log or symlog."`
|
||||
}
|
||||
|
||||
type HeatmapChartAppearance struct {
|
||||
Colors HeatmapColors `json:"colors"`
|
||||
Colors HeatmapColors `json:"colors" description:"The color scale applied to bucket counts."`
|
||||
}
|
||||
|
||||
type HeatmapColors struct {
|
||||
Mode HeatmapColorMode `json:"mode"`
|
||||
Palette HeatmapPalette `json:"palette"`
|
||||
Scale HeatmapColorScale `json:"scale"`
|
||||
Steps int `json:"steps" validate:"omitempty,min=2,max=128"`
|
||||
Mode HeatmapColorMode `json:"mode" description:"How counts are colored: palette (default) picks a hue from palette, opacity varies the opacity of fill."`
|
||||
Palette HeatmapPalette `json:"palette" description:"The named color ramp used in palette mode, such as lava (default), ice or ember."`
|
||||
Scale HeatmapColorScale `json:"scale" description:"How counts map onto the color range: log (default), sqrt or linear."`
|
||||
Steps int `json:"steps" validate:"omitempty,min=2,max=128" description:"The number of distinct color steps, 2 to 128. 0 uses the chart default."`
|
||||
// MinCount and MaxCount clamp the colour scale; nil derives them from the
|
||||
// grid, 0 and the highest count in it.
|
||||
MinCount *float64 `json:"minCount"`
|
||||
MaxCount *float64 `json:"maxCount"`
|
||||
MinCount *float64 `json:"minCount" description:"The count mapped to the lowest color. Null uses 0. Must not exceed maxCount."`
|
||||
MaxCount *float64 `json:"maxCount" description:"The count mapped to the highest color. Null uses the highest count in the grid."`
|
||||
// Fill applies in opacity mode; empty means the selected group's legend colour.
|
||||
Fill string `json:"fill"`
|
||||
Fill string `json:"fill" description:"The hex color varied in opacity mode. Empty uses the series legend color."`
|
||||
}
|
||||
|
||||
func (c *HeatmapColors) UnmarshalJSON(data []byte) error {
|
||||
@@ -344,16 +343,16 @@ func (c HeatmapColors) validate() error {
|
||||
}
|
||||
|
||||
type TextPanelSpec struct {
|
||||
Mode TextMode `json:"mode"`
|
||||
Text string `json:"text"`
|
||||
Presentation TextPresentation `json:"presentation"`
|
||||
HeaderOptions HeaderOptions `json:"headerOptions"`
|
||||
Mode TextMode `json:"mode" description:"How text is interpreted. Only markdown is supported."`
|
||||
Text string `json:"text" description:"The content to render."`
|
||||
Presentation TextPresentation `json:"presentation" description:"Alignment and background of the content."`
|
||||
HeaderOptions HeaderOptions `json:"headerOptions" description:"Whether the panel header is shown."`
|
||||
}
|
||||
|
||||
type TextPresentation struct {
|
||||
TextAlign TextAlign `json:"textAlign"`
|
||||
VerticalAlign VerticalAlign `json:"verticalAlign"`
|
||||
Background *string `json:"background,omitempty" validate:"omitempty,hexcolor"`
|
||||
TextAlign TextAlign `json:"textAlign" description:"Horizontal alignment: left (default), center or right."`
|
||||
VerticalAlign VerticalAlign `json:"verticalAlign" description:"Vertical alignment: top (default), center or bottom."`
|
||||
Background *string `json:"background,omitempty" validate:"omitempty,hexcolor" description:"A hex background color such as #1f2937. Omit for the panel default."`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -361,79 +360,79 @@ type TextPresentation struct {
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
type Axes struct {
|
||||
SoftMin *float64 `json:"softMin"`
|
||||
SoftMax *float64 `json:"softMax"`
|
||||
IsLogScale bool `json:"isLogScale"`
|
||||
SoftMin *float64 `json:"softMin" description:"The lowest value the Y axis always shows; data below it still extends the axis. Null fits the data."`
|
||||
SoftMax *float64 `json:"softMax" description:"The highest value the Y axis always shows; data above it still extends the axis. Null fits the data."`
|
||||
IsLogScale bool `json:"isLogScale" description:"Whether the Y axis uses a logarithmic scale."`
|
||||
}
|
||||
|
||||
// HeaderOptions controls the panel card's header strip — the title/description
|
||||
// row above the panel content. Phrased as hide so the zero value shows the
|
||||
// header, matching every other panel kind.
|
||||
type HeaderOptions struct {
|
||||
Hide bool `json:"hide"`
|
||||
Hide bool `json:"hide" description:"Whether the title and description row above the content is hidden."`
|
||||
}
|
||||
|
||||
type BasicVisualization struct {
|
||||
TimePreference TimePreference `json:"timePreference"`
|
||||
TimePreference TimePreference `json:"timePreference" description:"The time range the panel queries: global_time (default) follows the dashboard range; last_5_min, last_15_min, last_30_min, last_1_hr, last_6_hr, last_1_day, last_3_days, last_1_week or last_1_month pin a fixed window."`
|
||||
}
|
||||
|
||||
type TimeSeriesVisualization struct {
|
||||
BasicVisualization
|
||||
FillSpans bool `json:"fillSpans"`
|
||||
FillSpans bool `json:"fillSpans" description:"Whether intervals with no data are drawn as zero instead of a gap."`
|
||||
}
|
||||
|
||||
type BarChartVisualization struct {
|
||||
BasicVisualization
|
||||
FillSpans bool `json:"fillSpans"`
|
||||
StackedBarChart bool `json:"stackedBarChart"`
|
||||
FillSpans bool `json:"fillSpans" description:"Whether intervals with no data are drawn as zero instead of a gap."`
|
||||
StackedBarChart bool `json:"stackedBarChart" description:"Whether the series are stacked on top of each other."`
|
||||
}
|
||||
|
||||
type AreaChartVisualization struct {
|
||||
BasicVisualization
|
||||
FillSpans bool `json:"fillSpans"`
|
||||
Stack StackMode `json:"stack"`
|
||||
FillSpans bool `json:"fillSpans" description:"Whether intervals with no data are drawn as zero instead of a gap."`
|
||||
Stack StackMode `json:"stack" description:"How the areas combine: none (default) overlays them, normal stacks the values, percent stacks them as shares of the total."`
|
||||
}
|
||||
|
||||
type PanelFormatting struct {
|
||||
Unit string `json:"unit"`
|
||||
DecimalPrecision PrecisionOption `json:"decimalPrecision"`
|
||||
Unit string `json:"unit" description:"The display unit of the values, such as By, ms, s, percent or percentunit. Use percent for values already expressed as percentages (95 displays as 95%) and percentunit for fractions (0.95 displays as 95%). Empty shows plain numbers."`
|
||||
DecimalPrecision PrecisionOption `json:"decimalPrecision" description:"Decimal places shown: 0, 1, 2 (default), 3, 4 or full."`
|
||||
}
|
||||
|
||||
type TableFormatting struct {
|
||||
ColumnUnits map[string]string `json:"columnUnits"`
|
||||
DecimalPrecision PrecisionOption `json:"decimalPrecision"`
|
||||
ColumnUnits map[string]string `json:"columnUnits" description:"Unit id per value column, keyed by the column key: the query name for a builder query with one aggregation (A), the query name dotted with the aggregation expression for several (A.count()), or the SQL alias for a ClickHouse SQL column. Aggregation aliases are display labels, not keys."`
|
||||
DecimalPrecision PrecisionOption `json:"decimalPrecision" description:"Decimal places shown: 0, 1, 2 (default), 3, 4 or full."`
|
||||
}
|
||||
|
||||
type Legend struct {
|
||||
Position LegendPosition `json:"position"`
|
||||
Mode LegendMode `json:"mode"`
|
||||
CustomColors map[string]string `json:"customColors"`
|
||||
Position LegendPosition `json:"position" description:"Where the legend sits: bottom (default) or right."`
|
||||
Mode LegendMode `json:"mode" description:"The legend layout. Only list is supported."`
|
||||
CustomColors map[string]string `json:"customColors" description:"Hex colors keyed by series label, overriding the automatic palette."`
|
||||
}
|
||||
|
||||
type ThresholdWithLabel struct {
|
||||
// Value is always present in the schema (required:"true"), but 0 is a legitimate
|
||||
// threshold, so it drops validate:"required" — go-playground's required treats a
|
||||
// zero float as unset and would wrongly reject value: 0.
|
||||
Value float64 `json:"value" required:"true"`
|
||||
Unit string `json:"unit"`
|
||||
Color string `json:"color" validate:"required" required:"true"`
|
||||
Label string `json:"label"`
|
||||
Value float64 `json:"value" required:"true" description:"The value the line is drawn at, in unit."`
|
||||
Unit string `json:"unit" description:"The unit id of value, such as ms or percent. Empty means the panel's unit, or the column's unit for a table threshold. Prefer the panel's unit; a different unit works only when it converts to it, such as s against a panel in ms."`
|
||||
Color string `json:"color" validate:"required" required:"true" description:"The CSS color of the line, for example #F1575F, which matches the SigNoz red preset; CSS named colors such as red also work."`
|
||||
Label string `json:"label" description:"The caption shown on the line."`
|
||||
}
|
||||
|
||||
type ComparisonThreshold struct {
|
||||
// Value is always present in the schema (required:"true"), but 0 is a legitimate
|
||||
// threshold, so it drops validate:"required" — go-playground's required treats a
|
||||
// zero float as unset and would wrongly reject value: 0.
|
||||
Value float64 `json:"value" required:"true"`
|
||||
Operator ComparisonOperator `json:"operator"`
|
||||
Unit string `json:"unit"`
|
||||
Color string `json:"color" validate:"required" required:"true"`
|
||||
Format ThresholdFormat `json:"format"`
|
||||
Value float64 `json:"value" required:"true" description:"The value compared against, in unit."`
|
||||
Operator ComparisonOperator `json:"operator" description:"The comparison that activates the threshold: above (default), below, above_or_equal, below_or_equal, equal or not_equal."`
|
||||
Unit string `json:"unit" description:"The unit id of value, such as ms or percent. Empty means the panel's unit, or the column's unit for a table threshold. Prefer the panel's unit; a different unit works only when it converts to it, such as s against a panel in ms."`
|
||||
Color string `json:"color" validate:"required" required:"true" description:"The CSS color applied to the text or background, per format, while the threshold is active. For example #F1575F matches the SigNoz red preset; CSS named colors such as red also work."`
|
||||
Format ThresholdFormat `json:"format" description:"What the color is applied to: text (default) or background."`
|
||||
}
|
||||
|
||||
type TableThreshold struct {
|
||||
ComparisonThreshold
|
||||
ColumnName string `json:"columnName" validate:"required" required:"true"`
|
||||
ColumnName string `json:"columnName" validate:"required" required:"true" description:"The key of the value column the rule applies to: the query name for a builder query with one aggregation (A), the query name dotted with the aggregation expression for several (A.count()), or the SQL alias for a ClickHouse SQL column. Group-by columns are never colored, and aggregation aliases are display labels, not keys."`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -856,7 +855,7 @@ func (o *FillOpacity) UnmarshalJSON(data []byte) error {
|
||||
|
||||
type SpanGaps struct {
|
||||
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
|
||||
FillLessThan string `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`
|
||||
FillLessThan string `json:"fillLessThan" description:"The largest gap still connected when fillOnlyBelow is true; longer gaps are left disconnected. Required in that case, as a positive Prometheus duration such as 30s, 5m or 1d."`
|
||||
}
|
||||
|
||||
func (sg *SpanGaps) UnmarshalJSON(data []byte) error {
|
||||
|
||||
@@ -32,25 +32,25 @@ type PublicDashboard struct {
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
|
||||
TimeRangeEnabled bool `json:"timeRangeEnabled"`
|
||||
DefaultTimeRange string `json:"defaultTimeRange"`
|
||||
DashboardID valuer.UUID `json:"dashboardId"`
|
||||
TimeRangeEnabled bool `json:"timeRangeEnabled" description:"Whether anonymous viewers may change the time range."`
|
||||
DefaultTimeRange string `json:"defaultTimeRange" description:"The time range shown to anonymous viewers by default, as a Go duration such as 30m, 1h or 24h. Accepted units are ns, us, ms, s, m and h; write a day as 24h."`
|
||||
DashboardID valuer.UUID `json:"dashboardId" description:"The id of the dashboard being shared."`
|
||||
}
|
||||
|
||||
type GettablePublicDasbhboard struct {
|
||||
TimeRangeEnabled bool `json:"timeRangeEnabled"`
|
||||
DefaultTimeRange string `json:"defaultTimeRange"`
|
||||
PublicPath string `json:"publicPath"`
|
||||
TimeRangeEnabled bool `json:"timeRangeEnabled" description:"Whether anonymous viewers may change the time range."`
|
||||
DefaultTimeRange string `json:"defaultTimeRange" description:"The time range shown to anonymous viewers by default, as a Go duration such as 30m, 1h or 24h. Accepted units are ns, us, ms, s, m and h; write a day as 24h."`
|
||||
PublicPath string `json:"publicPath" description:"The path segment of the public URL."`
|
||||
}
|
||||
|
||||
type PostablePublicDashboard struct {
|
||||
TimeRangeEnabled bool `json:"timeRangeEnabled"`
|
||||
DefaultTimeRange string `json:"defaultTimeRange"`
|
||||
TimeRangeEnabled bool `json:"timeRangeEnabled" description:"Whether anonymous viewers may change the time range."`
|
||||
DefaultTimeRange string `json:"defaultTimeRange" description:"The time range shown to anonymous viewers by default, as a Go duration such as 30m, 1h or 24h. Accepted units are ns, us, ms, s, m and h; write a day as 24h."`
|
||||
}
|
||||
|
||||
type UpdatablePublicDashboard struct {
|
||||
TimeRangeEnabled bool `json:"timeRangeEnabled"`
|
||||
DefaultTimeRange string `json:"defaultTimeRange"`
|
||||
TimeRangeEnabled bool `json:"timeRangeEnabled" description:"Whether anonymous viewers may change the time range."`
|
||||
DefaultTimeRange string `json:"defaultTimeRange" description:"The time range shown to anonymous viewers by default, as a Go duration such as 30m, 1h or 24h. Accepted units are ns, us, ms, s, m and h; write a day as 24h."`
|
||||
}
|
||||
|
||||
func NewPublicDashboard(timeRangeEnabled bool, defaultTimeRange string, dashboardID valuer.UUID) *PublicDashboard {
|
||||
|
||||
@@ -41,12 +41,12 @@ func (TreemapMode) Enum() []any {
|
||||
|
||||
// StatsRequest represents the payload accepted by the metrics stats endpoint.
|
||||
type StatsRequest struct {
|
||||
Filter *qbtypes.Filter `json:"filter,omitempty"`
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Limit int `json:"limit" required:"true"`
|
||||
Offset int `json:"offset"`
|
||||
OrderBy *qbtypes.OrderBy `json:"orderBy,omitempty"`
|
||||
Filter *qbtypes.Filter `json:"filter,omitempty" description:"Query builder filter over metric attributes, for example host.name = 'web-1'. A bare word matches the metric name. Omitting it applies no restriction, but metrics whose name starts with signoz are always excluded."`
|
||||
Start int64 `json:"start" required:"true" description:"Start of the time range as a Unix timestamp in milliseconds. Must be greater than 0 and less than end."`
|
||||
End int64 `json:"end" required:"true" description:"End of the time range as a Unix timestamp in milliseconds. Must be greater than start."`
|
||||
Limit int `json:"limit" required:"true" description:"Maximum number of metrics to return, between 1 and 5000."`
|
||||
Offset int `json:"offset" description:"Number of metrics to skip for pagination. Must not be negative; defaults to 0."`
|
||||
OrderBy *qbtypes.OrderBy `json:"orderBy,omitempty" description:"Sort order; key.name is timeseries or samples, direction is asc or desc. Defaults to samples descending."`
|
||||
}
|
||||
|
||||
// Validate ensures StatsRequest contains acceptable values.
|
||||
@@ -104,26 +104,26 @@ func (req *StatsRequest) UnmarshalJSON(data []byte) error {
|
||||
|
||||
// Stat represents the summary information returned per metric.
|
||||
type Stat struct {
|
||||
MetricName string `json:"metricName" required:"true"`
|
||||
Description string `json:"description" required:"true"`
|
||||
MetricType metrictypes.Type `json:"type" required:"true"`
|
||||
MetricUnit string `json:"unit" required:"true"`
|
||||
TimeSeries uint64 `json:"timeseries" required:"true"`
|
||||
Samples uint64 `json:"samples" required:"true"`
|
||||
MetricName string `json:"metricName" required:"true" description:"The name of the metric."`
|
||||
Description string `json:"description" required:"true" description:"Human-readable description from the metric metadata; empty when none was reported."`
|
||||
MetricType metrictypes.Type `json:"type" required:"true" description:"The metric type, such as gauge, sum, histogram, summary or exponentialhistogram."`
|
||||
MetricUnit string `json:"unit" required:"true" description:"The unit reported in the metric metadata, for example ms or By; empty when none was reported."`
|
||||
TimeSeries uint64 `json:"timeseries" required:"true" description:"Number of distinct time series active in the time range, approximate: it can include series last active shortly before start."`
|
||||
Samples uint64 `json:"samples" required:"true" description:"Number of samples (data points) ingested for the metric in the requested time range."`
|
||||
}
|
||||
|
||||
// StatsResponse represents the aggregated metrics statistics.
|
||||
type StatsResponse struct {
|
||||
Metrics []Stat `json:"metrics" required:"true" nullable:"true"`
|
||||
Total uint64 `json:"total" required:"true"`
|
||||
Metrics []Stat `json:"metrics" required:"true" nullable:"true" description:"One entry per metric on the requested page, in the requested order."`
|
||||
Total uint64 `json:"total" required:"true" description:"Total number of metrics matching the filter across all pages."`
|
||||
}
|
||||
|
||||
type MetricMetadata struct {
|
||||
Description string `json:"description" required:"true"`
|
||||
MetricType metrictypes.Type `json:"type" required:"true"`
|
||||
MetricUnit string `json:"unit" required:"true"`
|
||||
Temporality metrictypes.Temporality `json:"temporality" required:"true"`
|
||||
IsMonotonic bool `json:"isMonotonic" required:"true"`
|
||||
Description string `json:"description" required:"true" description:"Human-readable description of the metric; empty when none was reported."`
|
||||
MetricType metrictypes.Type `json:"type" required:"true" description:"The metric type, such as gauge, sum, histogram, summary or exponentialhistogram."`
|
||||
MetricUnit string `json:"unit" required:"true" description:"The unit of the metric values, for example ms or By; empty when none was reported."`
|
||||
Temporality metrictypes.Temporality `json:"temporality" required:"true" description:"Aggregation temporality of the metric: cumulative, delta or unspecified."`
|
||||
IsMonotonic bool `json:"isMonotonic" required:"true" description:"True when the metric is a monotonically increasing Sum (a counter)."`
|
||||
}
|
||||
|
||||
// MarshalBinary implements cachetypes.Cacheable interface.
|
||||
@@ -138,21 +138,21 @@ func (m *MetricMetadata) UnmarshalBinary(data []byte) error {
|
||||
|
||||
// UpdateMetricMetadataRequest represents the payload for updating metric metadata.
|
||||
type UpdateMetricMetadataRequest struct {
|
||||
MetricName string `json:"metricName" required:"true"`
|
||||
Type metrictypes.Type `json:"type" required:"true"`
|
||||
Description string `json:"description" required:"true"`
|
||||
Unit string `json:"unit" required:"true"`
|
||||
Temporality metrictypes.Temporality `json:"temporality" required:"true"`
|
||||
IsMonotonic bool `json:"isMonotonic" required:"true"`
|
||||
MetricName string `json:"metricName" required:"true" description:"The name of the metric whose metadata to replace."`
|
||||
Type metrictypes.Type `json:"type" required:"true" description:"The metric type to store, such as gauge, sum, histogram, summary or exponentialhistogram."`
|
||||
Description string `json:"description" required:"true" description:"Human-readable description of the metric."`
|
||||
Unit string `json:"unit" required:"true" description:"The unit of the metric values, for example ms or By."`
|
||||
Temporality metrictypes.Temporality `json:"temporality" required:"true" description:"Aggregation temporality to store. Required as delta or cumulative when type is sum, histogram or exponentialhistogram; gauges are stored as unspecified and summaries as cumulative whatever is sent. A non-monotonic cumulative sum is stored as a gauge."`
|
||||
IsMonotonic bool `json:"isMonotonic" required:"true" description:"Whether the metric is a monotonically increasing Sum (a counter)."`
|
||||
}
|
||||
|
||||
// TreemapRequest represents the payload for the metrics treemap endpoint.
|
||||
type TreemapRequest struct {
|
||||
Filter *qbtypes.Filter `json:"filter,omitempty"`
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Limit int `json:"limit" required:"true"`
|
||||
Mode TreemapMode `json:"mode" required:"true"`
|
||||
Filter *qbtypes.Filter `json:"filter,omitempty" description:"Query builder filter over metric attributes, for example host.name = 'web-1'. A bare word matches the metric name. Omitting it applies no restriction, but metrics whose name starts with signoz are always excluded."`
|
||||
Start int64 `json:"start" required:"true" description:"Start of the time range as a Unix timestamp in milliseconds. Must be greater than 0 and less than end."`
|
||||
End int64 `json:"end" required:"true" description:"End of the time range as a Unix timestamp in milliseconds. Must be greater than start."`
|
||||
Limit int `json:"limit" required:"true" description:"Maximum number of metrics to return, between 1 and 5000, ordered by percentage descending."`
|
||||
Mode TreemapMode `json:"mode" required:"true" description:"What each metric is sized by: timeseries counts distinct time series; samples counts ingested data points, ranked among a shortlist of the metrics with the most time series. Only the matching response array is populated."`
|
||||
}
|
||||
|
||||
// Validate enforces basic constraints on TreemapRequest.
|
||||
@@ -218,32 +218,32 @@ func (req *TreemapRequest) UnmarshalJSON(data []byte) error {
|
||||
|
||||
// TreemapEntry represents each node in the treemap response.
|
||||
type TreemapEntry struct {
|
||||
MetricName string `json:"metricName" required:"true"`
|
||||
Percentage float64 `json:"percentage" required:"true"`
|
||||
TotalValue uint64 `json:"totalValue" required:"true"`
|
||||
MetricName string `json:"metricName" required:"true" description:"The name of the metric."`
|
||||
Percentage float64 `json:"percentage" required:"true" description:"Share of the total across all metrics in the time range, from 0 to 100."`
|
||||
TotalValue uint64 `json:"totalValue" required:"true" description:"Time series count or sample count for the metric, depending on the requested mode."`
|
||||
}
|
||||
|
||||
// TreemapResponse is the output structure for the treemap endpoint.
|
||||
type TreemapResponse struct {
|
||||
TimeSeries []TreemapEntry `json:"timeseries" required:"true" nullable:"true"`
|
||||
Samples []TreemapEntry `json:"samples" required:"true" nullable:"true"`
|
||||
TimeSeries []TreemapEntry `json:"timeseries" required:"true" nullable:"true" description:"Entries sized by time series count; populated only when mode is timeseries."`
|
||||
Samples []TreemapEntry `json:"samples" required:"true" nullable:"true" description:"Entries sized by sample count, chosen among the metrics with the most time series; populated only when mode is samples."`
|
||||
}
|
||||
|
||||
// MetricAlert represents an alert associated with a metric.
|
||||
type MetricAlert struct {
|
||||
AlertName string `json:"alertName" required:"true"`
|
||||
AlertID string `json:"alertId" required:"true"`
|
||||
AlertName string `json:"alertName" required:"true" description:"The name of the alert rule that queries the metric."`
|
||||
AlertID string `json:"alertId" required:"true" description:"The id of the alert rule."`
|
||||
}
|
||||
|
||||
// MetricAlertsResponse represents the response for metric alerts endpoint.
|
||||
type MetricAlertsResponse struct {
|
||||
Alerts []MetricAlert `json:"alerts" required:"true" nullable:"true"`
|
||||
Alerts []MetricAlert `json:"alerts" required:"true" nullable:"true" description:"Alert rules whose queries reference the metric."`
|
||||
}
|
||||
|
||||
// MetricDashboardPanelsResponse is the response for the v2 metric dashboards
|
||||
// endpoint: the dashboard panels that reference the metric.
|
||||
type MetricDashboardPanelsResponse struct {
|
||||
Dashboards []dashboardtypes.DashboardPanelRef `json:"dashboards" required:"true" nullable:"true"`
|
||||
Dashboards []dashboardtypes.DashboardPanelRef `json:"dashboards" required:"true" nullable:"true" description:"One entry per dashboard panel whose query references the metric."`
|
||||
}
|
||||
|
||||
// NewMetricDashboardPanelsResponse wraps the dashboard panels that reference a
|
||||
@@ -258,10 +258,10 @@ func NewMetricDashboardPanelsResponse(refs []dashboardtypes.DashboardPanelRef) *
|
||||
|
||||
// MetricHighlightsResponse is the output structure for the metric highlights endpoint.
|
||||
type MetricHighlightsResponse struct {
|
||||
DataPoints uint64 `json:"dataPoints" required:"true"`
|
||||
LastReceived uint64 `json:"lastReceived" required:"true"`
|
||||
TotalTimeSeries uint64 `json:"totalTimeSeries" required:"true"`
|
||||
ActiveTimeSeries uint64 `json:"activeTimeSeries" required:"true"`
|
||||
DataPoints uint64 `json:"dataPoints" required:"true" description:"Number of samples ingested for the metric over retained data."`
|
||||
LastReceived uint64 `json:"lastReceived" required:"true" description:"Unix timestamp in milliseconds of the most recently received sample."`
|
||||
TotalTimeSeries uint64 `json:"totalTimeSeries" required:"true" description:"Number of distinct time series for the metric over retained data."`
|
||||
ActiveTimeSeries uint64 `json:"activeTimeSeries" required:"true" description:"Number of distinct time series active in the last 120 minutes, approximate: a series that last reported early in that window can be missed."`
|
||||
}
|
||||
|
||||
// MetricNameQuery represents the query parameters for endpoints that take a metric name.
|
||||
@@ -308,24 +308,24 @@ func (req *MetricAttributesRequest) Validate() error {
|
||||
|
||||
// MetricAttribute represents a single attribute with its values and count.
|
||||
type MetricAttribute struct {
|
||||
Key string `json:"key" required:"true"`
|
||||
Values []string `json:"values" required:"true" nullable:"true"`
|
||||
ValueCount uint64 `json:"valueCount" required:"true"`
|
||||
Key string `json:"key" required:"true" description:"The attribute (label) name."`
|
||||
Values []string `json:"values" required:"true" nullable:"true" description:"Distinct values observed for the attribute, capped at 1000."`
|
||||
ValueCount uint64 `json:"valueCount" required:"true" description:"Total number of distinct values observed, which can exceed the length of values."`
|
||||
}
|
||||
|
||||
// MetricAttributesResponse is the output structure for the metric attributes endpoint.
|
||||
type MetricAttributesResponse struct {
|
||||
Attributes []MetricAttribute `json:"attributes" required:"true" nullable:"true"`
|
||||
TotalKeys int64 `json:"totalKeys" required:"true"`
|
||||
Attributes []MetricAttribute `json:"attributes" required:"true" nullable:"true" description:"Attributes of the metric, ordered by distinct value count descending."`
|
||||
TotalKeys int64 `json:"totalKeys" required:"true" description:"Number of attributes returned."`
|
||||
}
|
||||
|
||||
// ListMetricsParams represents the query parameters for the list metrics endpoint.
|
||||
type ListMetricsParams struct {
|
||||
Start *int64 `query:"start"`
|
||||
End *int64 `query:"end"`
|
||||
Limit int `query:"limit"`
|
||||
Search string `query:"searchText"`
|
||||
Source string `query:"source"`
|
||||
Start *int64 `query:"start" description:"Start of the time range as a Unix timestamp in milliseconds. Must be greater than 0; applied only when end is also set."`
|
||||
End *int64 `query:"end" description:"End of the time range as a Unix timestamp in milliseconds. Must be greater than start; applied only when start is also set."`
|
||||
Limit int `query:"limit" description:"Maximum number of metric names to return. Defaults to 100; must not exceed 5000."`
|
||||
Search string `query:"searchText" description:"Case-insensitive substring to match against metric names."`
|
||||
Source string `query:"source" description:"Set to meter to list Cost Meter metrics instead of telemetry metrics."`
|
||||
}
|
||||
|
||||
// Validate ensures ListMetricsParams contains acceptable values.
|
||||
@@ -353,30 +353,30 @@ func (p *ListMetricsParams) Validate() error {
|
||||
|
||||
// ListMetric represents a single metric with its metadata in the list metrics response.
|
||||
type ListMetric struct {
|
||||
MetricName string `json:"metricName" required:"true"`
|
||||
Description string `json:"description" required:"true"`
|
||||
MetricType metrictypes.Type `json:"type" required:"true"`
|
||||
MetricUnit string `json:"unit" required:"true"`
|
||||
Temporality metrictypes.Temporality `json:"temporality" required:"true"`
|
||||
IsMonotonic bool `json:"isMonotonic" required:"true"`
|
||||
MetricName string `json:"metricName" required:"true" description:"The name of the metric."`
|
||||
Description string `json:"description" required:"true" description:"Human-readable description from the metric metadata; empty when none was reported."`
|
||||
MetricType metrictypes.Type `json:"type" required:"true" description:"The metric type, such as gauge, sum, histogram, summary or exponentialhistogram."`
|
||||
MetricUnit string `json:"unit" required:"true" description:"The unit reported in the metric metadata, for example ms or By; empty when none was reported."`
|
||||
Temporality metrictypes.Temporality `json:"temporality" required:"true" description:"Aggregation temporality of the metric: cumulative, delta or unspecified."`
|
||||
IsMonotonic bool `json:"isMonotonic" required:"true" description:"True when the metric is a monotonically increasing Sum (a counter)."`
|
||||
}
|
||||
|
||||
// ListMetricsResponse represents the response for the list metrics endpoint.
|
||||
type ListMetricsResponse struct {
|
||||
Metrics []ListMetric `json:"metrics" required:"true" nullable:"true"`
|
||||
Metrics []ListMetric `json:"metrics" required:"true" nullable:"true" description:"Matching metrics with their metadata, ordered by name."`
|
||||
}
|
||||
|
||||
// MetricsOnboardingResponse is the response for the lightweight metrics onboarding check.
|
||||
type MetricsOnboardingResponse struct {
|
||||
HasMetrics bool `json:"hasMetrics" required:"true"`
|
||||
HasMetrics bool `json:"hasMetrics" required:"true" description:"True when at least one metric not emitted by SigNoz itself has been ingested."`
|
||||
}
|
||||
|
||||
// InspectMetricsRequest is the request for inspecting raw metric data points.
|
||||
type InspectMetricsRequest struct {
|
||||
MetricName string `json:"metricName" required:"true"`
|
||||
Start int64 `json:"start" required:"true"`
|
||||
End int64 `json:"end" required:"true"`
|
||||
Filter *qbtypes.Filter `json:"filter,omitempty"`
|
||||
MetricName string `json:"metricName" required:"true" description:"The name of the metric to inspect."`
|
||||
Start int64 `json:"start" required:"true" description:"Start of the time range as a Unix timestamp in milliseconds. Must be greater than 0 and less than end."`
|
||||
End int64 `json:"end" required:"true" description:"End of the time range as a Unix timestamp in milliseconds. At most 30 minutes after start."`
|
||||
Filter *qbtypes.Filter `json:"filter,omitempty" description:"Query builder filter over the metric attributes, for example host.name = 'web-1'. Omitting it applies no attribute restriction; inspection returns at most 50 series either way."`
|
||||
}
|
||||
|
||||
// Validate ensures the request is valid.
|
||||
@@ -399,5 +399,5 @@ func (r *InspectMetricsRequest) Validate() error {
|
||||
|
||||
// InspectMetricsResponse is the response for the inspect metrics endpoint.
|
||||
type InspectMetricsResponse struct {
|
||||
Series []*qbtypes.TimeSeries `json:"series" required:"true" nullable:"true"`
|
||||
Series []*qbtypes.TimeSeries `json:"series" required:"true" nullable:"true" description:"Raw time series, at most 50, each with its labels and timestamp-value pairs."`
|
||||
}
|
||||
|
||||
@@ -277,8 +277,8 @@ type MetricValueFilter struct {
|
||||
}
|
||||
|
||||
type ComparisonSpaceAggregationParam struct {
|
||||
Operater string `json:"operator" required:"true"`
|
||||
Threshold float64 `json:"threshold" required:"true"`
|
||||
Operater string `json:"operator" required:"true" description:"Comparison applied to histogram bucket bounds when spaceAggregation is count: <= gives the estimated count of observations at or below threshold, > the estimated count above it."`
|
||||
Threshold float64 `json:"threshold" required:"true" description:"Bucket bound the comparison uses, in the unit of the histogram metric. The count is exact when threshold equals one of the metric's le bucket bounds, and otherwise an estimate interpolated linearly between the two nearest bounds."`
|
||||
}
|
||||
|
||||
func (param ComparisonSpaceAggregationParam) StringValue() string {
|
||||
|
||||
@@ -497,10 +497,8 @@ func FunctionReduceTo(result *TimeSeries, reduceTo ReduceTo) *TimeSeries {
|
||||
}
|
||||
|
||||
type TraceAggregation struct {
|
||||
// aggregation expression - example: count(), sum(item_price), countIf(day > 10)
|
||||
Expression string `json:"expression"`
|
||||
// if any, it will be used as the alias of the aggregation in the result
|
||||
Alias string `json:"alias,omitempty"`
|
||||
Expression string `json:"expression" description:"The aggregation over spans, such as count(), sum(item_price), p99(duration_nano) or countIf(day > 10)."`
|
||||
Alias string `json:"alias,omitempty" description:"The name the aggregation is reported under; when omitted, the result carries the alias __result_N, with N the zero-based index of the aggregation."`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of TraceAggregation.
|
||||
@@ -509,10 +507,8 @@ func (t TraceAggregation) Copy() TraceAggregation {
|
||||
}
|
||||
|
||||
type LogAggregation struct {
|
||||
// aggregation expression - example: count(), sum(item_price), countIf(day > 10)
|
||||
Expression string `json:"expression"`
|
||||
// if any, it will be used as the alias of the aggregation in the result
|
||||
Alias string `json:"alias,omitempty"`
|
||||
Expression string `json:"expression" description:"The aggregation over log records, such as count(), sum(item_price), count_distinct(user_id) or countIf(day > 10)."`
|
||||
Alias string `json:"alias,omitempty" description:"The name the aggregation is reported under; when omitted, the result carries the alias __result_N, with N the zero-based index of the aggregation."`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of LogAggregation.
|
||||
@@ -521,24 +517,18 @@ func (l LogAggregation) Copy() LogAggregation {
|
||||
}
|
||||
|
||||
type MetricAggregation struct {
|
||||
// metric to query
|
||||
MetricName string `json:"metricName"`
|
||||
MetricName string `json:"metricName" description:"The name of the metric to query, for example k8s.pod.cpu_request_utilization."`
|
||||
// type of the metric
|
||||
Type metrictypes.Type `json:"-"`
|
||||
// temporality to apply to the query
|
||||
Temporality metrictypes.Temporality `json:"temporality"`
|
||||
// time aggregation to apply to the query
|
||||
TimeAggregation metrictypes.TimeAggregation `json:"timeAggregation"`
|
||||
// space aggregation to apply to the query
|
||||
SpaceAggregation metrictypes.SpaceAggregation `json:"spaceAggregation"`
|
||||
// param for space aggregation if needed
|
||||
ComparisonSpaceAggregationParam *metrictypes.ComparisonSpaceAggregationParam `json:"comparisonSpaceAggregationParam,omitempty"`
|
||||
Type metrictypes.Type `json:"-"`
|
||||
Temporality metrictypes.Temporality `json:"temporality" description:"The temporality of the metric: delta, cumulative or unspecified. Resolved from the metric metadata when omitted."`
|
||||
TimeAggregation metrictypes.TimeAggregation `json:"timeAggregation" description:"How samples of one series are combined within each step, such as avg, sum, min, max, count, count_distinct, latest, rate or increase."`
|
||||
SpaceAggregation metrictypes.SpaceAggregation `json:"spaceAggregation" description:"How series are combined across each group, such as sum, avg, min, max or count; histograms take percentiles such as p50, p90, p95 or p99."`
|
||||
ComparisonSpaceAggregationParam *metrictypes.ComparisonSpaceAggregationParam `json:"comparisonSpaceAggregationParam,omitempty" description:"Histogram metrics with spaceAggregation count only: the comparison operator and bucket bound used to estimate how many observations fall at or below, or above, the threshold. Ignored for other metric types and aggregations."`
|
||||
// table hints to use for the query
|
||||
TableHints *metrictypes.MetricTableHints `json:"-"`
|
||||
// value filter to apply to the query
|
||||
ValueFilter *metrictypes.MetricValueFilter `json:"-"`
|
||||
// reduce to operator for metric scalar requests
|
||||
ReduceTo ReduceTo `json:"reduceTo,omitzero"`
|
||||
ReduceTo ReduceTo `json:"reduceTo,omitzero" description:"How a series collapses to one number in scalar requests: sum, count, avg, min, max, last or median. Ignored by time_series requests."`
|
||||
|
||||
HeatmapBucketing *HeatmapBucketing `json:"-"`
|
||||
|
||||
@@ -564,8 +554,7 @@ func (m MetricAggregation) Copy() MetricAggregation {
|
||||
}
|
||||
|
||||
type Filter struct {
|
||||
// expression to filter by following the filter syntax
|
||||
Expression string `json:"expression"`
|
||||
Expression string `json:"expression" description:"A filter in the query filter syntax: comparisons such as =, !=, <, >, IN, LIKE, CONTAINS and EXISTS on field names, combined with AND, OR and NOT, with string values in single quotes, for example key = 'value' AND other_key > 10. Which fields exist depends on the signal or endpoint the filter is used with."`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of Filter.
|
||||
@@ -588,8 +577,7 @@ func (g GroupByKey) Copy() GroupByKey {
|
||||
}
|
||||
|
||||
type Having struct {
|
||||
// expression to filter by following the filter syntax
|
||||
Expression string `json:"expression"`
|
||||
Expression string `json:"expression" description:"A condition on aggregation results in the filter syntax, referencing an alias or expression, for example count() > 100."`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of Having.
|
||||
@@ -613,10 +601,8 @@ func (o OrderByKey) Copy() OrderByKey {
|
||||
|
||||
// key to order by.
|
||||
type OrderBy struct {
|
||||
// key to order by
|
||||
Key OrderByKey `json:"key"`
|
||||
// direction to order by
|
||||
Direction OrderDirection `json:"direction"`
|
||||
Key OrderByKey `json:"key" description:"The sort key. For aggregation requests name is a group-by field such as service.name, an aggregation alias, or the aggregation expression: count() or p99(duration_nano) for traces and logs; for metrics the spaceAggregation(timeAggregation(metricName)) form such as sum(rate(signoz_calls_total)), p99(metricName) for histogram metrics, or __result. Raw requests sort by any field, such as timestamp."`
|
||||
Direction OrderDirection `json:"direction" description:"The sort direction: asc or desc."`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of OrderBy.
|
||||
@@ -629,21 +615,13 @@ func (o OrderBy) Copy() OrderBy {
|
||||
|
||||
// secondary aggregation to apply to the query.
|
||||
type SecondaryAggregation struct {
|
||||
// stepInterval of the query
|
||||
// if not set, it will use the step interval of the primary aggregation
|
||||
StepInterval Step `json:"stepInterval,omitzero"`
|
||||
// expression to aggregate. example: count(), sum(item_price), countIf(day > 10)
|
||||
Expression string `json:"expression"`
|
||||
// if any, it will be used as the alias of the aggregation in the result
|
||||
Alias string `json:"alias,omitempty"`
|
||||
// groupBy fields to group by
|
||||
GroupBy []GroupByKey `json:"groupBy,omitzero"`
|
||||
// order by keys and directions
|
||||
Order []OrderBy `json:"order,omitzero"`
|
||||
// limit the maximum number of rows to return
|
||||
Limit int `json:"limit,omitempty"`
|
||||
// limitBy fields to limit by
|
||||
LimitBy *LimitBy `json:"limitBy,omitempty"`
|
||||
StepInterval Step `json:"stepInterval,omitzero" description:"The time bucket for this aggregation: seconds as a number or a Go duration string. Uses the primary aggregation step when omitted."`
|
||||
Expression string `json:"expression" description:"The aggregation over the primary results, such as count(), sum(item_price) or countIf(day > 10)."`
|
||||
Alias string `json:"alias,omitempty" description:"The name the aggregation is reported under; when omitted, the result carries the alias __result_N, with N the zero-based index of the aggregation."`
|
||||
GroupBy []GroupByKey `json:"groupBy,omitzero" description:"The fields to group this aggregation by."`
|
||||
Order []OrderBy `json:"order,omitzero" description:"The sort order of this aggregation result."`
|
||||
Limit int `json:"limit,omitempty" description:"The maximum number of groups to return. Unlimited when 0."`
|
||||
LimitBy *LimitBy `json:"limitBy,omitempty" description:"Applies limit within each distinct combination of the given keys."`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of SecondaryAggregation.
|
||||
@@ -672,10 +650,8 @@ func (s SecondaryAggregation) Copy() SecondaryAggregation {
|
||||
}
|
||||
|
||||
type FunctionArg struct {
|
||||
// name of the argument
|
||||
Name string `json:"name,omitempty"`
|
||||
// value of the argument
|
||||
Value any `json:"value"`
|
||||
Name string `json:"name,omitempty" description:"The argument name for functions that take named arguments, such as z_score_threshold or seasonality for anomaly."`
|
||||
Value any `json:"value" description:"The argument value as a number, string or boolean, for example the threshold for cutOffMin or the duration for timeShift."`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of FunctionArg.
|
||||
@@ -695,6 +671,7 @@ func (FunctionArg) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
}
|
||||
|
||||
value := jsonschema.Schema{}
|
||||
value.Description = propertyDescription(s, "value")
|
||||
value.OneOf = []jsonschema.SchemaOrBool{
|
||||
jsonschema.Number.ToSchemaOrBool(),
|
||||
jsonschema.String.ToSchemaOrBool(),
|
||||
@@ -705,11 +682,9 @@ func (FunctionArg) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
}
|
||||
|
||||
type Function struct {
|
||||
// name of the function
|
||||
Name FunctionName `json:"name"`
|
||||
Name FunctionName `json:"name" description:"The function to apply, such as cutOffMin, cutOffMax, clampMin, clampMax, absolute, runningDiff, log2, log10, cumulativeSum, ewma3, ewma5, ewma7, median3, median5, median7, timeShift, anomaly or fillZero."`
|
||||
|
||||
// args is the arguments to the function
|
||||
Args []FunctionArg `json:"args,omitzero"`
|
||||
Args []FunctionArg `json:"args,omitzero" description:"The function arguments; which ones are required depends on the function, for example a threshold for the cutOff and clamp functions."`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of Function.
|
||||
@@ -739,10 +714,8 @@ func (f Function) Validate() error {
|
||||
}
|
||||
|
||||
type LimitBy struct {
|
||||
// keys to limit by
|
||||
Keys []string `json:"keys"`
|
||||
// value to limit by
|
||||
Value string `json:"value"`
|
||||
Keys []string `json:"keys" description:"The group-by field names that partition the result before the limit is applied."`
|
||||
Value string `json:"value" description:"The aggregation alias or expression whose value ranks entries within each partition."`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of LimitBy.
|
||||
@@ -759,3 +732,13 @@ func (l *LimitBy) Copy() *LimitBy {
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// propertyDescription returns the description the struct tag put on a reflected
|
||||
// property, so a PrepareJSONSchema hook that replaces the property schema keeps it.
|
||||
func propertyDescription(s *jsonschema.Schema, name string) *string {
|
||||
prop, ok := s.Properties[name]
|
||||
if !ok || prop.TypeObject == nil {
|
||||
return nil
|
||||
}
|
||||
return prop.TypeObject.Description
|
||||
}
|
||||
|
||||
@@ -12,65 +12,43 @@ import (
|
||||
)
|
||||
|
||||
type QueryBuilderQuery[T any] struct {
|
||||
// name of the query, mainly used when query is used in formula
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name" description:"The unique name of the query, such as A or B; formulas, trace operators and selectedQueryName refer to it."`
|
||||
|
||||
// stepInterval of the query
|
||||
StepInterval Step `json:"stepInterval,omitzero"`
|
||||
StepInterval Step `json:"stepInterval,omitzero" description:"The time bucket for time_series aggregation: seconds as a number or a Go duration string such as 60s or 1m. Derived from the time range when omitted."`
|
||||
|
||||
// signal to query
|
||||
Signal telemetrytypes.Signal `json:"signal,omitempty"`
|
||||
Signal telemetrytypes.Signal `json:"signal,omitempty" description:"The telemetry signal to query: traces, logs or metrics. It selects the aggregation shape and is required for builder_query."`
|
||||
|
||||
// source for query
|
||||
Source telemetrytypes.Source `json:"source"`
|
||||
Source telemetrytypes.Source `json:"source" description:"The data source within the signal. Set to meter for Cost Meter metrics; leave empty for regular telemetry."`
|
||||
|
||||
// we want to support multiple aggregations
|
||||
// currently supported: []Aggregation, []MetricAggregation
|
||||
Aggregations []T `json:"aggregations,omitzero"`
|
||||
Aggregations []T `json:"aggregations,omitzero" description:"The aggregations to compute, one result per entry. Traces and logs take expressions such as count() or p99(duration_nano); metrics take a metricName with time and space aggregations. Omit for raw requests."`
|
||||
|
||||
// BucketOptions is the bucket axis to count this query's values into. Only a
|
||||
// heatmap request reads it, and only from the query it draws.
|
||||
BucketOptions *BucketOptions `json:"bucketOptions,omitempty"`
|
||||
BucketOptions *BucketOptions `json:"bucketOptions,omitempty" description:"The value axis to count this query into, as a kind-discriminated linear or log bucket spec. Read only by heatmap requests, and only from the query they draw."`
|
||||
|
||||
// disabled if true, the query will not be executed
|
||||
Disabled bool `json:"disabled"`
|
||||
Disabled bool `json:"disabled" description:"When true, the result is excluded from the response; the query may still run and can still feed a formula or trace operator. Defaults to false."`
|
||||
|
||||
// search query is simple string
|
||||
Filter *Filter `json:"filter,omitempty"`
|
||||
Filter *Filter `json:"filter,omitempty" description:"The filter expression that selects the rows or series of this signal."`
|
||||
|
||||
// group by keys to group by
|
||||
GroupBy []GroupByKey `json:"groupBy,omitzero"`
|
||||
GroupBy []GroupByKey `json:"groupBy,omitzero" description:"The fields to group aggregations by; each group becomes a series or a row with the field as a label or column."`
|
||||
|
||||
// order by keys and directions
|
||||
Order []OrderBy `json:"order,omitzero"`
|
||||
Order []OrderBy `json:"order,omitzero" description:"The sort order of the result. Order by a group-by field, an aggregation alias, or for raw requests a selected field such as timestamp."`
|
||||
|
||||
// select columns to select
|
||||
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitzero"`
|
||||
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitzero" description:"The fields to return for each row in raw requests, in addition to the columns always present: timestamp and id for logs, timestamp, trace_id and span_id for traces. Ignored by aggregating requests."`
|
||||
|
||||
// limit the maximum number of rows to return
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Limit int `json:"limit,omitempty" description:"The maximum number of rows for raw requests, or of series or rows for grouped aggregations, keeping the top entries by order. When 0, aggregations are unlimited while raw requests ordered by timestamp return a page of 10000 rows; follow nextCursor for the rest."`
|
||||
|
||||
// limitBy fields to limit by
|
||||
LimitBy *LimitBy `json:"limitBy,omitempty"`
|
||||
LimitBy *LimitBy `json:"limitBy,omitempty" description:"Reserved for applying limit within each combination of the given group-by keys. Accepted but currently ignored."`
|
||||
|
||||
// offset the number of rows to skip
|
||||
// TODO: remove this once we have cursor-based pagination everywhere?
|
||||
Offset int `json:"offset,omitempty"`
|
||||
Offset int `json:"offset,omitempty" description:"The number of rows to skip before returning results. Prefer cursor for raw requests."`
|
||||
|
||||
// cursor to paginate the query
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
Cursor string `json:"cursor,omitempty" description:"The opaque nextCursor value from a previous raw result, used to fetch the next page."`
|
||||
|
||||
// having clause to apply to the query
|
||||
Having *Having `json:"having,omitempty"`
|
||||
Having *Having `json:"having,omitempty" description:"A filter applied to aggregation results, referencing aliases or expressions, for example count() > 100."`
|
||||
|
||||
// secondary aggregation to apply to the query
|
||||
// on top of the primary aggregation
|
||||
SecondaryAggregations []SecondaryAggregation `json:"secondaryAggregations,omitzero"`
|
||||
SecondaryAggregations []SecondaryAggregation `json:"secondaryAggregations,omitzero" description:"Accepted but currently ignored, including their nested order, limit and limitBy."`
|
||||
|
||||
// functions to apply to the query
|
||||
Functions []Function `json:"functions,omitzero"`
|
||||
Functions []Function `json:"functions,omitzero" description:"Post-processing functions applied in order to each result series, such as absolute, cumulativeSum, timeShift or anomaly."`
|
||||
|
||||
Legend string `json:"legend"`
|
||||
Legend string `json:"legend" description:"The display name template for each series; {{field}} placeholders are replaced by group-by label values."`
|
||||
|
||||
// ShiftBy is extracted from timeShift function for internal use
|
||||
// This field is not serialized to JSON
|
||||
@@ -96,7 +74,9 @@ func (QueryBuilderQuery[T]) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return nil
|
||||
}
|
||||
prop := (&jsonschema.Schema{}).WithType(jsonschema.String.Type()).WithEnum(signal.StringValue())
|
||||
prop.Description = propertyDescription(s, "signal")
|
||||
s.Properties["signal"] = prop.ToSchemaOrBool()
|
||||
describePropertyForSignal(s, "filter", filterDescriptionsBySignal[signal])
|
||||
if !slices.Contains(s.Required, "signal") {
|
||||
s.Required = append(s.Required, "signal")
|
||||
}
|
||||
@@ -292,3 +272,23 @@ func CanShortCircuitReduced(metricAgg MetricAggregation) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// filterDescriptionsBySignal gives each QueryBuilderQuery instantiation a filter
|
||||
// example drawn from its own signal, so the traces example never shows up on a
|
||||
// logs or metrics query. The shared Filter schema stays signal-neutral.
|
||||
var filterDescriptionsBySignal = map[telemetrytypes.Signal]string{
|
||||
telemetrytypes.SignalTraces: "The filter that selects spans, in the query filter syntax, for example service.name = 'frontend' AND http.response.status_code >= 500.",
|
||||
telemetrytypes.SignalLogs: "The filter that selects log records, in the query filter syntax, for example severity_text = 'ERROR' AND body CONTAINS 'timeout'.",
|
||||
telemetrytypes.SignalMetrics: "The filter that selects series by their attributes, in the query filter syntax, for example k8s.namespace.name = 'prod' AND k8s.pod.name LIKE 'api%'.",
|
||||
}
|
||||
|
||||
// describePropertyForSignal replaces a property's description with a
|
||||
// signal-specific one, keeping whatever schema shape the property already has.
|
||||
func describePropertyForSignal(s *jsonschema.Schema, name, description string) {
|
||||
prop, ok := s.Properties[name]
|
||||
if !ok || prop.TypeObject == nil || description == "" {
|
||||
return
|
||||
}
|
||||
prop.TypeObject.Description = &description
|
||||
s.Properties[name] = prop
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package querybuildertypesv5
|
||||
|
||||
type ClickHouseQuery struct {
|
||||
// name of the query
|
||||
Name string `json:"name"`
|
||||
// query to execute
|
||||
Query string `json:"query"`
|
||||
// disabled if true, the query will not be executed
|
||||
Disabled bool `json:"disabled"`
|
||||
Name string `json:"name" description:"The unique name of the query, such as A."`
|
||||
Query string `json:"query" description:"The ClickHouse SQL statement. Reserved time placeholders such as {{.start_timestamp}}, {{.end_timestamp}}, {{.start_timestamp_ms}} and {{.start_datetime}} and dashboard variables written as {{name}}, [[name]] or $name are substituted before execution."`
|
||||
Disabled bool `json:"disabled" description:"When true, the result is excluded from the response but the query may still run. Defaults to false."`
|
||||
|
||||
Legend string `json:"legend"`
|
||||
Legend string `json:"legend" description:"The display name template for each series; {{column}} placeholders are replaced by result column values."`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of the ClickHouseQuery.
|
||||
|
||||
@@ -17,30 +17,22 @@ import (
|
||||
)
|
||||
|
||||
type QueryBuilderFormula struct {
|
||||
// name of the formula
|
||||
Name string `json:"name"`
|
||||
// expression to apply to the query
|
||||
Expression string `json:"expression"`
|
||||
Name string `json:"name" description:"The unique name of the formula, such as F1, used by selectedQueryName and by panels. Referencing a formula from another formula is not reliable."`
|
||||
Expression string `json:"expression" description:"An arithmetic expression over builder query names, for example A / B * 100. Reference queries, not other formulas."`
|
||||
|
||||
Disabled bool `json:"disabled"`
|
||||
Disabled bool `json:"disabled" description:"When true, the result is excluded from the response; the formula may still be evaluated. Defaults to false."`
|
||||
|
||||
// order by keys and directions
|
||||
Order []OrderBy `json:"order,omitzero"`
|
||||
Order []OrderBy `json:"order,omitzero" description:"The sort order of the formula result."`
|
||||
|
||||
// limit the maximum number of rows to return
|
||||
Limit int `json:"limit,omitempty"`
|
||||
Limit int `json:"limit,omitempty" description:"The maximum number of series or rows to return. Unlimited when 0."`
|
||||
|
||||
// having clause to apply to the formula result
|
||||
Having *Having `json:"having,omitempty"`
|
||||
Having *Having `json:"having,omitempty" description:"Accepted but currently ignored; the computed values are returned unfiltered."`
|
||||
|
||||
// functions to apply to the formula result
|
||||
Functions []Function `json:"functions,omitzero"`
|
||||
Functions []Function `json:"functions,omitzero" description:"Post-processing functions applied in order to each result series."`
|
||||
|
||||
// BucketOptions is the bucket axis to count the formula's results into.
|
||||
// Only a heatmap request reads it, and only from the query it draws.
|
||||
BucketOptions *BucketOptions `json:"bucketOptions,omitempty"`
|
||||
BucketOptions *BucketOptions `json:"bucketOptions,omitempty" description:"The value axis to count the formula result into, as a kind-discriminated linear or log bucket spec. Read only by heatmap requests, and only from the query they draw."`
|
||||
|
||||
Legend string `json:"legend"`
|
||||
Legend string `json:"legend" description:"The display name template for each series; {{field}} placeholders are replaced by label values."`
|
||||
}
|
||||
|
||||
// Copy creates a deep copy of the QueryBuilderFormula.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user