mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-28 03:51:14 +00:00
Compare commits
55 Commits
ns/trace-s
...
nv/rules-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1624b5d2f | ||
|
|
0e25da12c3 | ||
|
|
4ad572a8c2 | ||
|
|
d3d53c8b98 | ||
|
|
b380686577 | ||
|
|
1d0328fae5 | ||
|
|
01b2b49521 | ||
|
|
52f4657f57 | ||
|
|
ba475bdb48 | ||
|
|
94113eeed5 | ||
|
|
680f7de69e | ||
|
|
2237dbde9a | ||
|
|
aac321de58 | ||
|
|
7a7c35eca0 | ||
|
|
41d81cfcbc | ||
|
|
00afd873e3 | ||
|
|
428acb6f23 | ||
|
|
736ad2aba3 | ||
|
|
99f21adadd | ||
|
|
bdfadbbe87 | ||
|
|
e23f96c7a1 | ||
|
|
3e1d76f3c6 | ||
|
|
6b06bacb91 | ||
|
|
5b91346a27 | ||
|
|
a66d6000e5 | ||
|
|
070343a6c0 | ||
|
|
f42084ad31 | ||
|
|
7525667f28 | ||
|
|
bdad1235aa | ||
|
|
19af7782d9 | ||
|
|
119ab97240 | ||
|
|
a7e0ac1f4d | ||
|
|
3a6ae85dee | ||
|
|
1e4c96907d | ||
|
|
b4191b9e10 | ||
|
|
7e043b1221 | ||
|
|
55e76a182d | ||
|
|
9135807532 | ||
|
|
a0d671e0e8 | ||
|
|
a5f5dc4fb6 | ||
|
|
b18258e76f | ||
|
|
872b4172d3 | ||
|
|
08f4c099f6 | ||
|
|
87b1eed20a | ||
|
|
f6cfcdfc78 | ||
|
|
37cf5ae407 | ||
|
|
9a8f66be63 | ||
|
|
3a5078e29b | ||
|
|
7ebb1910b9 | ||
|
|
1e1d6f8d24 | ||
|
|
6f6eea7187 | ||
|
|
0bec6c5494 | ||
|
|
d6744c1d8e | ||
|
|
6476be26bb | ||
|
|
06c54c6483 |
3886
docs/api/openapi.yml
3886
docs/api/openapi.yml
File diff suppressed because it is too large
Load Diff
@@ -34,8 +34,7 @@ type AnomalyRule struct {
|
||||
|
||||
provider anomaly.Provider
|
||||
|
||||
version string
|
||||
logger *slog.Logger
|
||||
logger *slog.Logger
|
||||
|
||||
seasonality anomaly.Seasonality
|
||||
}
|
||||
@@ -63,7 +62,6 @@ func NewAnomalyRule(
|
||||
r := AnomalyRule{
|
||||
BaseRule: baseRule,
|
||||
querier: querier,
|
||||
version: p.Version,
|
||||
logger: logger.With(slog.String("rule.id", id)),
|
||||
}
|
||||
|
||||
@@ -105,15 +103,15 @@ func (r *AnomalyRule) Type() ruletypes.RuleType {
|
||||
}
|
||||
|
||||
func (r *AnomalyRule) prepareQueryRange(ctx context.Context, ts time.Time) *qbtypes.QueryRangeRequest {
|
||||
r.logger.InfoContext(
|
||||
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
|
||||
slog.Int64("eval.window_ms", r.EvalWindow().Milliseconds()),
|
||||
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
|
||||
)
|
||||
|
||||
startTs, endTs := r.Timestamps(ts)
|
||||
start, end := startTs.UnixMilli(), endTs.UnixMilli()
|
||||
|
||||
r.logger.InfoContext(
|
||||
ctx, "prepare query range request", slog.Int64("ts", ts.UnixMilli()),
|
||||
slog.Int64("eval.window_ms", end-start),
|
||||
slog.Int64("eval.delay_ms", r.EvalDelay().Milliseconds()),
|
||||
)
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(start),
|
||||
End: uint64(end),
|
||||
@@ -420,12 +418,10 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
func (r *AnomalyRule) String() string {
|
||||
|
||||
ar := ruletypes.PostableRule{
|
||||
AlertName: valuer.NewDisplayName(r.Name()),
|
||||
RuleCondition: r.Condition(),
|
||||
EvalWindow: r.EvalWindow(),
|
||||
Labels: r.Labels().Map(),
|
||||
Annotations: r.Annotations().Map(),
|
||||
PreferredChannels: r.PreferredChannels(),
|
||||
AlertName: valuer.NewDisplayName(r.Name()),
|
||||
RuleCondition: r.Condition(),
|
||||
Labels: r.Labels().Map(),
|
||||
Annotations: r.Annotations().Map(),
|
||||
}
|
||||
|
||||
byt, err := json.Marshal(ar)
|
||||
|
||||
@@ -54,9 +54,6 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
Target: &target,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypeBuilder,
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
@@ -157,10 +154,7 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
AlertOnAbsent: true,
|
||||
Target: &target,
|
||||
AlertOnAbsent: true,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypeBuilder,
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
|
||||
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;
|
||||
}, []);
|
||||
}
|
||||
@@ -338,7 +338,7 @@ export function buildCreateAnomalyAlertRulePayload(
|
||||
notificationSettings: notificationSettingsProps,
|
||||
evaluation: evaluationProps,
|
||||
version: '',
|
||||
schemaVersion: '',
|
||||
schemaVersion: 'v2alpha1',
|
||||
source: window?.location.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -25,6 +26,7 @@ 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';
|
||||
|
||||
@@ -33,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 {
|
||||
@@ -50,6 +57,11 @@ 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);
|
||||
|
||||
@@ -83,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
|
||||
@@ -123,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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
@@ -239,37 +237,6 @@ func (store *config) ListAllChannels(ctx context.Context) ([]*alertmanagertypes.
|
||||
return channels, nil
|
||||
}
|
||||
|
||||
func (store *config) GetMatchers(ctx context.Context, orgID string) (map[string][]string, error) {
|
||||
type matcher struct {
|
||||
bun.BaseModel `bun:"table:rule"`
|
||||
ID valuer.UUID `bun:"id,pk"`
|
||||
Data string `bun:"data"`
|
||||
}
|
||||
|
||||
matchers := []matcher{}
|
||||
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Column("id", "data").
|
||||
Model(&matchers).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
matchersMap := make(map[string][]string)
|
||||
for _, matcher := range matchers {
|
||||
receivers := gjson.Get(matcher.Data, "preferredChannels").Array()
|
||||
for _, receiver := range receivers {
|
||||
matchersMap[matcher.ID.StringValue()] = append(matchersMap[matcher.ID.StringValue()], receiver.String())
|
||||
}
|
||||
}
|
||||
|
||||
return matchersMap, nil
|
||||
}
|
||||
|
||||
func (store *config) wrap(ctx context.Context, fn func(ctx context.Context) error, opts ...alertmanagertypes.StoreOption) error {
|
||||
storeOpts := alertmanagertypes.NewStoreOptions(opts...)
|
||||
|
||||
|
||||
@@ -245,25 +245,13 @@ func (service *Service) compareAndSelectConfig(ctx context.Context, incomingConf
|
||||
return nil, err
|
||||
}
|
||||
|
||||
matchers, err := service.configStore.GetMatchers(ctx, incomingConfig.StoreableConfig().OrgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err := alertmanagertypes.NewConfigFromChannels(service.config.Global, service.config.Route, channels, incomingConfig.StoreableConfig().OrgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for ruleID, receivers := range matchers {
|
||||
err = config.CreateRuleIDMatcher(ruleID, receivers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if incomingConfig.StoreableConfig().Hash != config.StoreableConfig().Hash {
|
||||
service.settings.Logger().InfoContext(ctx, "mismatch found, updating config to match channels and matchers")
|
||||
service.settings.Logger().InfoContext(ctx, "mismatch found, updating config to match channels")
|
||||
return config, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,20 +11,6 @@ import (
|
||||
)
|
||||
|
||||
func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRules), handler.OpenAPIDef{
|
||||
ID: "ListRules",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "List alert rules",
|
||||
Description: "This endpoint lists all alert rules with their current evaluation state. Deprecated: use ListRulesV3, which supports filtering, sorting and pagination.",
|
||||
Response: make([]*ruletypes.Rule, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v3/rules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListRulesV3), handler.OpenAPIDef{
|
||||
ID: "ListRulesV3",
|
||||
Tags: []string{"rules"},
|
||||
|
||||
@@ -221,16 +221,15 @@ func postableRuleExamples() []handler.OpenAPIExample {
|
||||
},
|
||||
{
|
||||
Name: "metric_anomaly",
|
||||
Summary: "Metric anomaly rule (v1 only)",
|
||||
Description: "Anomaly rules are not yet supported under schemaVersion v2alpha1, so this example uses the v1 shape. Wraps a builder query in the `anomaly` function with daily seasonality SigNoz compares each point against the forecast for that time of day. Fires when the anomaly score stays below the threshold for the entire window; `requireMinPoints` guards against noisy intervals.",
|
||||
Summary: "Metric anomaly rule",
|
||||
Description: "Wraps a builder query in the `anomaly` function with daily seasonality, so SigNoz compares each point against the forecast for that time of day. The threshold is compared against the signed anomaly score, so `below` with a target of -2 fires when an abrupt drop holds for the entire window; `requireMinPoints` guards against noisy intervals.",
|
||||
Value: map[string]any{
|
||||
"alert": "Anomalous drop in ingested spans",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"description": "Detect an abrupt drop in span ingestion using a z-score anomaly function",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"evalWindow": "24h",
|
||||
"frequency": "3h",
|
||||
"alert": "Anomalous drop in ingested spans",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"description": "Detect an abrupt drop in span ingestion using a z-score anomaly function",
|
||||
"ruleType": "anomaly_rule",
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1",
|
||||
"condition": map[string]any{
|
||||
"compositeQuery": map[string]any{
|
||||
"queryType": "builder",
|
||||
@@ -256,17 +255,29 @@ func postableRuleExamples() []handler.OpenAPIExample {
|
||||
},
|
||||
},
|
||||
},
|
||||
"op": "below",
|
||||
"matchType": "all_the_times",
|
||||
"target": 2,
|
||||
"algorithm": "standard",
|
||||
"seasonality": "daily",
|
||||
"selectedQueryName": "A",
|
||||
"requireMinPoints": true,
|
||||
"requiredNumPoints": 3,
|
||||
"thresholds": map[string]any{
|
||||
"kind": "basic",
|
||||
"spec": []any{
|
||||
map[string]any{
|
||||
"name": "warning",
|
||||
"op": "below",
|
||||
"matchType": "all_the_times",
|
||||
"target": -2,
|
||||
"channels": []any{"slack-ingestion"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"labels": map[string]any{"severity": "warning"},
|
||||
"preferredChannels": []any{"slack-ingestion"},
|
||||
"evaluation": rolling("24h", "3h"),
|
||||
"notificationSettings": map[string]any{
|
||||
"renotify": renotify("4h", "firing"),
|
||||
},
|
||||
"labels": map[string]any{"severity": "warning"},
|
||||
"annotations": map[string]any{
|
||||
"description": "Ingestion rate for tenant {{$tenant_id}} is anomalously low (z-score {{$value}}).",
|
||||
"summary": "Span ingestion anomaly",
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -59,11 +59,7 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
|
||||
}
|
||||
}
|
||||
if builder.evaluation == nil {
|
||||
evalWindow := rule.EvalWindow
|
||||
if evalWindow.IsZero() {
|
||||
evalWindow = valuer.MustParseTextDuration("5m")
|
||||
}
|
||||
builder.evaluation = ruletypes.RollingWindow{EvalWindow: evalWindow}
|
||||
return nil
|
||||
}
|
||||
|
||||
return builder
|
||||
|
||||
@@ -3,10 +3,8 @@ package app
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/clickhousesql"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -360,14 +358,6 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
router.HandleFunc("/api/v1/query_range", am.ViewAccess(aH.queryRangeMetrics)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/query", am.ViewAccess(aH.queryMetrics)).Methods(http.MethodGet)
|
||||
|
||||
router.HandleFunc("/api/v1/rules", am.ViewAccess(aH.listRules)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/rules/{id}", am.ViewAccess(aH.getRule)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/rules", am.EditAccess(aH.createRule)).Methods(http.MethodPost)
|
||||
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.editRule)).Methods(http.MethodPut)
|
||||
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.deleteRule)).Methods(http.MethodDelete)
|
||||
router.HandleFunc("/api/v1/rules/{id}", am.EditAccess(aH.patchRule)).Methods(http.MethodPatch)
|
||||
router.HandleFunc("/api/v1/testRule", am.EditAccess(aH.testRule)).Methods(http.MethodPost)
|
||||
|
||||
router.HandleFunc("/api/v2/variables/query", am.ViewAccess(aH.queryDashboardVarsV2)).Methods(http.MethodPost)
|
||||
|
||||
router.HandleFunc("/api/v1/explorer/views", am.ViewAccess(aH.Signoz.Handlers.SavedView.List)).Methods(http.MethodGet)
|
||||
@@ -496,166 +486,6 @@ func (aH *APIHandler) PopulateTemporality(ctx context.Context, orgID valuer.UUID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (aH *APIHandler) listRules(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
rules, err := aH.ruleManager.ListRuleStates(r.Context())
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// todo(amol): need to add sorter
|
||||
|
||||
aH.Respond(w, rules)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) getRule(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := mux.Vars(r)["id"]
|
||||
id, err := valuer.NewUUID(idStr)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
ruleResponse, err := aH.ruleManager.GetRule(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
|
||||
return
|
||||
}
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
aH.Respond(w, ruleResponse)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) createRule(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
aH.logger.ErrorContext(r.Context(), "error reading request body for create rule", errors.Attr(err))
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
rule, err := aH.ruleManager.CreateRule(r.Context(), string(body))
|
||||
if err != nil {
|
||||
RespondError(w, toApiError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
aH.Respond(w, rule)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) editRule(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := mux.Vars(r)["id"]
|
||||
id, err := valuer.NewUUID(idStr)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
aH.logger.ErrorContext(r.Context(), "error reading request body for edit rule", errors.Attr(err))
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
err = aH.ruleManager.EditRule(r.Context(), string(body), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
|
||||
return
|
||||
}
|
||||
RespondError(w, toApiError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
aH.Respond(w, "rule successfully edited")
|
||||
}
|
||||
|
||||
func (aH *APIHandler) deleteRule(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["id"]
|
||||
|
||||
err := aH.ruleManager.DeleteRule(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
|
||||
return
|
||||
}
|
||||
RespondError(w, toApiError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
aH.Respond(w, "rule successfully deleted")
|
||||
}
|
||||
|
||||
func (aH *APIHandler) patchRule(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := mux.Vars(r)["id"]
|
||||
id, err := valuer.NewUUID(idStr)
|
||||
if err != nil {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
aH.logger.ErrorContext(r.Context(), "error reading request body for patch rule", errors.Attr(err))
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
gettableRule, err := aH.ruleManager.PatchRule(r.Context(), string(body), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorNotFound, Err: fmt.Errorf("rule not found")}, nil)
|
||||
return
|
||||
}
|
||||
RespondError(w, toApiError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
aH.Respond(w, gettableRule)
|
||||
}
|
||||
|
||||
func (aH *APIHandler) testRule(w http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
aH.logger.ErrorContext(r.Context(), "error reading request body for test rule", errors.Attr(err))
|
||||
RespondError(w, &model.ApiError{Typ: model.ErrorBadData, Err: err}, nil)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
alertCount, err := aH.ruleManager.TestNotification(ctx, orgID, string(body))
|
||||
if err != nil {
|
||||
RespondError(w, toApiError(err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"alertCount": alertCount,
|
||||
"message": "notification sent",
|
||||
}
|
||||
aH.Respond(w, response)
|
||||
}
|
||||
|
||||
func prepareQuery(r *http.Request) (string, error) {
|
||||
var postData *model.DashboardVars
|
||||
|
||||
|
||||
@@ -34,10 +34,6 @@ type BaseRule struct {
|
||||
ruleCondition *ruletypes.RuleCondition
|
||||
|
||||
Threshold ruletypes.RuleThreshold
|
||||
// evalWindow is the time window used for evaluating the rule
|
||||
// i.e. each time we lookback from the current time, we look at data for the last
|
||||
// evalWindow duration
|
||||
evalWindow valuer.TextDuration
|
||||
// holdDuration is the duration for which the alert waits before firing
|
||||
holdDuration valuer.TextDuration
|
||||
|
||||
@@ -49,10 +45,7 @@ type BaseRule struct {
|
||||
// these are the same for all alerts created for this rule
|
||||
labels ruletypes.Labels
|
||||
annotations ruletypes.Labels
|
||||
// preferredChannels is the list of channels to send the alert to
|
||||
// if the rule is triggered
|
||||
preferredChannels []string
|
||||
mtx sync.Mutex
|
||||
mtx sync.Mutex
|
||||
// the time it took to evaluate the rule (most recent evaluation)
|
||||
evaluationDuration time.Duration
|
||||
// the timestamp of the last evaluation
|
||||
@@ -151,26 +144,27 @@ func NewBaseRule(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := threshold.Validate(); err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid rule threshold spec: %v", err)
|
||||
}
|
||||
evaluation, err := p.Evaluation.GetEvaluation()
|
||||
if err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to get evaluation: %v", err)
|
||||
}
|
||||
|
||||
baseRule := &BaseRule{
|
||||
id: id,
|
||||
orgID: orgID,
|
||||
externalURL: externalURL,
|
||||
name: p.AlertName.StringValue(),
|
||||
typ: p.AlertType,
|
||||
ruleCondition: p.RuleCondition,
|
||||
evalWindow: p.EvalWindow,
|
||||
labels: ruletypes.FromMap(p.Labels),
|
||||
annotations: ruletypes.FromMap(p.Annotations),
|
||||
preferredChannels: p.PreferredChannels,
|
||||
health: ruletypes.HealthUnknown,
|
||||
Active: map[uint64]*ruletypes.Alert{},
|
||||
Threshold: threshold,
|
||||
evaluation: evaluation,
|
||||
id: id,
|
||||
orgID: orgID,
|
||||
externalURL: externalURL,
|
||||
name: p.AlertName.StringValue(),
|
||||
typ: p.AlertType,
|
||||
ruleCondition: p.RuleCondition,
|
||||
labels: ruletypes.FromMap(p.Labels),
|
||||
annotations: ruletypes.FromMap(p.Annotations),
|
||||
health: ruletypes.HealthUnknown,
|
||||
Active: map[uint64]*ruletypes.Alert{},
|
||||
Threshold: threshold,
|
||||
evaluation: evaluation,
|
||||
}
|
||||
|
||||
// Store newGroupEvalDelay and groupBy keys from NotificationSettings
|
||||
@@ -178,10 +172,6 @@ func NewBaseRule(
|
||||
baseRule.newGroupEvalDelay = p.NotificationSettings.NewGroupEvalDelay
|
||||
}
|
||||
|
||||
if baseRule.evalWindow.IsZero() {
|
||||
baseRule.evalWindow = valuer.MustParseTextDuration("5m")
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(baseRule)
|
||||
}
|
||||
@@ -232,10 +222,6 @@ func (r *BaseRule) EvalDelay() valuer.TextDuration {
|
||||
return r.evalDelay
|
||||
}
|
||||
|
||||
func (r *BaseRule) EvalWindow() valuer.TextDuration {
|
||||
return r.evalWindow
|
||||
}
|
||||
|
||||
func (r *BaseRule) HoldDuration() valuer.TextDuration {
|
||||
return r.holdDuration
|
||||
}
|
||||
@@ -246,7 +232,6 @@ func (r *BaseRule) Name() string { return r.name }
|
||||
func (r *BaseRule) Condition() *ruletypes.RuleCondition { return r.ruleCondition }
|
||||
func (r *BaseRule) Labels() ruletypes.Labels { return r.labels }
|
||||
func (r *BaseRule) Annotations() ruletypes.Labels { return r.annotations }
|
||||
func (r *BaseRule) PreferredChannels() []string { return r.preferredChannels }
|
||||
|
||||
func (r *BaseRule) GeneratorURL() string {
|
||||
params := url.Values{}
|
||||
|
||||
@@ -332,17 +332,28 @@ func (m *Manager) validateChannels(ctx context.Context, orgID string, rule *rule
|
||||
}
|
||||
|
||||
known := make(map[string]struct{}, len(orgChannels))
|
||||
displayNameByName := make(map[string]string, len(orgChannels))
|
||||
for _, ch := range orgChannels {
|
||||
known[ch.DisplayName] = struct{}{}
|
||||
displayNameByName[ch.Name] = ch.DisplayName
|
||||
}
|
||||
|
||||
var unknown []string
|
||||
var unknown, byName []string
|
||||
for _, name := range channels {
|
||||
if _, ok := known[name]; !ok {
|
||||
unknown = append(unknown, name)
|
||||
if _, ok := known[name]; ok {
|
||||
continue
|
||||
}
|
||||
if displayName, ok := displayNameByName[name]; ok {
|
||||
byName = append(byName, fmt.Sprintf("%s (display name %q)", name, displayName))
|
||||
continue
|
||||
}
|
||||
unknown = append(unknown, name)
|
||||
}
|
||||
|
||||
if len(byName) > 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"channels: rules reference channels by display name, not name: %v", byName)
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"channels: the following channels do not exist: %v", unknown)
|
||||
@@ -359,7 +370,7 @@ func (m *Manager) EditRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
||||
}
|
||||
orgID := valuer.MustNewUUID(claims.OrgID)
|
||||
parsedRule := ruletypes.PostableRule{}
|
||||
err = json.Unmarshal([]byte(ruleStr), &parsedRule)
|
||||
err = parsedRule.DecodeStrict(ruleStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -438,7 +449,7 @@ func (m *Manager) editTask(_ context.Context, orgID valuer.UUID, rule *ruletypes
|
||||
})
|
||||
if err != nil {
|
||||
m.logger.Error("loading tasks failed", errors.Attr(err))
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "error preparing rule with given parameters, previous rule set restored")
|
||||
return err
|
||||
}
|
||||
|
||||
for _, r := range newTask.Rules() {
|
||||
@@ -547,7 +558,7 @@ func (m *Manager) deleteTask(taskName string) {
|
||||
// starts an executor for the rule, unless the rule is disabled
|
||||
func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error) {
|
||||
parsedRule := ruletypes.PostableRule{}
|
||||
if err := json.Unmarshal([]byte(ruleStr), &parsedRule); err != nil {
|
||||
if err := parsedRule.DecodeStrict(ruleStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -659,7 +670,7 @@ func (m *Manager) addTask(_ context.Context, orgID valuer.UUID, rule *ruletypes.
|
||||
})
|
||||
if err != nil {
|
||||
m.logger.Error("creating rule task failed", "name", taskName, errors.Attr(err))
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "error loading rules, previous rule set restored")
|
||||
return err
|
||||
}
|
||||
|
||||
for _, r := range newTask.Rules() {
|
||||
@@ -1049,7 +1060,7 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(ruleStr), &storedRule); err != nil {
|
||||
if err := storedRule.DecodeStrict(ruleStr); err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to unmarshal patched rule with given id", slog.String("rule.id", id.StringValue()), errors.Attr(err))
|
||||
return nil, err
|
||||
}
|
||||
@@ -1105,9 +1116,9 @@ func (m *Manager) PatchRule(ctx context.Context, ruleStr string, id valuer.UUID)
|
||||
// sends a test notification. returns alert count and error (if any)
|
||||
func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleStr string) (int, error) {
|
||||
parsedRule := ruletypes.PostableRule{}
|
||||
err := json.Unmarshal([]byte(ruleStr), &parsedRule)
|
||||
err := parsedRule.DecodeStrict(ruleStr)
|
||||
if err != nil {
|
||||
return 0, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to unmarshal rule")
|
||||
return 0, err
|
||||
}
|
||||
if err := parsedRule.Validate(); err != nil {
|
||||
return 0, err
|
||||
|
||||
@@ -49,13 +49,12 @@ func ThresholdRuleAtLeastOnceValueAbove(target float64, recovery *float64) rulet
|
||||
Annotations: map[string]string{
|
||||
"value": "{{$value}}",
|
||||
},
|
||||
Version: "v5",
|
||||
Version: "v5",
|
||||
SchemaVersion: ruletypes.SchemaVersionV2Alpha1,
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
Target: &target,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypeBuilder,
|
||||
PanelType: ruletypes.PanelTypeGraph,
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
@@ -108,12 +107,10 @@ func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes
|
||||
Annotations: map[string]string{
|
||||
"value": "{{$value}}",
|
||||
},
|
||||
Version: "v5",
|
||||
Version: "v5",
|
||||
SchemaVersion: ruletypes.SchemaVersionV2Alpha1,
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
SelectedQuery: "A",
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
Target: &target,
|
||||
SelectedQuery: "A",
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypePromQL,
|
||||
PanelType: ruletypes.PanelTypeGraph,
|
||||
@@ -138,7 +135,6 @@ func BuildPromAtLeastOnceValueAbove(target float64, recovery *float64) ruletypes
|
||||
RecoveryTarget: recovery,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
Channels: []string{"slack"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
|
||||
type PromRule struct {
|
||||
*BaseRule
|
||||
version string
|
||||
prometheus prometheus.Prometheus
|
||||
}
|
||||
|
||||
@@ -47,7 +46,6 @@ func NewPromRule(
|
||||
|
||||
p := PromRule{
|
||||
BaseRule: baseRule,
|
||||
version: postableRule.Version,
|
||||
prometheus: prometheus,
|
||||
}
|
||||
|
||||
@@ -356,12 +354,10 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
|
||||
func (r *PromRule) String() string {
|
||||
ar := ruletypes.PostableRule{
|
||||
AlertName: valuer.NewDisplayName(r.name),
|
||||
RuleCondition: r.ruleCondition,
|
||||
EvalWindow: r.evalWindow,
|
||||
Labels: r.labels.Map(),
|
||||
Annotations: r.annotations.Map(),
|
||||
PreferredChannels: r.preferredChannels,
|
||||
AlertName: valuer.NewDisplayName(r.name),
|
||||
RuleCondition: r.ruleCondition,
|
||||
Labels: r.labels.Map(),
|
||||
Annotations: r.annotations.Map(),
|
||||
}
|
||||
|
||||
byt, err := json.Marshal(ar)
|
||||
|
||||
@@ -690,13 +690,11 @@ func TestPromRuleEval(t *testing.T) {
|
||||
logger := instrumentationtest.New().Logger()
|
||||
|
||||
for idx, c := range cases {
|
||||
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
||||
postableRule.RuleCondition.MatchType = c.matchType
|
||||
postableRule.RuleCondition.Target = &c.target
|
||||
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: "critical",
|
||||
TargetValue: &c.target,
|
||||
MatchType: c.matchType,
|
||||
CompareOperator: c.compareOperator,
|
||||
@@ -905,11 +903,7 @@ func TestPromRuleUnitCombinations(t *testing.T) {
|
||||
|
||||
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
|
||||
|
||||
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
||||
postableRule.RuleCondition.MatchType = ruletypes.MatchType(c.matchType)
|
||||
postableRule.RuleCondition.Target = &c.target
|
||||
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
|
||||
postableRule.RuleCondition.TargetUnit = c.targetUnit
|
||||
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
@@ -1198,11 +1192,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
|
||||
|
||||
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
|
||||
|
||||
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
||||
postableRule.RuleCondition.MatchType = c.matchType
|
||||
postableRule.RuleCondition.Target = &c.target
|
||||
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
|
||||
postableRule.RuleCondition.TargetUnit = c.targetUnit
|
||||
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
@@ -1259,6 +1249,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
|
||||
|
||||
func TestPromRule_NoData(t *testing.T) {
|
||||
evalTime := time.Now()
|
||||
target := 200.0
|
||||
|
||||
postableRule := ruletypes.PostableRule{
|
||||
AlertName: valuer.NewDisplayName("Test no data"),
|
||||
@@ -1269,8 +1260,6 @@ func TestPromRule_NoData(t *testing.T) {
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypePromQL,
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
@@ -1282,7 +1271,7 @@ func TestPromRule_NoData(t *testing.T) {
|
||||
},
|
||||
Thresholds: &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{{Name: "Test no data"}},
|
||||
Spec: ruletypes.BasicRuleThresholds{{Name: "Test no data", TargetValue: &target, CompareOperator: ruletypes.ValueIsAbove, MatchType: ruletypes.AtleastOnce}},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1365,10 +1354,7 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
AlertOnAbsent: true,
|
||||
Target: &target,
|
||||
AlertOnAbsent: true,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypePromQL,
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
@@ -1491,8 +1477,6 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypePromQL,
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
@@ -1562,9 +1546,9 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
|
||||
for _, c := range cases {
|
||||
rc := postableRule.RuleCondition
|
||||
|
||||
rc.Target = &targetForNoAlert
|
||||
target := &targetForNoAlert
|
||||
if c.alertCondition {
|
||||
rc.Target = &targetForAlert
|
||||
target = &targetForAlert
|
||||
}
|
||||
rc.RequireMinPoints = c.requireMinPoints
|
||||
rc.RequiredNumPoints = c.requiredNumPoints
|
||||
@@ -1573,9 +1557,9 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: postableRule.AlertName.StringValue(),
|
||||
TargetValue: rc.Target,
|
||||
MatchType: rc.MatchType,
|
||||
CompareOperator: rc.CompareOperator,
|
||||
TargetValue: target,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -20,15 +20,12 @@ type Rule interface {
|
||||
Annotations() ruletypes.Labels
|
||||
Condition() *ruletypes.RuleCondition
|
||||
EvalDelay() valuer.TextDuration
|
||||
EvalWindow() valuer.TextDuration
|
||||
HoldDuration() valuer.TextDuration
|
||||
State() ruletypes.AlertState
|
||||
ActiveAlerts() []*ruletypes.Alert
|
||||
// ActiveAlertsLabelFP returns a map of active alert labels fingerprint
|
||||
ActiveAlertsLabelFP() map[uint64]struct{}
|
||||
|
||||
PreferredChannels() []string
|
||||
|
||||
// Eval evaluates the rule at the given timestamp and returns the number of active alerts.
|
||||
Eval(context.Context, time.Time) (int, error)
|
||||
String() string
|
||||
|
||||
@@ -61,16 +61,16 @@ func (r *ThresholdRule) Type() ruletypes.RuleType {
|
||||
}
|
||||
|
||||
func (r *ThresholdRule) prepareQueryRange(ctx context.Context, ts time.Time) (*qbtypes.QueryRangeRequest, error) {
|
||||
startTs, endTs := r.Timestamps(ts)
|
||||
start, end := startTs.UnixMilli(), endTs.UnixMilli()
|
||||
|
||||
r.logger.InfoContext(
|
||||
ctx, "prepare query range request v5",
|
||||
slog.Int64("ts", ts.UnixMilli()),
|
||||
slog.Int64("eval_window", r.evalWindow.Milliseconds()),
|
||||
slog.Int64("eval_window", end-start),
|
||||
slog.Int64("eval_delay", r.evalDelay.Milliseconds()),
|
||||
)
|
||||
|
||||
startTs, endTs := r.Timestamps(ts)
|
||||
start, end := startTs.UnixMilli(), endTs.UnixMilli()
|
||||
|
||||
req := &qbtypes.QueryRangeRequest{
|
||||
Start: uint64(start),
|
||||
End: uint64(end),
|
||||
@@ -455,12 +455,10 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
|
||||
func (r *ThresholdRule) String() string {
|
||||
ar := ruletypes.PostableRule{
|
||||
AlertName: valuer.NewDisplayName(r.name),
|
||||
RuleCondition: r.ruleCondition,
|
||||
EvalWindow: r.evalWindow,
|
||||
Labels: r.labels.Map(),
|
||||
Annotations: r.annotations.Map(),
|
||||
PreferredChannels: r.preferredChannels,
|
||||
AlertName: valuer.NewDisplayName(r.name),
|
||||
RuleCondition: r.ruleCondition,
|
||||
Labels: r.labels.Map(),
|
||||
Annotations: r.annotations.Map(),
|
||||
}
|
||||
|
||||
byt, err := json.Marshal(ar)
|
||||
|
||||
@@ -63,6 +63,7 @@ func TestThresholdRuleEvalWithoutRecoveryTarget(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: "critical",
|
||||
TargetValue: &c.target,
|
||||
MatchType: c.matchType,
|
||||
CompareOperator: c.compareOperator,
|
||||
@@ -171,10 +172,7 @@ func TestPrepareParamsForLogs(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
Target: &[]float64{0.0}[0],
|
||||
SelectedQuery: "A",
|
||||
SelectedQuery: "A",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -183,9 +181,10 @@ func TestPrepareParamsForLogs(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
TargetValue: postableRule.RuleCondition.Target,
|
||||
MatchType: postableRule.RuleCondition.MatchType,
|
||||
CompareOperator: postableRule.RuleCondition.CompareOperator,
|
||||
Name: "critical",
|
||||
TargetValue: &[]float64{0.0}[0],
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -239,10 +238,7 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
Target: &[]float64{0.0}[0],
|
||||
SelectedQuery: "A",
|
||||
SelectedQuery: "A",
|
||||
},
|
||||
Version: "v5",
|
||||
}
|
||||
@@ -252,9 +248,10 @@ func TestPrepareParamsForLogsFilterExpression(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
TargetValue: postableRule.RuleCondition.Target,
|
||||
MatchType: postableRule.RuleCondition.MatchType,
|
||||
CompareOperator: postableRule.RuleCondition.CompareOperator,
|
||||
Name: "critical",
|
||||
TargetValue: &[]float64{0.0}[0],
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -304,10 +301,7 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
Target: &[]float64{0.0}[0],
|
||||
SelectedQuery: "A",
|
||||
SelectedQuery: "A",
|
||||
},
|
||||
Version: "v5",
|
||||
}
|
||||
@@ -317,9 +311,10 @@ func TestPrepareParamsForTracesFilterExpression(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
TargetValue: postableRule.RuleCondition.Target,
|
||||
MatchType: postableRule.RuleCondition.MatchType,
|
||||
CompareOperator: postableRule.RuleCondition.CompareOperator,
|
||||
Name: "critical",
|
||||
TargetValue: &[]float64{0.0}[0],
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -366,10 +361,7 @@ func TestPrepareParamsForTraces(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
Target: &[]float64{0.0}[0],
|
||||
SelectedQuery: "A",
|
||||
SelectedQuery: "A",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -378,9 +370,10 @@ func TestPrepareParamsForTraces(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
TargetValue: postableRule.RuleCondition.Target,
|
||||
MatchType: postableRule.RuleCondition.MatchType,
|
||||
CompareOperator: postableRule.RuleCondition.CompareOperator,
|
||||
Name: "critical",
|
||||
TargetValue: &[]float64{0.0}[0],
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsNotEq,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -470,6 +463,7 @@ func TestThresholdRuleLabelNormalization(t *testing.T) {
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
{
|
||||
Name: "critical",
|
||||
TargetValue: &c.target,
|
||||
MatchType: c.matchType,
|
||||
CompareOperator: c.compareOperator,
|
||||
@@ -653,11 +647,7 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
|
||||
ExpectQuery(queryString).
|
||||
WithArgs(nil, nil, nil, nil, nil, nil, nil, nil).
|
||||
WillReturnRows(rows)
|
||||
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
||||
postableRule.RuleCondition.MatchType = c.matchType
|
||||
postableRule.RuleCondition.Target = &c.target
|
||||
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
|
||||
postableRule.RuleCondition.TargetUnit = c.targetUnit
|
||||
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
@@ -867,11 +857,7 @@ func TestThresholdRuleTracesLink(t *testing.T) {
|
||||
|
||||
querier := prepareQuerierForTraces(t, telemetryStore, keysMap)
|
||||
|
||||
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
||||
postableRule.RuleCondition.MatchType = c.matchType
|
||||
postableRule.RuleCondition.Target = &c.target
|
||||
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
|
||||
postableRule.RuleCondition.TargetUnit = c.targetUnit
|
||||
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
@@ -975,11 +961,7 @@ func TestThresholdRuleAITracesLink(t *testing.T) {
|
||||
|
||||
querier := prepareQuerierForAITraces(t, telemetryStore, keysMap)
|
||||
|
||||
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
||||
postableRule.RuleCondition.MatchType = c.matchType
|
||||
postableRule.RuleCondition.Target = &c.target
|
||||
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
|
||||
postableRule.RuleCondition.TargetUnit = c.targetUnit
|
||||
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
@@ -1083,11 +1065,7 @@ func TestThresholdRuleLogsLink(t *testing.T) {
|
||||
|
||||
querier := prepareQuerierForLogs(t, telemetryStore, keysMap)
|
||||
|
||||
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
||||
postableRule.RuleCondition.MatchType = c.matchType
|
||||
postableRule.RuleCondition.Target = &c.target
|
||||
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
|
||||
postableRule.RuleCondition.TargetUnit = c.targetUnit
|
||||
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
@@ -1256,11 +1234,7 @@ func TestMultipleThresholdRule(t *testing.T) {
|
||||
querier, mockMetadataStore := prepareQuerierForMetrics(t, telemetryStore)
|
||||
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
|
||||
|
||||
postableRule.RuleCondition.CompareOperator = c.compareOperator
|
||||
postableRule.RuleCondition.MatchType = c.matchType
|
||||
postableRule.RuleCondition.Target = &c.target
|
||||
postableRule.RuleCondition.CompositeQuery.Unit = c.yAxisUnit
|
||||
postableRule.RuleCondition.TargetUnit = c.targetUnit
|
||||
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
|
||||
Kind: ruletypes.BasicThresholdKind,
|
||||
Spec: ruletypes.BasicRuleThresholds{
|
||||
@@ -1930,8 +1904,6 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
|
||||
Frequency: valuer.MustParseTextDuration("1m"),
|
||||
}},
|
||||
RuleCondition: &ruletypes.RuleCondition{
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompositeQuery: &ruletypes.AlertCompositeQuery{
|
||||
QueryType: ruletypes.QueryTypeBuilder,
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
@@ -2050,7 +2022,6 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
|
||||
mockMetadataStore.TypeMap["signoz_calls_total"] = metrictypes.SumType
|
||||
|
||||
rc := postableRule.RuleCondition
|
||||
rc.Target = &c.target
|
||||
rc.RequireMinPoints = c.requireMinPoints
|
||||
rc.RequiredNumPoints = c.requiredNumPoints
|
||||
rc.Thresholds = &ruletypes.RuleThresholdData{
|
||||
@@ -2059,8 +2030,8 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
|
||||
{
|
||||
Name: postableRule.AlertName.StringValue(),
|
||||
TargetValue: &c.target,
|
||||
MatchType: rc.MatchType,
|
||||
CompareOperator: rc.CompareOperator,
|
||||
MatchType: ruletypes.AtleastOnce,
|
||||
CompareOperator: ruletypes.ValueIsAbove,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package ruler
|
||||
import "net/http"
|
||||
|
||||
type Handler interface {
|
||||
ListRules(http.ResponseWriter, *http.Request)
|
||||
ListRulesV3(http.ResponseWriter, *http.Request)
|
||||
GetRuleByID(http.ResponseWriter, *http.Request)
|
||||
CreateRule(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -25,24 +25,6 @@ func NewHandler(ruler ruler.Ruler) ruler.Handler {
|
||||
return &handler{ruler: ruler}
|
||||
}
|
||||
|
||||
func (handler *handler) ListRules(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rules, err := handler.ruler.ListRuleStates(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
view := make([]*ruletypes.Rule, 0, len(rules.Rules))
|
||||
for _, rule := range rules.Rules {
|
||||
view = append(view, ruletypes.NewRule(rule))
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (handler *handler) ListRulesV3(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -72,7 +54,7 @@ func (handler *handler) GetRuleByID(rw http.ResponseWriter, req *http.Request) {
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -111,7 +93,7 @@ func (handler *handler) CloneRuleByID(rw http.ResponseWriter, req *http.Request)
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -130,7 +112,7 @@ func (handler *handler) UpdateRuleByID(rw http.ResponseWriter, req *http.Request
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -156,7 +138,7 @@ func (handler *handler) DeleteRuleByID(rw http.ResponseWriter, req *http.Request
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -175,7 +157,7 @@ func (handler *handler) PatchRuleByID(rw http.ResponseWriter, req *http.Request)
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -279,7 +261,7 @@ func (handler *handler) GetDowntimeScheduleByID(rw http.ResponseWriter, req *htt
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -322,7 +304,7 @@ func (handler *handler) UpdateDowntimeScheduleByID(rw http.ResponseWriter, req *
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -352,7 +334,7 @@ func (handler *handler) DeleteDowntimeScheduleByID(rw http.ResponseWriter, req *
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -436,7 +418,7 @@ func (handler *handler) UpdateRuleView(rw http.ResponseWriter, req *http.Request
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -472,7 +454,7 @@ func (handler *handler) DeleteRuleView(rw http.ResponseWriter, req *http.Request
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(req)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid-v7"))
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is not a valid uuid"))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddMetricsFieldTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddGCPConnectionMethodFactory(),
|
||||
sqlmigration.NewDeleteOrphanSARolesFactory(),
|
||||
sqlmigration.NewMigrateRulesV1ToV2Alpha1Factory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
602
pkg/sqlmigration/140_migrate_rules_v1_to_v2alpha1.go
Normal file
602
pkg/sqlmigration/140_migrate_rules_v1_to_v2alpha1.go
Normal file
@@ -0,0 +1,602 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
)
|
||||
|
||||
const (
|
||||
schemaVersionV2Alpha1 = "v2alpha1"
|
||||
ruleTypeThreshold = "threshold_rule"
|
||||
ruleTypeProm = "promql_rule"
|
||||
ruleTypeAnomaly = "anomaly_rule"
|
||||
queryTypeBuilder = "builder"
|
||||
queryTypePromQL = "promql"
|
||||
queryTypeClickHouseSQL = "clickhouse_sql"
|
||||
panelTypeGraph = "graph"
|
||||
alertTypeMetric = "METRIC_BASED_ALERT"
|
||||
alertTypeTraces = "TRACES_BASED_ALERT"
|
||||
alertTypeLogs = "LOGS_BASED_ALERT"
|
||||
signalLogs = "logs"
|
||||
signalTraces = "traces"
|
||||
compareOperatorBelow = "2"
|
||||
criticalThresholdName = "critical"
|
||||
labelSeverity = "severity"
|
||||
defaultEvalWindow = "5m"
|
||||
defaultFrequency = "1m"
|
||||
)
|
||||
|
||||
// The values the write path accepts; a migrated rule outside them is written
|
||||
// disabled, for the user to fix.
|
||||
var (
|
||||
supportedCompareOperators = []string{"1", "above", ">", "2", "below", "<", "3", "equal", "eq", "=", "4", "not_equal", "not_eq", "!=", "5", "above_or_equal", "above_or_eq", ">=", "6", "below_or_equal", "below_or_eq", "<=", "7", "outside_bounds"}
|
||||
supportedMatchTypes = []string{"1", "at_least_once", "2", "all_the_times", "3", "on_average", "avg", "4", "in_total", "sum", "5", "last"}
|
||||
)
|
||||
|
||||
// The values the write path accepts; a migrated row outside them cannot be
|
||||
// edited until the field is supplied, so the migration derives them.
|
||||
var (
|
||||
supportedAlertTypes = []string{"METRIC_BASED_ALERT", "TRACES_BASED_ALERT", "LOGS_BASED_ALERT", "EXCEPTIONS_BASED_ALERT", "AI_TRACES_BASED_ALERT"}
|
||||
supportedQueryTypes = []string{"builder", "clickhouse_sql", "promql"}
|
||||
supportedPanelTypes = []string{"value", "table", "graph"}
|
||||
)
|
||||
|
||||
type migrateRulesV1ToV2Alpha1 struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewMigrateRulesV1ToV2Alpha1Factory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("migrate_rules_v1_to_v2alpha1"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &migrateRulesV1ToV2Alpha1{sqlstore: sqlstore, logger: ps.Logger}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *migrateRulesV1ToV2Alpha1) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
// Up rewrites every v1 rule in every org as v2alpha1 in place. A row that does
|
||||
// not decode, or a v1 rule that cannot be rewritten, is logged and left as is;
|
||||
// a failed write aborts the run and rolls back every org.
|
||||
func (migration *migrateRulesV1ToV2Alpha1) Up(ctx context.Context, _ *bun.DB) error {
|
||||
return migration.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
|
||||
var orgIDs []string
|
||||
if err := migration.sqlstore.BunDBCtx(ctx).NewSelect().Table("organizations").Column("id").Scan(ctx, &orgIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
if err := migration.convertOrg(ctx, orgID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *migrateRulesV1ToV2Alpha1) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *migrateRulesV1ToV2Alpha1) convertOrg(ctx context.Context, orgID string) error {
|
||||
db := migration.sqlstore.BunDBCtx(ctx)
|
||||
|
||||
type ruleRow struct {
|
||||
bun.BaseModel `bun:"table:rule"`
|
||||
|
||||
ID string `bun:"id,pk"`
|
||||
OrgID string `bun:"org_id"`
|
||||
Data string `bun:"data"`
|
||||
}
|
||||
|
||||
allRules := make([]*ruleRow, 0)
|
||||
if err := db.NewSelect().Model(&allRules).Where("org_id = ?", orgID).Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
allRoutePolicies := make([]*routePolicyRow, 0)
|
||||
if err := db.NewSelect().Model(&allRoutePolicies).Where("org_id = ?", orgID).Scan(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
// the name is not unique; a v1 rule has one threshold so one row, and any
|
||||
// duplicate is as good as another
|
||||
routePolicyNameToRoute := make(map[string]*routePolicyRow)
|
||||
for _, route := range allRoutePolicies {
|
||||
routePolicyNameToRoute[route.Name] = route
|
||||
}
|
||||
|
||||
var migrated, disabled, skipped, undecodable, unmigratable int
|
||||
for _, storedRule := range allRules {
|
||||
storedRuleData := new(postableRule)
|
||||
if err := json.Unmarshal([]byte(storedRule.Data), storedRuleData); err != nil {
|
||||
undecodable++
|
||||
migration.logger.WarnContext(ctx, "failed to decode stored rule", slog.String("org_id", orgID), slog.String("rule_id", storedRule.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
if storedRuleData.SchemaVersion == schemaVersionV2Alpha1 {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
storedRoute := routePolicyNameToRoute[storedRule.ID]
|
||||
if err := validateRoute(storedRoute, storedRuleData.PreferredChannels); err != nil {
|
||||
unmigratable++
|
||||
migration.logger.WarnContext(ctx, "leaving rule at v1", slog.String("org_id", orgID), slog.String("rule_id", storedRule.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
|
||||
convertedRule, convertedRoute, err := convertRule(storedRule.ID, storedRuleData, storedRoute)
|
||||
if err != nil {
|
||||
unmigratable++
|
||||
migration.logger.WarnContext(ctx, "leaving rule at v1", slog.String("org_id", orgID), slog.String("rule_id", storedRule.ID), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
if cause := convertedRule.disableIfNeverFired(); cause != "" {
|
||||
disabled++
|
||||
migration.logger.WarnContext(ctx, "migrating rule as disabled, it never fired in v1", slog.String("org_id", orgID), slog.String("rule_id", storedRule.ID), slog.String("cause", cause))
|
||||
}
|
||||
|
||||
data, err := marshalUnescaped(convertedRule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storedRule.Data = string(data)
|
||||
if _, err := db.NewUpdate().Model(storedRule).Column("data").WherePK().Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if convertedRoute != nil && convertedRoute.Expression != storedRoute.Expression {
|
||||
if _, err := db.NewUpdate().Model(convertedRoute).Column("expression").WherePK().Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
migrated++
|
||||
}
|
||||
|
||||
migration.logger.InfoContext(ctx, "migrated rules from v1 to v2alpha1", slog.String("org_id", orgID), slog.Int("total", len(allRules)), slog.Int("migrated", migrated), slog.Int("disabled", disabled), slog.Int("skipped", skipped), slog.Int("undecodable", undecodable), slog.Int("unmigratable", unmigratable))
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateRoute checks the rule's route_policy row, named after the rule. A
|
||||
// rule that never had channels got no row from migration 049 and migrates with
|
||||
// none; a rule with preferred channels but no row would lose them, so it is
|
||||
// left at v1.
|
||||
func validateRoute(route *routePolicyRow, preferredChannels []string) error {
|
||||
if route == nil && len(preferredChannels) > 0 {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "no route_policy row for a rule with %d preferred channels", len(preferredChannels))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertRule returns a v2alpha1 copy of storedRuleData, derived in the order
|
||||
// the v1 decode derived the same values, with the rule's route_policy row as it
|
||||
// must be stored alongside. Version and source are left as stored. A nil
|
||||
// storedRoute is a rule that never had channels.
|
||||
func convertRule(ruleID string, storedRuleData *postableRule, storedRoute *routePolicyRow) (*postableRule, *routePolicyRow, error) {
|
||||
if storedRuleData.RuleCondition == nil {
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "condition in stored rule data is null")
|
||||
}
|
||||
channels := []string{}
|
||||
if storedRoute != nil {
|
||||
channels = storedRoute.Channels
|
||||
}
|
||||
|
||||
rule := storedRuleData.clone()
|
||||
rule.fillDurationDefaults()
|
||||
rule.deriveAlertType()
|
||||
rule.deriveQueryType()
|
||||
rule.repairPanelType()
|
||||
rule.disableUnmetMinPoints()
|
||||
rule.deriveRuleType()
|
||||
rule.negateAnomalyBelowTarget()
|
||||
convertedRoute := rule.repairEmptySeverity(ruleID, storedRoute)
|
||||
rule.relocateThreshold(channels)
|
||||
rule.relocateEvaluation()
|
||||
rule.synthesizeNotificationSettings()
|
||||
rule.SchemaVersion = schemaVersionV2Alpha1
|
||||
rule.clearV1Fields()
|
||||
|
||||
return rule, convertedRoute, nil
|
||||
}
|
||||
|
||||
type routePolicyRow struct {
|
||||
bun.BaseModel `bun:"table:route_policy"`
|
||||
|
||||
ID string `bun:"id,pk"`
|
||||
OrgID string `bun:"org_id"`
|
||||
Name string `bun:"name"`
|
||||
Expression string `bun:"expression"`
|
||||
Channels []string `bun:"channels,type:jsonb"`
|
||||
}
|
||||
|
||||
// postableRule mirrors PostableRule key for key, in its order. Keys the
|
||||
// crosswalk reads or writes are typed; the rest pass through as stored.
|
||||
type postableRule struct {
|
||||
AlertName json.RawMessage `json:"alert,omitempty"`
|
||||
AlertType string `json:"alertType"`
|
||||
Description json.RawMessage `json:"description,omitempty"`
|
||||
RuleType string `json:"ruleType,omitempty"`
|
||||
EvalWindow textDuration `json:"evalWindow,omitzero"`
|
||||
Frequency textDuration `json:"frequency,omitzero"`
|
||||
RuleCondition *ruleCondition `json:"condition"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Annotations json.RawMessage `json:"annotations,omitempty"`
|
||||
Disabled json.RawMessage `json:"disabled,omitempty"`
|
||||
Source json.RawMessage `json:"source,omitempty"`
|
||||
PreferredChannels []string `json:"preferredChannels,omitempty"`
|
||||
Version json.RawMessage `json:"version,omitempty"`
|
||||
Evaluation *evaluationEnvelope `json:"evaluation,omitempty"`
|
||||
SchemaVersion string `json:"schemaVersion,omitempty"`
|
||||
NotificationSettings *notificationSettings `json:"notificationSettings,omitempty"`
|
||||
}
|
||||
|
||||
type ruleCondition struct {
|
||||
CompositeQuery *compositeQuery `json:"compositeQuery"`
|
||||
CompareOperator string `json:"op,omitempty"`
|
||||
Target *float64 `json:"target,omitempty"`
|
||||
AlertOnAbsent bool `json:"alertOnAbsent,omitempty"`
|
||||
AbsentFor json.RawMessage `json:"absentFor,omitempty"`
|
||||
MatchType string `json:"matchType,omitempty"`
|
||||
TargetUnit string `json:"targetUnit,omitempty"`
|
||||
Seasonality string `json:"seasonality,omitempty"`
|
||||
SelectedQuery json.RawMessage `json:"selectedQueryName,omitempty"`
|
||||
RequireMinPoints json.RawMessage `json:"requireMinPoints,omitempty"`
|
||||
RequiredNumPoints json.RawMessage `json:"requiredNumPoints,omitempty"`
|
||||
Thresholds *ruleThresholds `json:"thresholds,omitempty"`
|
||||
}
|
||||
|
||||
type compositeQuery struct {
|
||||
Queries json.RawMessage `json:"queries,omitempty"`
|
||||
PanelType string `json:"panelType"`
|
||||
QueryType string `json:"queryType"`
|
||||
Unit json.RawMessage `json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
// ruleQueryEnvelope reads the two envelope keys the derivations need.
|
||||
type ruleQueryEnvelope struct {
|
||||
Type string `json:"type"`
|
||||
Spec struct {
|
||||
Signal string `json:"signal"`
|
||||
} `json:"spec"`
|
||||
}
|
||||
|
||||
type ruleThresholds struct {
|
||||
Kind string `json:"kind"`
|
||||
Spec []basicThreshold `json:"spec"`
|
||||
}
|
||||
|
||||
type basicThreshold struct {
|
||||
Name string `json:"name"`
|
||||
TargetValue *float64 `json:"target"`
|
||||
TargetUnit string `json:"targetUnit"`
|
||||
RecoveryTarget *float64 `json:"recoveryTarget"`
|
||||
MatchType string `json:"matchType"`
|
||||
CompareOperator string `json:"op"`
|
||||
Channels []string `json:"channels"`
|
||||
}
|
||||
|
||||
type evaluationEnvelope struct {
|
||||
Kind string `json:"kind"`
|
||||
Spec rollingWindow `json:"spec"`
|
||||
}
|
||||
|
||||
type rollingWindow struct {
|
||||
EvalWindow textDuration `json:"evalWindow"`
|
||||
Frequency textDuration `json:"frequency"`
|
||||
}
|
||||
|
||||
type notificationSettings struct {
|
||||
Renotify *renotify `json:"renotify,omitempty"`
|
||||
UsePolicy bool `json:"usePolicy"`
|
||||
}
|
||||
|
||||
type renotify struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ReNotifyInterval textDuration `json:"interval,omitzero"`
|
||||
AlertStates []string `json:"alertStates,omitzero"`
|
||||
}
|
||||
|
||||
// clone copies what the conversion writes to: the rule, its condition, the
|
||||
// composite query and the labels. Raw keys share their bytes.
|
||||
func (rule *postableRule) clone() *postableRule {
|
||||
cloned := *rule
|
||||
if rule.RuleCondition != nil {
|
||||
condition := *rule.RuleCondition
|
||||
if condition.CompositeQuery != nil {
|
||||
query := *condition.CompositeQuery
|
||||
condition.CompositeQuery = &query
|
||||
}
|
||||
cloned.RuleCondition = &condition
|
||||
}
|
||||
cloned.Labels = maps.Clone(rule.Labels)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// disableIfNeverFired disables a rule v1 stored but could never fire: a
|
||||
// negative evalWindow or frequency, which is reset to its default since no
|
||||
// read of a rolling window accepts it, a null compositeQuery or one with no
|
||||
// queries, or a threshold with a null target or an op or matchType outside the
|
||||
// supported sets, which the v2 write path rejects. It reads the relocated
|
||||
// fields, so it runs after convertRule, and returns the offending fields, or
|
||||
// "" when the rule stays enabled.
|
||||
func (rule *postableRule) disableIfNeverFired() string {
|
||||
var causes []string
|
||||
window := &rule.Evaluation.Spec
|
||||
if !window.EvalWindow.IsPositive() {
|
||||
causes = append(causes, fmt.Sprintf("evalWindow %s is negative", window.EvalWindow))
|
||||
window.EvalWindow = mustParseTextDuration(defaultEvalWindow)
|
||||
}
|
||||
if !window.Frequency.IsPositive() {
|
||||
causes = append(causes, fmt.Sprintf("frequency %s is negative", window.Frequency))
|
||||
window.Frequency = mustParseTextDuration(defaultFrequency)
|
||||
}
|
||||
switch {
|
||||
case rule.RuleCondition.CompositeQuery == nil:
|
||||
causes = append(causes, "condition.compositeQuery is null")
|
||||
case len(rule.decodeQueryEnvelopes()) == 0:
|
||||
causes = append(causes, "condition.compositeQuery.queries is empty")
|
||||
}
|
||||
threshold := rule.RuleCondition.Thresholds.Spec[0]
|
||||
switch {
|
||||
case threshold.TargetValue == nil:
|
||||
causes = append(causes, "condition.target is null")
|
||||
case !slices.Contains(supportedCompareOperators, threshold.CompareOperator):
|
||||
causes = append(causes, fmt.Sprintf("condition.op: unsupported value %q", threshold.CompareOperator))
|
||||
case !slices.Contains(supportedMatchTypes, threshold.MatchType):
|
||||
causes = append(causes, fmt.Sprintf("condition.matchType: unsupported value %q", threshold.MatchType))
|
||||
}
|
||||
if len(causes) == 0 {
|
||||
return ""
|
||||
}
|
||||
rule.Disabled = json.RawMessage("true")
|
||||
return strings.Join(causes, ", ")
|
||||
}
|
||||
|
||||
func (rule *postableRule) fillDurationDefaults() {
|
||||
if rule.EvalWindow.IsZero() {
|
||||
rule.EvalWindow = mustParseTextDuration(defaultEvalWindow)
|
||||
}
|
||||
if rule.Frequency.IsZero() {
|
||||
rule.Frequency = mustParseTextDuration(defaultFrequency)
|
||||
}
|
||||
}
|
||||
|
||||
func (rule *postableRule) deriveAlertType() {
|
||||
if slices.Contains(supportedAlertTypes, rule.AlertType) {
|
||||
return
|
||||
}
|
||||
// promql, clickhouse and formula queries carry no signal; they alert on metrics
|
||||
rule.AlertType = alertTypeMetric
|
||||
for _, envelope := range rule.decodeQueryEnvelopes() {
|
||||
switch envelope.Spec.Signal {
|
||||
case signalLogs:
|
||||
rule.AlertType = alertTypeLogs
|
||||
return
|
||||
case signalTraces:
|
||||
rule.AlertType = alertTypeTraces
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (rule *postableRule) deriveQueryType() {
|
||||
query := rule.RuleCondition.CompositeQuery
|
||||
if query == nil || slices.Contains(supportedQueryTypes, query.QueryType) {
|
||||
return
|
||||
}
|
||||
envelopes := rule.decodeQueryEnvelopes()
|
||||
if len(envelopes) == 0 {
|
||||
return
|
||||
}
|
||||
// a promql or clickhouse_sql envelope spells its type like the composite query does; every other envelope is a builder one
|
||||
switch envelopes[0].Type {
|
||||
case queryTypePromQL, queryTypeClickHouseSQL:
|
||||
query.QueryType = envelopes[0].Type
|
||||
default:
|
||||
query.QueryType = queryTypeBuilder
|
||||
}
|
||||
}
|
||||
|
||||
func (rule *postableRule) repairPanelType() {
|
||||
query := rule.RuleCondition.CompositeQuery
|
||||
// nothing reads panelType; both alert forms write graph
|
||||
if query != nil && !slices.Contains(supportedPanelTypes, query.PanelType) {
|
||||
query.PanelType = panelTypeGraph
|
||||
}
|
||||
}
|
||||
|
||||
func (rule *postableRule) disableUnmetMinPoints() {
|
||||
condition := rule.RuleCondition
|
||||
if string(condition.RequireMinPoints) != "true" {
|
||||
return
|
||||
}
|
||||
points := 0.0
|
||||
if len(condition.RequiredNumPoints) > 0 {
|
||||
if err := json.Unmarshal(condition.RequiredNumPoints, &points); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if points > 0 {
|
||||
return
|
||||
}
|
||||
// zero required points is a check every series passes
|
||||
condition.RequireMinPoints = json.RawMessage("false")
|
||||
}
|
||||
|
||||
func (rule *postableRule) decodeQueryEnvelopes() []ruleQueryEnvelope {
|
||||
query := rule.RuleCondition.CompositeQuery
|
||||
if query == nil {
|
||||
return nil
|
||||
}
|
||||
var envelopes []ruleQueryEnvelope
|
||||
if err := json.Unmarshal(query.Queries, &envelopes); err != nil {
|
||||
return nil
|
||||
}
|
||||
return envelopes
|
||||
}
|
||||
|
||||
func (rule *postableRule) deriveRuleType() {
|
||||
query := rule.RuleCondition.CompositeQuery
|
||||
if query == nil {
|
||||
return
|
||||
}
|
||||
switch query.QueryType {
|
||||
case queryTypeBuilder, queryTypeClickHouseSQL:
|
||||
if rule.RuleType == "" {
|
||||
rule.RuleType = ruleTypeThreshold
|
||||
}
|
||||
case queryTypePromQL:
|
||||
rule.RuleType = ruleTypeProm
|
||||
}
|
||||
}
|
||||
|
||||
// negateAnomalyBelowTarget stores "below 3" as -3, since anomaly scores are
|
||||
// signed. Only the raw "2" negates; "below" and "<" keep the sign the rule
|
||||
// evaluates with today.
|
||||
func (rule *postableRule) negateAnomalyBelowTarget() {
|
||||
condition := rule.RuleCondition
|
||||
if rule.RuleType != ruleTypeAnomaly || condition.CompareOperator != compareOperatorBelow || condition.Target == nil {
|
||||
return
|
||||
}
|
||||
negated := -*condition.Target
|
||||
condition.Target = &negated
|
||||
}
|
||||
|
||||
// repairEmptySeverity rewrites a severity of "" as critical in labels and in
|
||||
// the route expression that matches alerts on it, since a threshold named ""
|
||||
// fails every later read. It returns storedRoute, or a copy with the new
|
||||
// expression.
|
||||
func (rule *postableRule) repairEmptySeverity(ruleID string, storedRoute *routePolicyRow) *routePolicyRow {
|
||||
if severity, ok := rule.Labels[labelSeverity]; !ok || severity != "" {
|
||||
return storedRoute
|
||||
}
|
||||
rule.Labels[labelSeverity] = criticalThresholdName
|
||||
if storedRoute == nil {
|
||||
return nil
|
||||
}
|
||||
route := *storedRoute
|
||||
route.Expression = fmt.Sprintf(`threshold.name == "%s" && ruleId == "%s"`, criticalThresholdName, ruleID)
|
||||
return &route
|
||||
}
|
||||
|
||||
func (rule *postableRule) relocateThreshold(channels []string) {
|
||||
thresholdName := criticalThresholdName
|
||||
if severity, ok := rule.Labels[labelSeverity]; ok {
|
||||
thresholdName = severity
|
||||
}
|
||||
|
||||
condition := rule.RuleCondition
|
||||
condition.Thresholds = &ruleThresholds{
|
||||
Kind: "basic",
|
||||
Spec: []basicThreshold{{
|
||||
Name: thresholdName,
|
||||
TargetValue: condition.Target,
|
||||
TargetUnit: condition.TargetUnit,
|
||||
MatchType: condition.MatchType,
|
||||
CompareOperator: condition.CompareOperator,
|
||||
Channels: channels,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func (rule *postableRule) relocateEvaluation() {
|
||||
rule.Evaluation = &evaluationEnvelope{
|
||||
Kind: "rolling",
|
||||
Spec: rollingWindow{EvalWindow: rule.EvalWindow, Frequency: rule.Frequency},
|
||||
}
|
||||
}
|
||||
|
||||
func (rule *postableRule) synthesizeNotificationSettings() {
|
||||
alertStates := []string{"firing"}
|
||||
if rule.RuleCondition.AlertOnAbsent {
|
||||
alertStates = append(alertStates, "nodata")
|
||||
}
|
||||
rule.NotificationSettings = ¬ificationSettings{
|
||||
Renotify: &renotify{
|
||||
Enabled: true,
|
||||
ReNotifyInterval: mustParseTextDuration("4h"),
|
||||
AlertStates: alertStates,
|
||||
},
|
||||
UsePolicy: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (rule *postableRule) clearV1Fields() {
|
||||
rule.EvalWindow = textDuration{}
|
||||
rule.Frequency = textDuration{}
|
||||
rule.PreferredChannels = nil
|
||||
rule.RuleCondition.Target = nil
|
||||
rule.RuleCondition.CompareOperator = ""
|
||||
rule.RuleCondition.MatchType = ""
|
||||
rule.RuleCondition.TargetUnit = ""
|
||||
}
|
||||
|
||||
// textDuration decodes a duration the way the app's TextDuration does, a
|
||||
// string or a number of nanoseconds, and writes a string back in its stored
|
||||
// spelling.
|
||||
type textDuration struct {
|
||||
text string
|
||||
value time.Duration
|
||||
}
|
||||
|
||||
func mustParseTextDuration(text string) textDuration {
|
||||
value, err := time.ParseDuration(text)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return textDuration{text: text, value: value}
|
||||
}
|
||||
|
||||
func (d textDuration) IsZero() bool {
|
||||
return d.value == 0
|
||||
}
|
||||
|
||||
func (d textDuration) IsPositive() bool {
|
||||
return d.value > 0
|
||||
}
|
||||
|
||||
func (d textDuration) String() string {
|
||||
if d.text != "" {
|
||||
return d.text
|
||||
}
|
||||
return d.value.String()
|
||||
}
|
||||
|
||||
func (d textDuration) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(d.String())
|
||||
}
|
||||
|
||||
func (d *textDuration) UnmarshalJSON(data []byte) error {
|
||||
var raw any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
switch value := raw.(type) {
|
||||
case float64:
|
||||
*d = textDuration{value: time.Duration(value)}
|
||||
return nil
|
||||
case string:
|
||||
parsed, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*d = textDuration{text: value, value: parsed}
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid duration %s", data)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -619,10 +619,6 @@ type ConfigStore interface {
|
||||
|
||||
// ListAllChannels returns the list of channels for all organizations.
|
||||
ListAllChannels(context.Context) ([]*Channel, error)
|
||||
|
||||
// GetMatchers gets a list of matchers per organization.
|
||||
// Matchers is an array of ruleId to receiver names.
|
||||
GetMatchers(context.Context, string) (map[string][]string, error)
|
||||
}
|
||||
|
||||
// MarshalSecretValue if set to true will expose Secret type
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user