mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-13 21:50:31 +01:00
Compare commits
3 Commits
mute-rules
...
test/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb7d3503c6 | ||
|
|
914e87158b | ||
|
|
b98359a785 |
@@ -4802,8 +4802,6 @@ components:
|
||||
type: string
|
||||
kind:
|
||||
$ref: '#/components/schemas/RuletypesMaintenanceKind'
|
||||
labelExpression:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
schedule:
|
||||
@@ -4831,8 +4829,6 @@ components:
|
||||
type: array
|
||||
description:
|
||||
type: string
|
||||
labelExpression:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
schedule:
|
||||
@@ -4897,6 +4893,10 @@ components:
|
||||
properties:
|
||||
duration:
|
||||
type: string
|
||||
endTime:
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
repeatOn:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesRepeatOn'
|
||||
@@ -4904,7 +4904,11 @@ components:
|
||||
type: array
|
||||
repeatType:
|
||||
$ref: '#/components/schemas/RuletypesRepeatType'
|
||||
startTime:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- startTime
|
||||
- duration
|
||||
- repeatType
|
||||
type: object
|
||||
@@ -5068,7 +5072,6 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- timezone
|
||||
- startTime
|
||||
type: object
|
||||
RuletypesScheduleType:
|
||||
enum:
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error) {
|
||||
@@ -48,7 +49,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
rules = append(rules, tr)
|
||||
|
||||
// create ch rule task for evaluation
|
||||
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
|
||||
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
|
||||
|
||||
} else if opts.Rule.RuleType == ruletypes.RuleTypeProm {
|
||||
|
||||
@@ -72,7 +73,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
rules = append(rules, pr)
|
||||
|
||||
// create promql rule task for evaluation
|
||||
task = newTask(baserules.TaskTypeProm, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
|
||||
task = newTask(baserules.TaskTypeProm, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
|
||||
|
||||
} else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly {
|
||||
// create anomaly rule
|
||||
@@ -95,7 +96,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
|
||||
rules = append(rules, ar)
|
||||
|
||||
// create anomaly rule task for evaluation
|
||||
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
|
||||
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
|
||||
|
||||
} else {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
|
||||
@@ -209,9 +210,9 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, error) {
|
||||
}
|
||||
|
||||
// newTask returns an appropriate group for the rule type
|
||||
func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc) baserules.Task {
|
||||
func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) baserules.Task {
|
||||
if taskType == baserules.TaskTypeCh {
|
||||
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify)
|
||||
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify, maintenanceStore, orgID)
|
||||
}
|
||||
return baserules.NewPromRuleTask(name, "", frequency, rules, opts, notify)
|
||||
return baserules.NewPromRuleTask(name, "", frequency, rules, opts, notify, maintenanceStore, orgID)
|
||||
}
|
||||
|
||||
@@ -7042,10 +7042,6 @@ export interface RuletypesPlannedMaintenanceDTO {
|
||||
*/
|
||||
id: string;
|
||||
kind: RuletypesMaintenanceKindDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
labelExpression?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -7073,10 +7069,6 @@ export interface RuletypesPostablePlannedMaintenanceDTO {
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
labelExpression?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -7150,12 +7142,23 @@ export interface RuletypesRecurrenceDTO {
|
||||
* @type string
|
||||
*/
|
||||
duration: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
* @nullable true
|
||||
*/
|
||||
endTime?: Date | null;
|
||||
/**
|
||||
* @type array
|
||||
* @nullable true
|
||||
*/
|
||||
repeatOn?: RuletypesRepeatOnDTO[] | null;
|
||||
repeatType: RuletypesRepeatTypeDTO;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
startTime: Date;
|
||||
}
|
||||
|
||||
export interface RuletypesRenotifyDTO {
|
||||
@@ -7337,7 +7340,7 @@ export interface RuletypesScheduleDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
startTime: Date;
|
||||
startTime?: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -81,20 +81,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.alert-rule-scope {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.ant-radio-wrapper {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.alert-rule-all-warning {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.formItemWithBullet {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { DeepPartial } from 'utils/types';
|
||||
|
||||
import 'dayjs/locale/en';
|
||||
|
||||
@@ -25,7 +24,7 @@ import { PlannedDowntimeDeleteModal } from './PlannedDowntimeDeleteModal';
|
||||
import { PlannedDowntimeForm } from './PlannedDowntimeForm';
|
||||
import { PlannedDowntimeList } from './PlannedDowntimeList';
|
||||
import {
|
||||
defaultInitialValues,
|
||||
defautlInitialValues,
|
||||
deleteDowntimeHandler,
|
||||
} from './PlannedDowntimeutils';
|
||||
|
||||
@@ -49,8 +48,8 @@ export function PlannedDowntime(): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const [initialValues, setInitialValues] =
|
||||
useState<DeepPartial<RuletypesPlannedMaintenanceDTO & { editMode: boolean }>>(
|
||||
defaultInitialValues,
|
||||
useState<Partial<RuletypesPlannedMaintenanceDTO & { editMode: boolean }>>(
|
||||
defautlInitialValues,
|
||||
);
|
||||
|
||||
const downtimeSchedules = useListDowntimeSchedules();
|
||||
@@ -149,7 +148,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={(): void => {
|
||||
setInitialValues({ ...defaultInitialValues, editMode: false });
|
||||
setInitialValues({ ...defautlInitialValues, editMode: false });
|
||||
setIsOpen(true);
|
||||
setEditMode(false);
|
||||
form.resetFields();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Check, Info } from '@signozhq/icons';
|
||||
import { Check } from '@signozhq/icons';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
@@ -8,15 +8,12 @@ import {
|
||||
FormInstance,
|
||||
Input,
|
||||
Modal,
|
||||
Radio,
|
||||
Select,
|
||||
SelectProps,
|
||||
Spin,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import { DatePickerProps } from 'antd/es/date-picker';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
createDowntimeSchedule,
|
||||
@@ -42,7 +39,6 @@ import { defaultTo, isEmpty } from 'lodash-es';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { ALL_TIME_ZONES } from 'utils/timeZoneUtil';
|
||||
import { DeepPartial } from 'utils/types';
|
||||
|
||||
import 'dayjs/locale/en';
|
||||
|
||||
@@ -68,23 +64,20 @@ const TIME_FORMAT = DATE_TIME_FORMATS.TIME;
|
||||
const DATE_FORMAT = DATE_TIME_FORMATS.ORDINAL_DATE;
|
||||
const ORDINAL_FORMAT = DATE_TIME_FORMATS.ORDINAL_ONLY;
|
||||
|
||||
type AlertRuleScope = 'all' | 'specific';
|
||||
|
||||
interface PlannedDowntimeFormData {
|
||||
name: string;
|
||||
startTime: dayjs.Dayjs | string;
|
||||
endTime: dayjs.Dayjs | string;
|
||||
recurrence?: RuletypesRecurrenceDTO | null;
|
||||
alertRuleScope: AlertRuleScope;
|
||||
alertRules: DefaultOptionType[];
|
||||
recurrenceSelect?: RuletypesRecurrenceDTO;
|
||||
timezone?: string;
|
||||
labelExpression?: string;
|
||||
}
|
||||
|
||||
const customFormat = DATE_TIME_FORMATS.ORDINAL_DATETIME;
|
||||
|
||||
interface PlannedDowntimeFormProps {
|
||||
initialValues: DeepPartial<
|
||||
initialValues: Partial<
|
||||
RuletypesPlannedMaintenanceDTO & {
|
||||
editMode: boolean;
|
||||
}
|
||||
@@ -96,7 +89,7 @@ interface PlannedDowntimeFormProps {
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
refetchAllSchedules: () => void;
|
||||
isEditMode: boolean;
|
||||
form: FormInstance;
|
||||
form: FormInstance<any>;
|
||||
}
|
||||
|
||||
export function PlannedDowntimeForm(
|
||||
@@ -133,12 +126,6 @@ export function PlannedDowntimeForm(
|
||||
recurrenceOptions.doesNotRepeat.value,
|
||||
);
|
||||
|
||||
const [alertRuleScope, setAlertRuleScope] = useState<AlertRuleScope>(
|
||||
initialValues.id && (initialValues.alertIds || []).length === 0
|
||||
? 'all'
|
||||
: 'specific',
|
||||
);
|
||||
|
||||
const timezoneInitialValue = !isEmpty(initialValues.schedule?.timezone)
|
||||
? (initialValues.schedule?.timezone as string)
|
||||
: undefined;
|
||||
@@ -146,28 +133,26 @@ export function PlannedDowntimeForm(
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const datePickerFooter: DatePickerProps['renderExtraFooter'] = (mode) =>
|
||||
const datePickerFooter = (mode: any): any =>
|
||||
mode === 'time' ? (
|
||||
<span style={{ color: 'gray' }}>Please select the time</span>
|
||||
) : null;
|
||||
|
||||
const saveHandler = useCallback(
|
||||
const saveHanlder = useCallback(
|
||||
async (values: PlannedDowntimeFormData) => {
|
||||
const shouldKeepLocalTime = !isEditMode;
|
||||
const data: RuletypesPostablePlannedMaintenanceDTO = {
|
||||
alertIds:
|
||||
values.alertRuleScope === 'all'
|
||||
? []
|
||||
: (values.alertRules
|
||||
.map((alert) => alert.value)
|
||||
.filter((alert) => alert !== undefined) as string[]),
|
||||
alertIds: values.alertRules
|
||||
.map((alert) => alert.value)
|
||||
.filter((alert) => alert !== undefined) as string[],
|
||||
name: values.name,
|
||||
labelExpression: values.labelExpression,
|
||||
schedule: {
|
||||
startTime: new Date(
|
||||
handleTimeConversion(
|
||||
values.startTime,
|
||||
timezoneInitialValue,
|
||||
values.timezone,
|
||||
shouldKeepLocalTime,
|
||||
),
|
||||
),
|
||||
timezone: values.timezone as string,
|
||||
@@ -177,6 +162,7 @@ export function PlannedDowntimeForm(
|
||||
values.endTime,
|
||||
timezoneInitialValue,
|
||||
values.timezone,
|
||||
shouldKeepLocalTime,
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
@@ -217,24 +203,38 @@ export function PlannedDowntimeForm(
|
||||
],
|
||||
);
|
||||
const onFinish = async (values: PlannedDowntimeFormData): Promise<void> => {
|
||||
const { recurrence } = values;
|
||||
const recurrenceData =
|
||||
!recurrence ||
|
||||
recurrence.repeatType === recurrenceOptions.doesNotRepeat.value
|
||||
values?.recurrence?.repeatType === recurrenceOptions.doesNotRepeat.value
|
||||
? undefined
|
||||
: {
|
||||
duration: recurrence.duration
|
||||
? `${recurrence.duration}${durationUnit}`
|
||||
duration: values.recurrence?.duration
|
||||
? `${values.recurrence?.duration}${durationUnit}`
|
||||
: undefined,
|
||||
repeatOn: recurrence.repeatOn?.length ? recurrence.repeatOn : undefined,
|
||||
repeatType: recurrence.repeatType,
|
||||
endTime: !isEmpty(values.endTime)
|
||||
? handleTimeConversion(
|
||||
values.endTime,
|
||||
timezoneInitialValue,
|
||||
values.timezone,
|
||||
!isEditMode,
|
||||
)
|
||||
: undefined,
|
||||
startTime: handleTimeConversion(
|
||||
values.startTime,
|
||||
timezoneInitialValue,
|
||||
values.timezone,
|
||||
!isEditMode,
|
||||
),
|
||||
repeatOn: !values.recurrence?.repeatOn?.length
|
||||
? undefined
|
||||
: values.recurrence?.repeatOn,
|
||||
repeatType: values.recurrence?.repeatType,
|
||||
};
|
||||
|
||||
const payloadValues = {
|
||||
...values,
|
||||
recurrence: recurrenceData as RuletypesRecurrenceDTO | undefined,
|
||||
};
|
||||
await saveHandler(payloadValues);
|
||||
await saveHanlder(payloadValues);
|
||||
};
|
||||
|
||||
const formValidationRules = [
|
||||
@@ -275,20 +275,19 @@ export function PlannedDowntimeForm(
|
||||
};
|
||||
|
||||
const formatedInitialValues = useMemo(() => {
|
||||
const initialAlertIds = initialValues.alertIds || [];
|
||||
const isExistingSchedule = Boolean(initialValues.id);
|
||||
const formData: PlannedDowntimeFormData = {
|
||||
name: defaultTo(initialValues.name, ''),
|
||||
alertRuleScope:
|
||||
isExistingSchedule && initialAlertIds.length === 0 ? 'all' : 'specific',
|
||||
alertRules: getAlertOptionsFromIds(initialAlertIds, alertOptions),
|
||||
alertRules: getAlertOptionsFromIds(
|
||||
initialValues.alertIds || [],
|
||||
alertOptions,
|
||||
),
|
||||
endTime: getEndTime(initialValues) ? dayjs(getEndTime(initialValues)) : '',
|
||||
startTime: initialValues.schedule?.startTime
|
||||
? dayjs(initialValues.schedule?.startTime)
|
||||
: '',
|
||||
recurrence: {
|
||||
...initialValues.schedule?.recurrence,
|
||||
repeatType: (!isScheduleRecurring(initialValues.schedule)
|
||||
repeatType: (!isScheduleRecurring(initialValues?.schedule)
|
||||
? recurrenceOptions.doesNotRepeat.value
|
||||
: initialValues.schedule?.recurrence
|
||||
?.repeatType) as RuletypesRecurrenceDTO['repeatType'],
|
||||
@@ -298,14 +297,12 @@ export function PlannedDowntimeForm(
|
||||
),
|
||||
} as RuletypesRecurrenceDTO,
|
||||
timezone: initialValues.schedule?.timezone as string,
|
||||
labelExpression: initialValues.labelExpression || '',
|
||||
};
|
||||
return formData;
|
||||
}, [initialValues, alertOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedTags(formatedInitialValues.alertRules);
|
||||
setAlertRuleScope(formatedInitialValues.alertRuleScope);
|
||||
form.setFieldsValue({ ...formatedInitialValues });
|
||||
}, [form, formatedInitialValues, initialValues]);
|
||||
|
||||
@@ -320,6 +317,7 @@ export function PlannedDowntimeForm(
|
||||
const getTimezoneFormattedTime = (
|
||||
time: string | dayjs.Dayjs,
|
||||
timeZone?: string,
|
||||
isEditMode?: boolean,
|
||||
format?: string,
|
||||
): string => {
|
||||
if (!time) {
|
||||
@@ -328,11 +326,20 @@ export function PlannedDowntimeForm(
|
||||
if (!timeZone) {
|
||||
return dayjs(time).format(format);
|
||||
}
|
||||
return dayjs(time).tz(timeZone).format(format);
|
||||
return dayjs(time).tz(timeZone, isEditMode).format(format);
|
||||
};
|
||||
|
||||
const startTimeText = useMemo((): string => {
|
||||
let startTime = formData.startTime;
|
||||
let startTime = formData?.startTime;
|
||||
if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) {
|
||||
startTime =
|
||||
(formData?.recurrence?.startTime
|
||||
? dayjs(formData.recurrence.startTime).toISOString()
|
||||
: '') ||
|
||||
formData?.startTime ||
|
||||
'';
|
||||
}
|
||||
|
||||
if (!startTime) {
|
||||
return '';
|
||||
}
|
||||
@@ -342,6 +349,7 @@ export function PlannedDowntimeForm(
|
||||
startTime,
|
||||
timezoneInitialValue,
|
||||
formData?.timezone,
|
||||
!isEditMode,
|
||||
);
|
||||
}
|
||||
const daysOfWeek = formData?.recurrence?.repeatOn;
|
||||
@@ -349,16 +357,21 @@ export function PlannedDowntimeForm(
|
||||
const formattedStartTime = getTimezoneFormattedTime(
|
||||
startTime,
|
||||
formData.timezone,
|
||||
!isEditMode,
|
||||
TIME_FORMAT,
|
||||
);
|
||||
|
||||
const formattedStartDate = getTimezoneFormattedTime(
|
||||
startTime,
|
||||
formData.timezone,
|
||||
!isEditMode,
|
||||
DATE_FORMAT,
|
||||
);
|
||||
|
||||
const ordinalFormat = getTimezoneFormattedTime(
|
||||
startTime,
|
||||
formData.timezone,
|
||||
!isEditMode,
|
||||
ORDINAL_FORMAT,
|
||||
);
|
||||
|
||||
@@ -375,10 +388,21 @@ export function PlannedDowntimeForm(
|
||||
default:
|
||||
return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`;
|
||||
}
|
||||
}, [formData, recurrenceType, timezoneInitialValue]);
|
||||
}, [formData, recurrenceType, isEditMode, timezoneInitialValue]);
|
||||
|
||||
const endTimeText = useMemo((): string => {
|
||||
let endTime = formData?.endTime;
|
||||
if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) {
|
||||
endTime =
|
||||
(formData?.recurrence?.endTime
|
||||
? dayjs(formData.recurrence.endTime).toISOString()
|
||||
: '') || '';
|
||||
|
||||
if (!isEditMode && !endTime) {
|
||||
endTime = formData?.endTime || '';
|
||||
}
|
||||
}
|
||||
|
||||
if (!endTime) {
|
||||
return '';
|
||||
}
|
||||
@@ -388,21 +412,25 @@ export function PlannedDowntimeForm(
|
||||
endTime,
|
||||
timezoneInitialValue,
|
||||
formData?.timezone,
|
||||
!isEditMode,
|
||||
);
|
||||
}
|
||||
|
||||
const formattedEndTime = getTimezoneFormattedTime(
|
||||
endTime,
|
||||
formData.timezone,
|
||||
!isEditMode,
|
||||
TIME_FORMAT,
|
||||
);
|
||||
|
||||
const formattedEndDate = getTimezoneFormattedTime(
|
||||
endTime,
|
||||
formData.timezone,
|
||||
!isEditMode,
|
||||
DATE_FORMAT,
|
||||
);
|
||||
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
|
||||
}, [formData, timezoneInitialValue]);
|
||||
}, [formData, recurrenceType, isEditMode, timezoneInitialValue]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -425,9 +453,6 @@ export function PlannedDowntimeForm(
|
||||
onFinish={onFinish}
|
||||
onValuesChange={(): void => {
|
||||
setRecurrenceType(form.getFieldValue('recurrence')?.repeatType as string);
|
||||
setAlertRuleScope(
|
||||
form.getFieldValue('alertRuleScope') as AlertRuleScope,
|
||||
);
|
||||
setFormData(form.getFieldsValue());
|
||||
}}
|
||||
autoComplete="off"
|
||||
@@ -440,7 +465,7 @@ export function PlannedDowntimeForm(
|
||||
name="startTime"
|
||||
rules={formValidationRules}
|
||||
className={!isEmpty(startTimeText) ? 'formItemWithBullet' : ''}
|
||||
getValueProps={(value) => ({
|
||||
getValueProps={(value): any => ({
|
||||
value: value ? dayjs(value).tz(timezoneInitialValue) : undefined,
|
||||
})}
|
||||
>
|
||||
@@ -521,7 +546,7 @@ export function PlannedDowntimeForm(
|
||||
},
|
||||
]}
|
||||
className={!isEmpty(endTimeText) ? 'formItemWithBullet' : ''}
|
||||
getValueProps={(value) => ({
|
||||
getValueProps={(value): any => ({
|
||||
value: value ? dayjs(value).tz(timezoneInitialValue) : undefined,
|
||||
})}
|
||||
>
|
||||
@@ -539,99 +564,50 @@ export function PlannedDowntimeForm(
|
||||
<div className="scheduleTimeInfoText">{endTimeText}</div>
|
||||
)}
|
||||
<div>
|
||||
<Typography style={{ marginBottom: 8 }}>Silence Alerts</Typography>
|
||||
<Form.Item
|
||||
name="alertRuleScope"
|
||||
initialValue="specific"
|
||||
className="alert-rule-scope"
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio value="all">All alert rules</Radio>
|
||||
<Radio value="specific">Specific alert rules</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{alertRuleScope === 'specific' && (
|
||||
<>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
<AlertRuleTags
|
||||
closable
|
||||
selectedTags={selectedTags}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={alertRuleFormName}
|
||||
rules={[
|
||||
{
|
||||
validator: async (
|
||||
_rule,
|
||||
value: DefaultOptionType[] | undefined,
|
||||
): Promise<void> => {
|
||||
if (!value || value.length === 0) {
|
||||
throw new Error(
|
||||
'Select at least one alert rule, or choose "All alert rules" to silence everything.',
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
placeholder="Search for alerts rules or groups..."
|
||||
mode="multiple"
|
||||
status={isError ? 'error' : undefined}
|
||||
loading={isLoading}
|
||||
tagRender={noTagRenderer}
|
||||
onChange={handleChange}
|
||||
showSearch
|
||||
options={alertOptions}
|
||||
filterOption={(input, option): boolean =>
|
||||
(option?.label as string)?.toLowerCase()?.includes(input.toLowerCase())
|
||||
}
|
||||
notFoundContent={
|
||||
isLoading ? (
|
||||
<span>
|
||||
<Spin size="small" /> Loading...
|
||||
</span>
|
||||
) : (
|
||||
<span>No alert available.</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
{alertOptions?.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
{alertRuleScope === 'all' && (
|
||||
<Typography
|
||||
className="alert-rule-info alert-rule-all-warning"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
All alerts will be silenced for the duration of this maintenance window.
|
||||
<div className="alert-rule-form">
|
||||
<Typography style={{ marginBottom: 8 }}>Silence Alerts</Typography>
|
||||
<Typography style={{ marginBottom: 8 }} className="alert-rule-info">
|
||||
(Leave empty to silence all alerts)
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
<AlertRuleTags
|
||||
closable
|
||||
selectedTags={selectedTags}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={alertRuleFormName}>
|
||||
<Select
|
||||
placeholder="Search for alerts rules or groups..."
|
||||
mode="multiple"
|
||||
status={isError ? 'error' : undefined}
|
||||
loading={isLoading}
|
||||
tagRender={noTagRenderer}
|
||||
onChange={handleChange}
|
||||
showSearch
|
||||
options={alertOptions}
|
||||
filterOption={(input, option): boolean =>
|
||||
(option?.label as string)?.toLowerCase()?.includes(input.toLowerCase())
|
||||
}
|
||||
notFoundContent={
|
||||
isLoading ? (
|
||||
<span>
|
||||
<Spin size="small" /> Loading...
|
||||
</span>
|
||||
) : (
|
||||
<span>No alert available.</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
{alertOptions?.map((option) => (
|
||||
<Select.Option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Label Expression
|
||||
<Tooltip title='Filter by alert labels. Examples: env == "prod", region == "us-east-1" && severity == "critical"'>
|
||||
<Info size={13} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="labelExpression"
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder='e.g. env == "prod" && region == "us-east-1"'
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<ModalButtonWrapper>
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode, useEffect } from 'react';
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Collapse, Flex, Space, Table, TableProps, Tag, Tooltip } from 'antd';
|
||||
@@ -18,9 +18,8 @@ import { defaultTo } from 'lodash-es';
|
||||
import { CalendarClock, PenLine, Trash2 } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { showErrorNotification } from 'utils/error';
|
||||
import { DeepPartial } from 'utils/types';
|
||||
|
||||
import { showErrorNotification } from '../../utils/error';
|
||||
import {
|
||||
formatDateTime,
|
||||
getAlertOptionsFromIds,
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
getEndTime,
|
||||
recurrenceInfo,
|
||||
} from './PlannedDowntimeutils';
|
||||
|
||||
import './PlannedDowntime.styles.scss';
|
||||
|
||||
const { Panel } = Collapse;
|
||||
@@ -136,7 +136,7 @@ export function CollapseListContent({
|
||||
created_at?: string;
|
||||
created_by_name?: string;
|
||||
created_by_email?: string;
|
||||
timeframe: [string | undefined, string | undefined];
|
||||
timeframe: [string | undefined | null, string | undefined | null];
|
||||
repeats?: RuletypesRecurrenceDTO | null;
|
||||
updated_at?: string;
|
||||
updated_by_name?: string;
|
||||
@@ -192,12 +192,7 @@ export function CollapseListContent({
|
||||
),
|
||||
)}
|
||||
{renderItems('Timezone', <Typography>{timezone || '-'}</Typography>)}
|
||||
{renderItems(
|
||||
'Repeats',
|
||||
<Typography>
|
||||
{recurrenceInfo(timeframe[0], timeframe[1], repeats)}
|
||||
</Typography>,
|
||||
)}
|
||||
{renderItems('Repeats', <Typography>{recurrenceInfo(repeats)}</Typography>)}
|
||||
{renderItems(
|
||||
'Alerts silenced',
|
||||
alertOptions?.length ? (
|
||||
@@ -207,7 +202,7 @@ export function CollapseListContent({
|
||||
selectedTags={alertOptions}
|
||||
/>
|
||||
) : (
|
||||
<Tag className="all-alerts-tag">All alert rules</Tag>
|
||||
'-'
|
||||
),
|
||||
)}
|
||||
</Flex>
|
||||
@@ -217,7 +212,7 @@ export function CollapseListContent({
|
||||
export function CustomCollapseList(
|
||||
props: DowntimeSchedulesTableData & {
|
||||
setInitialValues: React.Dispatch<
|
||||
React.SetStateAction<DeepPartial<RuletypesPlannedMaintenanceDTO>>
|
||||
React.SetStateAction<Partial<RuletypesPlannedMaintenanceDTO>>
|
||||
>;
|
||||
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handleDeleteDowntime: (id: string, name: string) => void;
|
||||
@@ -288,7 +283,9 @@ export function CustomCollapseList(
|
||||
schedule?.startTime?.toString(),
|
||||
typeof endTime === 'string' ? endTime : endTime?.toString(),
|
||||
]}
|
||||
repeats={schedule?.recurrence}
|
||||
repeats={
|
||||
schedule?.recurrence as RuletypesRecurrenceDTO | null | undefined
|
||||
}
|
||||
updated_at={updatedAt ? dayjs(updatedAt).toISOString() : ''}
|
||||
updated_by_name={defaultTo(updatedBy, '')}
|
||||
alertOptions={alertOptions}
|
||||
@@ -323,7 +320,7 @@ export function PlannedDowntimeList({
|
||||
>;
|
||||
alertOptions: DefaultOptionType[];
|
||||
setInitialValues: React.Dispatch<
|
||||
React.SetStateAction<DeepPartial<RuletypesPlannedMaintenanceDTO>>
|
||||
React.SetStateAction<Partial<RuletypesPlannedMaintenanceDTO>>
|
||||
>;
|
||||
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handleDeleteDowntime: (id: string, name: string) => void;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { UseMutateAsyncFunction } from 'react-query';
|
||||
import type { NotificationInstance } from 'antd/es/notification/interface';
|
||||
import type { DefaultOptionType } from 'antd/es/select';
|
||||
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
|
||||
import {
|
||||
import type {
|
||||
DeleteDowntimeScheduleByIDPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesPlannedMaintenanceDTO,
|
||||
@@ -14,7 +14,6 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import dayjs from 'dayjs';
|
||||
import { isEmpty, isEqual } from 'lodash-es';
|
||||
import APIError from 'types/api/error';
|
||||
import { DeepPartial } from 'utils/types';
|
||||
|
||||
type DateTimeString = string | null | undefined;
|
||||
|
||||
@@ -61,15 +60,13 @@ export const getAlertOptionsFromIds = (
|
||||
);
|
||||
|
||||
export const recurrenceInfo = (
|
||||
startTime?: string,
|
||||
endTime?: string,
|
||||
recurrence?: RuletypesRecurrenceDTO | null,
|
||||
): string => {
|
||||
if (!recurrence) {
|
||||
return 'No';
|
||||
}
|
||||
|
||||
const { duration, repeatOn, repeatType } = recurrence;
|
||||
const { startTime, duration, repeatOn, repeatType, endTime } = recurrence;
|
||||
|
||||
const formattedStartTime = startTime
|
||||
? formatDateTime(dayjs(startTime).toISOString())
|
||||
@@ -83,7 +80,7 @@ export const recurrenceInfo = (
|
||||
return `Repeats - ${repeatType} ${weeklyRepeatString} from ${formattedStartTime} ${formattedEndTime} ${durationString}`;
|
||||
};
|
||||
|
||||
export const defaultInitialValues: DeepPartial<
|
||||
export const defautlInitialValues: Partial<
|
||||
RuletypesPlannedMaintenanceDTO & { editMode: boolean }
|
||||
> = {
|
||||
name: '',
|
||||
@@ -213,17 +210,39 @@ export const recurrenceOptionWithSubmenu: Option[] = [
|
||||
recurrenceOptions.monthly,
|
||||
];
|
||||
|
||||
export const getRecurrenceOptionFromValue = (
|
||||
value?: string | Option | null,
|
||||
): Option | null | undefined => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return Object.values(recurrenceOptions).find(
|
||||
(option) => option.value === value,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const getEndTime = ({
|
||||
kind,
|
||||
schedule,
|
||||
}: DeepPartial<
|
||||
}: Partial<
|
||||
RuletypesPlannedMaintenanceDTO & {
|
||||
editMode: boolean;
|
||||
}
|
||||
>): string | dayjs.Dayjs =>
|
||||
schedule?.endTime ? dayjs(schedule.endTime).toISOString() : '';
|
||||
>): string | dayjs.Dayjs => {
|
||||
if (kind === 'fixed') {
|
||||
return schedule?.endTime ? dayjs(schedule.endTime).toISOString() : '';
|
||||
}
|
||||
|
||||
return schedule?.recurrence?.endTime
|
||||
? dayjs(schedule.recurrence.endTime).toISOString()
|
||||
: '';
|
||||
};
|
||||
|
||||
export const isScheduleRecurring = (
|
||||
schedule?: DeepPartial<RuletypesPlannedMaintenanceDTO['schedule']> | null,
|
||||
schedule?: RuletypesPlannedMaintenanceDTO['schedule'] | null,
|
||||
): boolean => (schedule ? !isEmpty(schedule?.recurrence) : false);
|
||||
|
||||
function convertUtcOffsetToTimezoneOffset(offsetMinutes: number): string {
|
||||
@@ -253,6 +272,7 @@ export function handleTimeConversion(
|
||||
dateValue: string | dayjs.Dayjs,
|
||||
timezoneInit?: string,
|
||||
timezone?: string,
|
||||
shouldKeepLocalTime?: boolean,
|
||||
): string {
|
||||
const timezoneChanged = !isEqual(timezoneInit, timezone);
|
||||
const initialTime = dayjs(dateValue).tz(timezoneInit);
|
||||
@@ -260,5 +280,5 @@ export function handleTimeConversion(
|
||||
const formattedTime = formatWithTimezone(initialTime, timezone);
|
||||
return timezoneChanged
|
||||
? formattedTime
|
||||
: dayjs(dateValue).tz(timezone).format();
|
||||
: dayjs(dateValue).tz(timezone, shouldKeepLocalTime).format();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const buildSchedule = (
|
||||
schedule: RuletypesScheduleDTO,
|
||||
schedule: Partial<RuletypesScheduleDTO>,
|
||||
): RuletypesScheduleDTO => ({
|
||||
timezone: schedule?.timezone ?? '',
|
||||
startTime: schedule?.startTime,
|
||||
@@ -17,13 +17,16 @@ export const buildSchedule = (
|
||||
});
|
||||
|
||||
export const createMockDowntime = (
|
||||
overrides: Partial<RuletypesPlannedMaintenanceDTO> &
|
||||
Pick<RuletypesPlannedMaintenanceDTO, 'schedule'>,
|
||||
overrides: Partial<RuletypesPlannedMaintenanceDTO>,
|
||||
): RuletypesPlannedMaintenanceDTO => ({
|
||||
id: overrides.id ?? '0',
|
||||
name: overrides.name ?? '',
|
||||
description: overrides.description ?? '',
|
||||
schedule: overrides.schedule,
|
||||
schedule: buildSchedule({
|
||||
timezone: 'UTC',
|
||||
startTime: new Date('2024-01-01'),
|
||||
...overrides.schedule,
|
||||
}),
|
||||
alertIds: overrides.alertIds ?? [],
|
||||
createdAt: overrides.createdAt,
|
||||
createdBy: overrides.createdBy ?? '',
|
||||
|
||||
@@ -19,6 +19,7 @@ export type ServerError = 500;
|
||||
export type SuccessStatusCode = Created | Success | SuccessNoContent;
|
||||
|
||||
export type ErrorStatusCode =
|
||||
| Forbidden
|
||||
| Forbidden
|
||||
| Unauthorized
|
||||
| NotFound
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
type Builtin =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| bigint
|
||||
| symbol
|
||||
| null
|
||||
| undefined
|
||||
| Function // eslint-disable-line
|
||||
| Date
|
||||
| RegExp
|
||||
| Error;
|
||||
|
||||
export type DeepPartial<T> = T extends Builtin
|
||||
? T
|
||||
: T extends Array<infer U>
|
||||
? Array<DeepPartial<U>>
|
||||
: T extends ReadonlyArray<infer U>
|
||||
? ReadonlyArray<DeepPartial<U>>
|
||||
: T extends object
|
||||
? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: T;
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
"github.com/prometheus/alertmanager/dispatch"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/provider/mem"
|
||||
promTypes "github.com/prometheus/alertmanager/types"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/prometheus/common/promslog"
|
||||
@@ -365,7 +364,7 @@ route:
|
||||
providerSettings := createTestProviderSettings()
|
||||
logger := providerSettings.Logger
|
||||
route := dispatch.NewRoute(conf.Route, nil)
|
||||
marker := promTypes.NewMarker(prometheus.NewRegistry())
|
||||
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -638,7 +637,7 @@ route:
|
||||
providerSettings := createTestProviderSettings()
|
||||
logger := providerSettings.Logger
|
||||
route := dispatch.NewRoute(conf.Route, nil)
|
||||
marker := promTypes.NewMarker(prometheus.NewRegistry())
|
||||
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -897,7 +896,7 @@ route:
|
||||
providerSettings := createTestProviderSettings()
|
||||
logger := providerSettings.Logger
|
||||
route := dispatch.NewRoute(conf.Route, nil)
|
||||
marker := promTypes.NewMarker(prometheus.NewRegistry())
|
||||
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1159,7 +1158,7 @@ func newAlert(labels model.LabelSet) *alertmanagertypes.Alert {
|
||||
|
||||
func TestDispatcherRace(t *testing.T) {
|
||||
logger := promslog.NewNopLogger()
|
||||
marker := promTypes.NewMarker(prometheus.NewRegistry())
|
||||
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1195,7 +1194,7 @@ route:
|
||||
route := dispatch.NewRoute(conf.Route, nil)
|
||||
providerSettings := createTestProviderSettings()
|
||||
logger := providerSettings.Logger
|
||||
marker := promTypes.NewMarker(prometheus.NewRegistry())
|
||||
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1265,7 +1264,7 @@ route:
|
||||
|
||||
func TestDispatcher_DoMaintenance(t *testing.T) {
|
||||
r := prometheus.NewRegistry()
|
||||
marker := promTypes.NewMarker(r)
|
||||
marker := alertmanagertypes.NewMarker(r)
|
||||
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Minute, 0, nil, promslog.NewNopLogger(), prometheus.NewRegistry(), nil)
|
||||
if err != nil {
|
||||
@@ -1371,7 +1370,7 @@ route:
|
||||
providerSettings := createTestProviderSettings()
|
||||
logger := providerSettings.Logger
|
||||
route := dispatch.NewRoute(conf.Route, nil)
|
||||
marker := promTypes.NewMarker(prometheus.NewRegistry())
|
||||
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
package alertmanagerserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
)
|
||||
|
||||
// MaintenanceMuter implements types.Muter for maintenance windows.
|
||||
// It suppresses alerts whose ruleId label matches an active maintenance schedule.
|
||||
// Results are cached for cacheTTL to avoid a DB query on every per-alert check.
|
||||
type MaintenanceMuter struct {
|
||||
maintenanceStore alertmanagertypes.MaintenanceStore
|
||||
orgID string
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.RWMutex
|
||||
cached []*alertmanagertypes.PlannedMaintenance
|
||||
cacheExpiry time.Time
|
||||
}
|
||||
|
||||
const maintenanceCacheTTL = 30 * time.Second
|
||||
|
||||
func NewMaintenanceMuter(store alertmanagertypes.MaintenanceStore, orgID string, logger *slog.Logger) *MaintenanceMuter {
|
||||
return &MaintenanceMuter{
|
||||
maintenanceStore: store,
|
||||
orgID: orgID,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MaintenanceMuter) Mutes(ctx context.Context, lset model.LabelSet) bool {
|
||||
ruleID := string(lset[ruletypes.AlertRuleIDLabel])
|
||||
if ruleID == "" {
|
||||
return false
|
||||
}
|
||||
now := time.Now()
|
||||
for _, mw := range m.getMaintenances(ctx) {
|
||||
if mw.ShouldSkip(ruleID, now, lset) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MutedBy returns the IDs of all active maintenance windows currently
|
||||
// suppressing the alert identified by lset. It is used to populate the
|
||||
// `mutedBy` field on the v2 API alert response so that maintenance-suppressed
|
||||
// alerts surface as `state=suppressed` in GetAlerts responses.
|
||||
func (m *MaintenanceMuter) MutedBy(ctx context.Context, lset model.LabelSet) []string {
|
||||
ruleID := string(lset[ruletypes.AlertRuleIDLabel])
|
||||
if ruleID == "" {
|
||||
return nil
|
||||
}
|
||||
var ids []string
|
||||
now := time.Now()
|
||||
for _, mw := range m.getMaintenances(ctx) {
|
||||
if mw.ShouldSkip(ruleID, now, lset) {
|
||||
ids = append(ids, mw.ID.String())
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (m *MaintenanceMuter) getMaintenances(ctx context.Context) []*alertmanagertypes.PlannedMaintenance {
|
||||
m.mu.RLock()
|
||||
if time.Now().Before(m.cacheExpiry) {
|
||||
cached := m.cached
|
||||
m.mu.RUnlock()
|
||||
return cached
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock.
|
||||
if time.Now().Before(m.cacheExpiry) {
|
||||
return m.cached
|
||||
}
|
||||
|
||||
mws, err := m.maintenanceStore.ListPlannedMaintenance(ctx, m.orgID)
|
||||
if err != nil {
|
||||
m.logger.ErrorContext(ctx, "failed to list planned maintenance windows; alerts will not be suppressed", slog.String("org_id", m.orgID))
|
||||
return m.cached // return stale (potentially empty) cache on error
|
||||
}
|
||||
m.cached = mws
|
||||
m.cacheExpiry = time.Now().Add(maintenanceCacheTTL)
|
||||
return m.cached
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
package alertmanagerserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// fakeMaintenanceStore implements alertmanagertypes.MaintenanceStore with an in-memory list.
|
||||
// It counts ListPlannedMaintenance calls so tests can assert caching behavior.
|
||||
type fakeMaintenanceStore struct {
|
||||
mu sync.Mutex
|
||||
items []*alertmanagertypes.PlannedMaintenance
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *fakeMaintenanceStore) ListPlannedMaintenance(_ context.Context, _ string) ([]*alertmanagertypes.PlannedMaintenance, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.calls++
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return s.items, nil
|
||||
}
|
||||
|
||||
func (s *fakeMaintenanceStore) callCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.calls
|
||||
}
|
||||
|
||||
func (s *fakeMaintenanceStore) setError(err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.err = err
|
||||
}
|
||||
|
||||
// Remaining MaintenanceStore methods — unused by MaintenanceMuter.
|
||||
func (s *fakeMaintenanceStore) CreatePlannedMaintenance(context.Context, *alertmanagertypes.PostablePlannedMaintenance) (*alertmanagertypes.PlannedMaintenance, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *fakeMaintenanceStore) DeletePlannedMaintenance(context.Context, valuer.UUID) error {
|
||||
return nil
|
||||
}
|
||||
func (s *fakeMaintenanceStore) GetPlannedMaintenanceByID(context.Context, valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *fakeMaintenanceStore) UpdatePlannedMaintenance(context.Context, *alertmanagertypes.PostablePlannedMaintenance, valuer.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newMuter(t *testing.T, items ...*alertmanagertypes.PlannedMaintenance) (*MaintenanceMuter, *fakeMaintenanceStore) {
|
||||
t.Helper()
|
||||
store := &fakeMaintenanceStore{items: items}
|
||||
muter := NewMaintenanceMuter(store, "org-1", slog.New(slog.DiscardHandler))
|
||||
return muter, store
|
||||
}
|
||||
|
||||
// activeFixed builds a fixed-time maintenance window that brackets now.
|
||||
// ruleIDs scope the window; an empty slice matches every rule.
|
||||
func activeFixed(ruleIDs ...string) *alertmanagertypes.PlannedMaintenance {
|
||||
now := time.Now().UTC()
|
||||
return &alertmanagertypes.PlannedMaintenance{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Schedule: &alertmanagertypes.Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: now.Add(-time.Hour),
|
||||
EndTime: now.Add(time.Hour),
|
||||
},
|
||||
RuleIDs: ruleIDs,
|
||||
}
|
||||
}
|
||||
|
||||
// futureFixed builds a fixed-time maintenance window that starts in the future.
|
||||
func futureFixed(ruleIDs ...string) *alertmanagertypes.PlannedMaintenance {
|
||||
now := time.Now().UTC()
|
||||
return &alertmanagertypes.PlannedMaintenance{
|
||||
ID: valuer.GenerateUUID(),
|
||||
Schedule: &alertmanagertypes.Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: now.Add(time.Hour),
|
||||
EndTime: now.Add(2 * time.Hour),
|
||||
},
|
||||
RuleIDs: ruleIDs,
|
||||
}
|
||||
}
|
||||
|
||||
func labelsFor(ruleID string) model.LabelSet {
|
||||
return model.LabelSet{model.LabelName(ruletypes.AlertRuleIDLabel): model.LabelValue(ruleID)}
|
||||
}
|
||||
|
||||
func TestMutes_EmptyRuleIDLabel(t *testing.T) {
|
||||
muter, store := newMuter(t, activeFixed())
|
||||
assert.False(t, muter.Mutes(context.Background(), model.LabelSet{}))
|
||||
// Short-circuit: no store lookup needed when the label is missing.
|
||||
assert.Equal(t, 0, store.callCount())
|
||||
}
|
||||
|
||||
func TestMutes_NoMaintenanceWindows(t *testing.T) {
|
||||
muter, _ := newMuter(t)
|
||||
assert.False(t, muter.Mutes(context.Background(), labelsFor("rule-1")))
|
||||
}
|
||||
|
||||
func TestMutes_MatchingRule(t *testing.T) {
|
||||
mw := activeFixed("rule-1", "rule-2")
|
||||
muter, _ := newMuter(t, mw)
|
||||
assert.True(t, muter.Mutes(context.Background(), labelsFor("rule-1")))
|
||||
assert.True(t, muter.Mutes(context.Background(), labelsFor("rule-2")))
|
||||
}
|
||||
|
||||
func TestMutes_NonMatchingRule(t *testing.T) {
|
||||
muter, _ := newMuter(t, activeFixed("rule-1"))
|
||||
assert.False(t, muter.Mutes(context.Background(), labelsFor("rule-other")))
|
||||
}
|
||||
|
||||
func TestMutes_EmptyRuleIDsMatchesAllRules(t *testing.T) {
|
||||
// A maintenance with no RuleIDs is treated as scoping every rule.
|
||||
muter, _ := newMuter(t, activeFixed())
|
||||
assert.True(t, muter.Mutes(context.Background(), labelsFor("any-rule")))
|
||||
}
|
||||
|
||||
func TestMutes_FutureWindowDoesNotMute(t *testing.T) {
|
||||
muter, _ := newMuter(t, futureFixed("rule-1"))
|
||||
assert.False(t, muter.Mutes(context.Background(), labelsFor("rule-1")))
|
||||
}
|
||||
|
||||
func TestMutes_AnyOfMultipleWindowsMatches(t *testing.T) {
|
||||
muter, _ := newMuter(t,
|
||||
futureFixed("rule-1"),
|
||||
activeFixed("rule-1"),
|
||||
)
|
||||
assert.True(t, muter.Mutes(context.Background(), labelsFor("rule-1")))
|
||||
}
|
||||
|
||||
func TestMutedBy_EmptyRuleIDLabel(t *testing.T) {
|
||||
muter, store := newMuter(t, activeFixed())
|
||||
assert.Nil(t, muter.MutedBy(context.Background(), model.LabelSet{}))
|
||||
assert.Equal(t, 0, store.callCount())
|
||||
}
|
||||
|
||||
func TestMutedBy_NoMatches(t *testing.T) {
|
||||
muter, _ := newMuter(t, activeFixed("rule-1"), futureFixed("rule-1"))
|
||||
assert.Nil(t, muter.MutedBy(context.Background(), labelsFor("rule-other")))
|
||||
}
|
||||
|
||||
func TestMutedBy_ReturnsIDsOfAllActiveMatchingWindows(t *testing.T) {
|
||||
mw1 := activeFixed("rule-1")
|
||||
mw2 := activeFixed() // matches all rules
|
||||
mw3 := futureFixed("rule-1")
|
||||
mw4 := activeFixed("rule-other")
|
||||
|
||||
muter, _ := newMuter(t, mw1, mw2, mw3, mw4)
|
||||
ids := muter.MutedBy(context.Background(), labelsFor("rule-1"))
|
||||
|
||||
want := []string{mw1.ID.String(), mw2.ID.String()}
|
||||
sort.Strings(want)
|
||||
sort.Strings(ids)
|
||||
assert.Equal(t, want, ids)
|
||||
}
|
||||
|
||||
func TestCache_RepeatedCallsHitStoreOnce(t *testing.T) {
|
||||
muter, store := newMuter(t, activeFixed("rule-1"))
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 5; i++ {
|
||||
require.True(t, muter.Mutes(ctx, labelsFor("rule-1")))
|
||||
}
|
||||
assert.Equal(t, 1, store.callCount(), "cached results should suppress further store lookups within TTL")
|
||||
}
|
||||
|
||||
func TestCache_StoreErrorReturnsStaleCache(t *testing.T) {
|
||||
mw := activeFixed("rule-1")
|
||||
muter, store := newMuter(t, mw)
|
||||
ctx := context.Background()
|
||||
|
||||
// First call populates the cache from a working store.
|
||||
require.True(t, muter.Mutes(ctx, labelsFor("rule-1")))
|
||||
require.Equal(t, 1, store.callCount())
|
||||
|
||||
// Force cache to be considered expired so the next call re-fetches.
|
||||
muter.mu.Lock()
|
||||
muter.cacheExpiry = time.Time{}
|
||||
muter.mu.Unlock()
|
||||
|
||||
// Store now errors. The muter should fall back to the previously cached value
|
||||
// (i.e. still mute rule-1) rather than returning false.
|
||||
store.setError(errors.New("boom"))
|
||||
assert.True(t, muter.Mutes(ctx, labelsFor("rule-1")),
|
||||
"on store error, muter should keep using the last known cache to avoid losing suppression")
|
||||
assert.Equal(t, 2, store.callCount())
|
||||
}
|
||||
|
||||
func TestCache_ExpiredCacheRefetchesUpdatedData(t *testing.T) {
|
||||
mw := activeFixed("rule-1")
|
||||
muter, store := newMuter(t, mw)
|
||||
ctx := context.Background()
|
||||
|
||||
require.True(t, muter.Mutes(ctx, labelsFor("rule-1")))
|
||||
require.Equal(t, 1, store.callCount())
|
||||
|
||||
// Drop the maintenance window from the store and expire the cache.
|
||||
store.mu.Lock()
|
||||
store.items = nil
|
||||
store.mu.Unlock()
|
||||
muter.mu.Lock()
|
||||
muter.cacheExpiry = time.Time{}
|
||||
muter.mu.Unlock()
|
||||
|
||||
assert.False(t, muter.Mutes(ctx, labelsFor("rule-1")))
|
||||
assert.Equal(t, 2, store.callCount())
|
||||
}
|
||||
|
||||
func TestMutes_IsConcurrencySafe(t *testing.T) {
|
||||
muter, store := newMuter(t, activeFixed("rule-1"))
|
||||
ctx := context.Background()
|
||||
|
||||
const goroutines = 32
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 50; j++ {
|
||||
_ = muter.Mutes(ctx, labelsFor("rule-1"))
|
||||
_ = muter.MutedBy(ctx, labelsFor("rule-1"))
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Even under contention the cache must collapse the load to a single fetch.
|
||||
assert.Equal(t, 1, store.callCount())
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// Copyright 2015 Prometheus Team
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package alertmanagerserver
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/alertmanager/featurecontrol"
|
||||
"github.com/prometheus/alertmanager/inhibit"
|
||||
"github.com/prometheus/alertmanager/nflog/nflogpb"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/silence"
|
||||
"github.com/prometheus/alertmanager/timeinterval"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
// pipelineBuilder is a local copy of notify.PipelineBuilder that injects
|
||||
// the maintenance mute stage immediately before the receiver stage.
|
||||
//
|
||||
// We maintain our own copy so we can control exactly where in the pipeline
|
||||
// the maintenance stage runs (between the silence stage and the receiver),
|
||||
// which is not possible by wrapping the output of the upstream builder.
|
||||
//
|
||||
// Upstream pipeline order:
|
||||
// GossipSettle → Inhibit → TimeActive → TimeMute → Silence → [mms] → Receiver
|
||||
type pipelineBuilder struct {
|
||||
metrics *notify.Metrics
|
||||
ff featurecontrol.Flagger
|
||||
}
|
||||
|
||||
func newPipelineBuilder(
|
||||
r prometheus.Registerer,
|
||||
ff featurecontrol.Flagger,
|
||||
) *pipelineBuilder {
|
||||
return &pipelineBuilder{
|
||||
metrics: notify.NewMetrics(r, ff),
|
||||
ff: ff,
|
||||
}
|
||||
}
|
||||
|
||||
// New returns a map of receivers to Stages, mirroring notify.PipelineBuilder.New
|
||||
// but inserting a maintenanceMuteStage between the silence stage and the receiver.
|
||||
func (pb *pipelineBuilder) New(
|
||||
receivers map[string][]notify.Integration,
|
||||
wait func() time.Duration,
|
||||
inhibitor *inhibit.Inhibitor,
|
||||
silencer *silence.Silencer,
|
||||
intervener *timeinterval.Intervener,
|
||||
marker types.GroupMarker,
|
||||
muter *MaintenanceMuter,
|
||||
notificationLog notify.NotificationLog,
|
||||
peer notify.Peer,
|
||||
) notify.RoutingStage {
|
||||
rs := make(notify.RoutingStage, len(receivers))
|
||||
|
||||
ms := notify.NewGossipSettleStage(peer)
|
||||
is := notify.NewMuteStage(inhibitor, pb.metrics)
|
||||
tas := notify.NewTimeActiveStage(intervener, marker, pb.metrics)
|
||||
tms := notify.NewTimeMuteStage(intervener, marker, pb.metrics)
|
||||
ss := notify.NewMuteStage(silencer, pb.metrics)
|
||||
mms := notify.NewMuteStage(muter, pb.metrics)
|
||||
|
||||
for name := range receivers {
|
||||
stages := notify.MultiStage{ms, is, tas, tms, ss, mms}
|
||||
stages = append(stages, createReceiverStage(name, receivers[name], wait, notificationLog, pb.metrics))
|
||||
rs[name] = stages
|
||||
}
|
||||
|
||||
pb.metrics.InitializeFor(receivers)
|
||||
return rs
|
||||
}
|
||||
|
||||
// createReceiverStage is a copy of notify.createReceiverStage (unexported upstream).
|
||||
func createReceiverStage(
|
||||
name string,
|
||||
integrations []notify.Integration,
|
||||
wait func() time.Duration,
|
||||
notificationLog notify.NotificationLog,
|
||||
metrics *notify.Metrics,
|
||||
) notify.Stage {
|
||||
var fs notify.FanoutStage
|
||||
for i := range integrations {
|
||||
recv := &nflogpb.Receiver{
|
||||
GroupName: name,
|
||||
Integration: integrations[i].Name(),
|
||||
Idx: uint32(integrations[i].Index()),
|
||||
}
|
||||
var s notify.MultiStage
|
||||
s = append(s, notify.NewWaitStage(wait))
|
||||
s = append(s, notify.NewDedupStage(&integrations[i], notificationLog, recv))
|
||||
s = append(s, notify.NewRetryStage(integrations[i], name, metrics))
|
||||
s = append(s, notify.NewSetNotifiesStage(notificationLog, recv))
|
||||
fs = append(fs, s)
|
||||
}
|
||||
return fs
|
||||
}
|
||||
@@ -28,10 +28,12 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
)
|
||||
|
||||
// This is not a real snapshot file and will never be used. We need this placeholder to ensure maintenance runs on shutdown.
|
||||
// See https://github.com/prometheus/alertmanager/blob/3ee2cd0f1271e277295c02b6160507b4d193dde2/silence/silence.go#L435-L438
|
||||
// and https://github.com/prometheus/alertmanager/blob/3b06b97af4d146e141af92885a185891eb79a5b0/nflog/nflog.go#L362.
|
||||
var snapfnoop string = "snapfnoop"
|
||||
var (
|
||||
// This is not a real file and will never be used. We need this placeholder to ensure maintenance runs on shutdown. See
|
||||
// https://github.com/prometheus/server/blob/3ee2cd0f1271e277295c02b6160507b4d193dde2/silence/silence.go#L435-L438
|
||||
// and https://github.com/prometheus/server/blob/3b06b97af4d146e141af92885a185891eb79a5b0/nflog/nflog.go#L362.
|
||||
snapfnoop string = "snapfnoop"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
// logger is the logger for the alertmanager
|
||||
@@ -61,25 +63,15 @@ type Server struct {
|
||||
silencer *silence.Silencer
|
||||
silences *silence.Silences
|
||||
timeIntervals map[string][]timeinterval.TimeInterval
|
||||
pipelineBuilder *pipelineBuilder
|
||||
muter *MaintenanceMuter
|
||||
marker *types.MemMarker
|
||||
pipelineBuilder *notify.PipelineBuilder
|
||||
marker *alertmanagertypes.MemMarker
|
||||
tmpl *template.Template
|
||||
wg sync.WaitGroup
|
||||
stopc chan struct{}
|
||||
notificationManager nfmanager.NotificationManager
|
||||
}
|
||||
|
||||
func New(
|
||||
ctx context.Context,
|
||||
logger *slog.Logger,
|
||||
registry prometheus.Registerer,
|
||||
srvConfig Config,
|
||||
orgID string,
|
||||
stateStore alertmanagertypes.StateStore,
|
||||
nfManager nfmanager.NotificationManager,
|
||||
maintenanceStore alertmanagertypes.MaintenanceStore,
|
||||
) (*Server, error) {
|
||||
func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registerer, srvConfig Config, orgID string, stateStore alertmanagertypes.StateStore, nfManager nfmanager.NotificationManager) (*Server, error) {
|
||||
server := &Server{
|
||||
logger: logger.With(slog.String("pkg", "go.signoz.io/pkg/alertmanager/alertmanagerserver")),
|
||||
registry: registry,
|
||||
@@ -92,7 +84,7 @@ func New(
|
||||
signozRegisterer := prometheus.WrapRegistererWithPrefix("signoz_", registry)
|
||||
signozRegisterer = prometheus.WrapRegistererWith(prometheus.Labels{"org_id": server.orgID}, signozRegisterer)
|
||||
// initialize marker
|
||||
server.marker = types.NewMarker(signozRegisterer)
|
||||
server.marker = alertmanagertypes.NewMarker(signozRegisterer)
|
||||
|
||||
// get silences for initial state
|
||||
state, err := server.stateStore.Get(ctx, server.orgID)
|
||||
@@ -168,6 +160,7 @@ func New(
|
||||
|
||||
return c, server.stateStore.Set(ctx, storableSilences)
|
||||
})
|
||||
|
||||
}()
|
||||
|
||||
// Start maintenance for notification logs
|
||||
@@ -203,25 +196,17 @@ func New(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
server.muter = NewMaintenanceMuter(maintenanceStore, orgID, server.logger)
|
||||
server.pipelineBuilder = newPipelineBuilder(signozRegisterer, featurecontrol.NoopFlags{})
|
||||
server.pipelineBuilder = notify.NewPipelineBuilder(signozRegisterer, featurecontrol.NoopFlags{})
|
||||
server.dispatcherMetrics = NewDispatcherMetrics(false, signozRegisterer)
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func (server *Server) GetAlerts(ctx context.Context, params alertmanagertypes.GettableAlertsParams) (alertmanagertypes.GettableAlerts, error) {
|
||||
return alertmanagertypes.NewGettableAlertsFromAlertProvider(
|
||||
server.alerts, server.alertmanagerConfig, server.marker.Status,
|
||||
func(labels model.LabelSet) {
|
||||
server.inhibitor.Mutes(ctx, labels)
|
||||
server.silencer.Mutes(ctx, labels)
|
||||
},
|
||||
func(labels model.LabelSet) []string {
|
||||
return server.muter.MutedBy(ctx, labels)
|
||||
},
|
||||
params,
|
||||
)
|
||||
return alertmanagertypes.NewGettableAlertsFromAlertProvider(server.alerts, server.alertmanagerConfig, server.marker.Status, func(labels model.LabelSet) {
|
||||
server.inhibitor.Mutes(ctx, labels)
|
||||
server.silencer.Mutes(ctx, labels)
|
||||
}, params)
|
||||
}
|
||||
|
||||
func (server *Server) PutAlerts(ctx context.Context, postableAlerts alertmanagertypes.PostableAlerts) error {
|
||||
@@ -305,7 +290,6 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
|
||||
server.silencer,
|
||||
intervener,
|
||||
server.marker,
|
||||
server.muter,
|
||||
server.nflog,
|
||||
pipelinePeer,
|
||||
)
|
||||
|
||||
@@ -7,10 +7,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sqlmock "github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes/alertmanagertypestest"
|
||||
"github.com/prometheus/alertmanager/dispatch"
|
||||
|
||||
@@ -94,8 +90,7 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
|
||||
stateStore := alertmanagertypestest.NewStateStore()
|
||||
registry := prometheus.NewRegistry()
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual))
|
||||
server, err := New(context.Background(), logger, registry, srvCfg, orgID, stateStore, notificationManager, maintenanceStore)
|
||||
server, err := New(context.Background(), logger, registry, srvCfg, orgID, stateStore, notificationManager)
|
||||
require.NoError(t, err)
|
||||
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, orgID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -10,11 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes/alertmanagertypestest"
|
||||
"github.com/go-openapi/strfmt"
|
||||
@@ -27,14 +23,9 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newTestMaintenanceStore() alertmanagertypes.MaintenanceStore {
|
||||
ss := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
|
||||
return sqlalertmanagerstore.NewMaintenanceStore(ss)
|
||||
}
|
||||
|
||||
func TestServerSetConfigAndStop(t *testing.T) {
|
||||
notificationManager := nfmanagertest.NewMock()
|
||||
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager, newTestMaintenanceStore())
|
||||
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
|
||||
require.NoError(t, err)
|
||||
|
||||
amConfig, err := alertmanagertypes.NewDefaultConfig(alertmanagertypes.GlobalConfig{}, alertmanagertypes.RouteConfig{GroupInterval: 1 * time.Minute, RepeatInterval: 1 * time.Minute, GroupWait: 1 * time.Minute}, "1")
|
||||
@@ -46,7 +37,7 @@ func TestServerSetConfigAndStop(t *testing.T) {
|
||||
|
||||
func TestServerTestReceiverTypeWebhook(t *testing.T) {
|
||||
notificationManager := nfmanagertest.NewMock()
|
||||
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager, newTestMaintenanceStore())
|
||||
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager)
|
||||
require.NoError(t, err)
|
||||
|
||||
amConfig, err := alertmanagertypes.NewDefaultConfig(alertmanagertypes.GlobalConfig{}, alertmanagertypes.RouteConfig{GroupInterval: 1 * time.Minute, RepeatInterval: 1 * time.Minute, GroupWait: 1 * time.Minute}, "1")
|
||||
@@ -94,7 +85,7 @@ func TestServerPutAlerts(t *testing.T) {
|
||||
srvCfg := NewConfig()
|
||||
srvCfg.Route.GroupInterval = 1 * time.Second
|
||||
notificationManager := nfmanagertest.NewMock()
|
||||
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager, newTestMaintenanceStore())
|
||||
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
|
||||
require.NoError(t, err)
|
||||
|
||||
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
|
||||
@@ -142,7 +133,7 @@ func TestServerTestAlert(t *testing.T) {
|
||||
srvCfg := NewConfig()
|
||||
srvCfg.Route.GroupInterval = 1 * time.Second
|
||||
notificationManager := nfmanagertest.NewMock()
|
||||
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager, newTestMaintenanceStore())
|
||||
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
|
||||
require.NoError(t, err)
|
||||
|
||||
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
|
||||
@@ -247,7 +238,7 @@ func TestServerTestAlertContinuesOnFailure(t *testing.T) {
|
||||
srvCfg := NewConfig()
|
||||
srvCfg.Route.GroupInterval = 1 * time.Second
|
||||
notificationManager := nfmanagertest.NewMock()
|
||||
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager, newTestMaintenanceStore())
|
||||
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager)
|
||||
require.NoError(t, err)
|
||||
|
||||
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
|
||||
|
||||
@@ -39,18 +39,16 @@ type Service struct {
|
||||
serversMtx sync.RWMutex
|
||||
|
||||
notificationManager nfmanager.NotificationManager
|
||||
|
||||
maintenanceStore alertmanagertypes.MaintenanceStore
|
||||
}
|
||||
|
||||
func New(
|
||||
ctx context.Context,
|
||||
settings factory.ScopedProviderSettings,
|
||||
config alertmanagerserver.Config,
|
||||
stateStore alertmanagertypes.StateStore,
|
||||
configStore alertmanagertypes.ConfigStore,
|
||||
orgGetter organization.Getter,
|
||||
nfManager nfmanager.NotificationManager,
|
||||
maintenanceStore alertmanagertypes.MaintenanceStore,
|
||||
) *Service {
|
||||
service := &Service{
|
||||
config: config,
|
||||
@@ -61,7 +59,6 @@ func New(
|
||||
servers: make(map[string]*alertmanagerserver.Server),
|
||||
serversMtx: sync.RWMutex{},
|
||||
notificationManager: nfManager,
|
||||
maintenanceStore: maintenanceStore,
|
||||
}
|
||||
|
||||
return service
|
||||
@@ -180,10 +177,7 @@ func (service *Service) newServer(ctx context.Context, orgID string) (*alertmana
|
||||
return nil, err
|
||||
}
|
||||
|
||||
server, err := alertmanagerserver.New(
|
||||
ctx, service.settings.Logger(), service.settings.PrometheusRegisterer(), service.config, orgID,
|
||||
service.stateStore, service.notificationManager, service.maintenanceStore,
|
||||
)
|
||||
server, err := alertmanagerserver.New(ctx, service.settings.Logger(), service.settings.PrometheusRegisterer(), service.config, orgID, service.stateStore, service.notificationManager)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,16 +4,18 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
amConfig "github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
|
||||
|
||||
amConfig "github.com/prometheus/alertmanager/config"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
@@ -28,49 +30,35 @@ type provider struct {
|
||||
configStore alertmanagertypes.ConfigStore
|
||||
stateStore alertmanagertypes.StateStore
|
||||
notificationManager nfmanager.NotificationManager
|
||||
maintenanceStore alertmanagertypes.MaintenanceStore
|
||||
stopC chan struct{}
|
||||
}
|
||||
|
||||
func NewFactory(
|
||||
sqlstore sqlstore.SQLStore,
|
||||
orgGetter organization.Getter,
|
||||
notificationManager nfmanager.NotificationManager,
|
||||
maintenanceStore alertmanagertypes.MaintenanceStore,
|
||||
) factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config] {
|
||||
func NewFactory(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager) factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, settings factory.ProviderSettings, config alertmanager.Config) (alertmanager.Alertmanager, error) {
|
||||
return New(settings, config, sqlstore, orgGetter, notificationManager, maintenanceStore)
|
||||
return New(ctx, settings, config, sqlstore, orgGetter, notificationManager)
|
||||
})
|
||||
}
|
||||
|
||||
func New(
|
||||
providerSettings factory.ProviderSettings,
|
||||
config alertmanager.Config,
|
||||
sqlstore sqlstore.SQLStore,
|
||||
orgGetter organization.Getter,
|
||||
notificationManager nfmanager.NotificationManager,
|
||||
maintenanceStore alertmanagertypes.MaintenanceStore,
|
||||
) (*provider, error) {
|
||||
func New(ctx context.Context, providerSettings factory.ProviderSettings, config alertmanager.Config, sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager) (*provider, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager")
|
||||
configStore := sqlalertmanagerstore.NewConfigStore(sqlstore)
|
||||
stateStore := sqlalertmanagerstore.NewStateStore(sqlstore)
|
||||
|
||||
p := &provider{
|
||||
service: alertmanager.New(
|
||||
ctx,
|
||||
settings,
|
||||
config.Signoz.Config,
|
||||
stateStore,
|
||||
configStore,
|
||||
orgGetter,
|
||||
notificationManager,
|
||||
maintenanceStore,
|
||||
),
|
||||
settings: settings,
|
||||
config: config,
|
||||
configStore: configStore,
|
||||
stateStore: stateStore,
|
||||
notificationManager: notificationManager,
|
||||
maintenanceStore: maintenanceStore,
|
||||
stopC: make(chan struct{}),
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
@@ -116,29 +115,13 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/rules/{id}/mute", handler.New(provider.authzMiddleware.EditAccess(provider.rulerHandler.MuteRule), handler.OpenAPIDef{
|
||||
ID: "MuteRule",
|
||||
Tags: []string{"rules"},
|
||||
Summary: "Mute alert rule",
|
||||
Description: "This endpoint mutes an alert rule until the provided endTime by creating a fixed-window planned maintenance scoped to that rule. Unmute by deleting the returned downtime schedule.",
|
||||
Request: new(alertmanagertypes.PostableMuteRule),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(alertmanagertypes.PlannedMaintenance),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/downtime_schedules", handler.New(provider.authzMiddleware.ViewAccess(provider.rulerHandler.ListDowntimeSchedules), handler.OpenAPIDef{
|
||||
ID: "ListDowntimeSchedules",
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "List downtime schedules",
|
||||
Description: "This endpoint lists all planned maintenance / downtime schedules",
|
||||
RequestQuery: new(alertmanagertypes.ListPlannedMaintenanceParams),
|
||||
Response: make([]*alertmanagertypes.PlannedMaintenance, 0),
|
||||
RequestQuery: new(ruletypes.ListPlannedMaintenanceParams),
|
||||
Response: make([]*ruletypes.PlannedMaintenance, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
@@ -151,7 +134,7 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Get downtime schedule by ID",
|
||||
Description: "This endpoint returns a downtime schedule by ID",
|
||||
Response: new(alertmanagertypes.PlannedMaintenance),
|
||||
Response: new(ruletypes.PlannedMaintenance),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
@@ -165,9 +148,9 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Create downtime schedule",
|
||||
Description: "This endpoint creates a new planned maintenance / downtime schedule",
|
||||
Request: new(alertmanagertypes.PostablePlannedMaintenance),
|
||||
Request: new(ruletypes.PostablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(alertmanagertypes.PlannedMaintenance),
|
||||
Response: new(ruletypes.PlannedMaintenance),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
@@ -181,7 +164,7 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "Update downtime schedule",
|
||||
Description: "This endpoint updates a downtime schedule by ID",
|
||||
Request: new(alertmanagertypes.PostablePlannedMaintenance),
|
||||
Request: new(ruletypes.PostablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
|
||||
@@ -2,8 +2,6 @@ package envprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/config"
|
||||
@@ -11,21 +9,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// clearSignozEnv unsets all existing SIGNOZ_* env vars for the duration of the test.
|
||||
func clearSignozEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, kv := range os.Environ() {
|
||||
if strings.HasPrefix(kv, prefix) {
|
||||
key := strings.SplitN(kv, "=", 2)[0]
|
||||
orig, _ := os.LookupEnv(key)
|
||||
os.Unsetenv(key)
|
||||
t.Cleanup(func() { os.Setenv(key, orig) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWithStrings(t *testing.T) {
|
||||
clearSignozEnv(t)
|
||||
t.Setenv("SIGNOZ_K1_K2", "string")
|
||||
t.Setenv("SIGNOZ_K3__K4", "string")
|
||||
t.Setenv("SIGNOZ_K5__K6_K7__K8", "string")
|
||||
@@ -47,7 +31,6 @@ func TestGetWithStrings(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetWithNoPrefix(t *testing.T) {
|
||||
clearSignozEnv(t)
|
||||
t.Setenv("K1_K2", "string")
|
||||
t.Setenv("K3_K4", "string")
|
||||
expected := map[string]any{}
|
||||
@@ -60,7 +43,6 @@ func TestGetWithNoPrefix(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetWithGoTypes(t *testing.T) {
|
||||
clearSignozEnv(t)
|
||||
t.Setenv("SIGNOZ_BOOL", "true")
|
||||
t.Setenv("SIGNOZ_STRING", "string")
|
||||
t.Setenv("SIGNOZ_INT", "1")
|
||||
|
||||
@@ -32,28 +32,30 @@ import (
|
||||
)
|
||||
|
||||
type PrepareTaskOptions struct {
|
||||
Rule *ruletypes.PostableRule
|
||||
TaskName string
|
||||
RuleStore ruletypes.RuleStore
|
||||
Querier querier.Querier
|
||||
Logger *slog.Logger
|
||||
Cache cache.Cache
|
||||
ManagerOpts *ManagerOptions
|
||||
NotifyFunc NotifyFunc
|
||||
SQLStore sqlstore.SQLStore
|
||||
OrgID valuer.UUID
|
||||
Rule *ruletypes.PostableRule
|
||||
TaskName string
|
||||
RuleStore ruletypes.RuleStore
|
||||
MaintenanceStore ruletypes.MaintenanceStore
|
||||
Querier querier.Querier
|
||||
Logger *slog.Logger
|
||||
Cache cache.Cache
|
||||
ManagerOpts *ManagerOptions
|
||||
NotifyFunc NotifyFunc
|
||||
SQLStore sqlstore.SQLStore
|
||||
OrgID valuer.UUID
|
||||
}
|
||||
|
||||
type PrepareTestRuleOptions struct {
|
||||
Rule *ruletypes.PostableRule
|
||||
RuleStore ruletypes.RuleStore
|
||||
Querier querier.Querier
|
||||
Logger *slog.Logger
|
||||
Cache cache.Cache
|
||||
ManagerOpts *ManagerOptions
|
||||
NotifyFunc NotifyFunc
|
||||
SQLStore sqlstore.SQLStore
|
||||
OrgID valuer.UUID
|
||||
Rule *ruletypes.PostableRule
|
||||
RuleStore ruletypes.RuleStore
|
||||
MaintenanceStore ruletypes.MaintenanceStore
|
||||
Querier querier.Querier
|
||||
Logger *slog.Logger
|
||||
Cache cache.Cache
|
||||
ManagerOpts *ManagerOptions
|
||||
NotifyFunc NotifyFunc
|
||||
SQLStore sqlstore.SQLStore
|
||||
OrgID valuer.UUID
|
||||
}
|
||||
|
||||
const taskNameSuffix = "webAppEditor"
|
||||
@@ -87,7 +89,7 @@ type ManagerOptions struct {
|
||||
Alertmanager alertmanager.Alertmanager
|
||||
OrgGetter organization.Getter
|
||||
RuleStore ruletypes.RuleStore
|
||||
MaintenanceStore alertmanagertypes.MaintenanceStore
|
||||
MaintenanceStore ruletypes.MaintenanceStore
|
||||
SQLStore sqlstore.SQLStore
|
||||
QueryParser queryparser.QueryParser
|
||||
}
|
||||
@@ -101,7 +103,7 @@ type Manager struct {
|
||||
block chan struct{}
|
||||
// datastore to store alert definitions
|
||||
ruleStore ruletypes.RuleStore
|
||||
maintenanceStore alertmanagertypes.MaintenanceStore
|
||||
maintenanceStore ruletypes.MaintenanceStore
|
||||
|
||||
logger *slog.Logger
|
||||
cache cache.Cache
|
||||
@@ -132,6 +134,7 @@ func defaultOptions(o *ManagerOptions) *ManagerOptions {
|
||||
}
|
||||
|
||||
func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
|
||||
|
||||
rules := make([]Rule, 0)
|
||||
var task Task
|
||||
|
||||
@@ -156,6 +159,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
|
||||
WithMetadataStore(opts.ManagerOpts.MetadataStore),
|
||||
WithRuleStateHistoryModule(opts.ManagerOpts.RuleStateHistoryModule),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return task, err
|
||||
}
|
||||
@@ -163,7 +167,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
|
||||
rules = append(rules, tr)
|
||||
|
||||
// create ch rule task for evaluation
|
||||
task = newTask(TaskTypeCh, opts.TaskName, taskNameSuffix, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
|
||||
task = newTask(TaskTypeCh, opts.TaskName, taskNameSuffix, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
|
||||
|
||||
} else if opts.Rule.RuleType == ruletypes.RuleTypeProm {
|
||||
|
||||
@@ -179,6 +183,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
|
||||
WithMetadataStore(opts.ManagerOpts.MetadataStore),
|
||||
WithRuleStateHistoryModule(opts.ManagerOpts.RuleStateHistoryModule),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return task, err
|
||||
}
|
||||
@@ -186,7 +191,7 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
|
||||
rules = append(rules, pr)
|
||||
|
||||
// create promql rule task for evaluation
|
||||
task = newTask(TaskTypeProm, opts.TaskName, taskNameSuffix, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
|
||||
task = newTask(TaskTypeProm, opts.TaskName, taskNameSuffix, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
|
||||
|
||||
} else {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
|
||||
@@ -232,11 +237,10 @@ func (m *Manager) RuleStore() ruletypes.RuleStore {
|
||||
return m.ruleStore
|
||||
}
|
||||
|
||||
func (m *Manager) MaintenanceStore() alertmanagertypes.MaintenanceStore {
|
||||
func (m *Manager) MaintenanceStore() ruletypes.MaintenanceStore {
|
||||
return m.maintenanceStore
|
||||
}
|
||||
|
||||
// TODO(jatinderjit): remove (unused)?
|
||||
func (m *Manager) Pause(b bool) {
|
||||
m.mtx.Lock()
|
||||
defer m.mtx.Unlock()
|
||||
@@ -426,17 +430,19 @@ func (m *Manager) editTask(_ context.Context, orgID valuer.UUID, rule *ruletypes
|
||||
m.logger.Debug("editing a rule task", "name", taskName)
|
||||
|
||||
newTask, err := m.prepareTaskFunc(PrepareTaskOptions{
|
||||
Rule: rule,
|
||||
TaskName: taskName,
|
||||
RuleStore: m.ruleStore,
|
||||
Querier: m.opts.Querier,
|
||||
Logger: m.opts.Logger,
|
||||
Cache: m.cache,
|
||||
ManagerOpts: m.opts,
|
||||
NotifyFunc: m.notifyFunc,
|
||||
SQLStore: m.sqlstore,
|
||||
OrgID: orgID,
|
||||
Rule: rule,
|
||||
TaskName: taskName,
|
||||
RuleStore: m.ruleStore,
|
||||
MaintenanceStore: m.maintenanceStore,
|
||||
Querier: m.opts.Querier,
|
||||
Logger: m.opts.Logger,
|
||||
Cache: m.cache,
|
||||
ManagerOpts: m.opts,
|
||||
NotifyFunc: m.notifyFunc,
|
||||
SQLStore: m.sqlstore,
|
||||
OrgID: orgID,
|
||||
})
|
||||
|
||||
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")
|
||||
@@ -637,17 +643,19 @@ func (m *Manager) addTask(_ context.Context, orgID valuer.UUID, rule *ruletypes.
|
||||
|
||||
m.logger.Debug("adding a new rule task", "name", taskName)
|
||||
newTask, err := m.prepareTaskFunc(PrepareTaskOptions{
|
||||
Rule: rule,
|
||||
TaskName: taskName,
|
||||
RuleStore: m.ruleStore,
|
||||
Querier: m.opts.Querier,
|
||||
Logger: m.opts.Logger,
|
||||
Cache: m.cache,
|
||||
ManagerOpts: m.opts,
|
||||
NotifyFunc: m.notifyFunc,
|
||||
SQLStore: m.sqlstore,
|
||||
OrgID: orgID,
|
||||
Rule: rule,
|
||||
TaskName: taskName,
|
||||
RuleStore: m.ruleStore,
|
||||
MaintenanceStore: m.maintenanceStore,
|
||||
Querier: m.opts.Querier,
|
||||
Logger: m.opts.Logger,
|
||||
Cache: m.cache,
|
||||
ManagerOpts: m.opts,
|
||||
NotifyFunc: m.notifyFunc,
|
||||
SQLStore: m.sqlstore,
|
||||
OrgID: orgID,
|
||||
})
|
||||
|
||||
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")
|
||||
@@ -695,6 +703,7 @@ func (m *Manager) RuleTasks() []Task {
|
||||
// RuleTasksWithoutLock returns the list of manager's rule tasks without
|
||||
// acquiring a lock on the manager.
|
||||
func (m *Manager) RuleTasksWithoutLock() []Task {
|
||||
|
||||
rgs := make([]Task, 0, len(m.tasks))
|
||||
for _, g := range m.tasks {
|
||||
rgs = append(rgs, g)
|
||||
@@ -888,6 +897,7 @@ func (m *Manager) GetRule(ctx context.Context, id valuer.UUID) (*ruletypes.Getta
|
||||
// the task state. For example - if a stored rule is disabled, then
|
||||
// there is no task running against it.
|
||||
func (m *Manager) syncRuleStateWithTask(ctx context.Context, orgID valuer.UUID, taskName string, rule *ruletypes.PostableRule) error {
|
||||
|
||||
if rule.Disabled {
|
||||
// check if rule has any task running
|
||||
if _, ok := m.tasks[taskName]; ok {
|
||||
@@ -1019,15 +1029,16 @@ func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleS
|
||||
}
|
||||
|
||||
alertCount, err := m.prepareTestRuleFunc(PrepareTestRuleOptions{
|
||||
Rule: &parsedRule,
|
||||
RuleStore: m.ruleStore,
|
||||
Querier: m.opts.Querier,
|
||||
Logger: m.opts.Logger,
|
||||
Cache: m.cache,
|
||||
ManagerOpts: m.opts,
|
||||
NotifyFunc: m.testNotifyFunc,
|
||||
SQLStore: m.sqlstore,
|
||||
OrgID: orgID,
|
||||
Rule: &parsedRule,
|
||||
RuleStore: m.ruleStore,
|
||||
MaintenanceStore: m.maintenanceStore,
|
||||
Querier: m.opts.Querier,
|
||||
Logger: m.opts.Logger,
|
||||
Cache: m.cache,
|
||||
ManagerOpts: m.opts,
|
||||
NotifyFunc: m.testNotifyFunc,
|
||||
SQLStore: m.sqlstore,
|
||||
OrgID: orgID,
|
||||
})
|
||||
|
||||
return alertCount, err
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// PromRuleTask is a promql rule executor
|
||||
@@ -38,11 +39,14 @@ type PromRuleTask struct {
|
||||
pause bool
|
||||
logger *slog.Logger
|
||||
notify NotifyFunc
|
||||
|
||||
maintenanceStore ruletypes.MaintenanceStore
|
||||
orgID valuer.UUID
|
||||
}
|
||||
|
||||
// NewPromRuleTask holds rules that have promql condition
|
||||
// and evaluates the rule at a given frequency
|
||||
func NewPromRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc) *PromRuleTask {
|
||||
func NewPromRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) *PromRuleTask {
|
||||
opts.Logger.Info("initiating a new rule group", "name", name, "frequency", frequency)
|
||||
|
||||
if frequency == 0 {
|
||||
@@ -59,8 +63,10 @@ func NewPromRuleTask(name, file string, frequency time.Duration, rules []Rule, o
|
||||
seriesInPreviousEval: make([]map[string]plabels.Labels, len(rules)),
|
||||
done: make(chan struct{}),
|
||||
terminated: make(chan struct{}),
|
||||
notify: notify,
|
||||
logger: opts.Logger,
|
||||
notify: notify,
|
||||
maintenanceStore: maintenanceStore,
|
||||
logger: opts.Logger,
|
||||
orgID: orgID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,12 +330,30 @@ func (g *PromRuleTask) Eval(ctx context.Context, ts time.Time) {
|
||||
}()
|
||||
|
||||
g.logger.InfoContext(ctx, "promql rule task", "name", g.name, "eval_started_at", ts)
|
||||
maintenance, err := g.maintenanceStore.ListPlannedMaintenance(ctx, g.orgID.StringValue())
|
||||
if err != nil {
|
||||
g.logger.ErrorContext(ctx, "error in processing sql query", errors.Attr(err))
|
||||
}
|
||||
|
||||
for i, rule := range g.rules {
|
||||
if rule == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
shouldSkip := false
|
||||
for _, m := range maintenance {
|
||||
g.logger.InfoContext(ctx, "checking if rule should be skipped", slog.String("rule.id", rule.ID()), slog.Any("maintenance", m))
|
||||
if m.ShouldSkip(rule.ID(), ts) {
|
||||
shouldSkip = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if shouldSkip {
|
||||
g.logger.InfoContext(ctx, "rule should be skipped", slog.String("rule.id", rule.ID()))
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case <-g.done:
|
||||
return
|
||||
|
||||
@@ -2,18 +2,20 @@ package rules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
opentracing "github.com/opentracing/opentracing-go"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// RuleTask holds a rule (with composite queries)
|
||||
@@ -35,28 +37,34 @@ type RuleTask struct {
|
||||
|
||||
pause bool
|
||||
notify NotifyFunc
|
||||
|
||||
maintenanceStore ruletypes.MaintenanceStore
|
||||
orgID valuer.UUID
|
||||
}
|
||||
|
||||
const DefaultFrequency = 1 * time.Minute
|
||||
|
||||
// NewRuleTask makes a new RuleTask with the given name, options, and rules.
|
||||
func NewRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc) *RuleTask {
|
||||
func NewRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) *RuleTask {
|
||||
|
||||
if frequency == 0 {
|
||||
frequency = DefaultFrequency
|
||||
}
|
||||
opts.Logger.Info("initiating a new rule task", "name", name, "frequency", frequency)
|
||||
|
||||
return &RuleTask{
|
||||
name: name,
|
||||
file: file,
|
||||
pause: false,
|
||||
frequency: frequency,
|
||||
rules: rules,
|
||||
opts: opts,
|
||||
logger: opts.Logger,
|
||||
done: make(chan struct{}),
|
||||
terminated: make(chan struct{}),
|
||||
notify: notify,
|
||||
name: name,
|
||||
file: file,
|
||||
pause: false,
|
||||
frequency: frequency,
|
||||
rules: rules,
|
||||
opts: opts,
|
||||
logger: opts.Logger,
|
||||
done: make(chan struct{}),
|
||||
terminated: make(chan struct{}),
|
||||
notify: notify,
|
||||
maintenanceStore: maintenanceStore,
|
||||
orgID: orgID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +72,6 @@ func NewRuleTask(name, file string, frequency time.Duration, rules []Rule, opts
|
||||
func (g *RuleTask) Name() string { return g.name }
|
||||
|
||||
// Key returns the group key
|
||||
// TODO(jatinderjit): remove (unused)?
|
||||
func (g *RuleTask) Key() string {
|
||||
return g.name + ";" + g.file
|
||||
}
|
||||
@@ -76,7 +83,7 @@ func (g *RuleTask) Type() TaskType { return TaskTypeCh }
|
||||
func (g *RuleTask) Rules() []Rule { return g.rules }
|
||||
|
||||
// Interval returns the group's interval.
|
||||
// TODO(jatinderjit): remove (unused)?
|
||||
// TODO: remove (unused)?
|
||||
func (g *RuleTask) Interval() time.Duration { return g.frequency }
|
||||
|
||||
func (g *RuleTask) Pause(b bool) {
|
||||
@@ -254,6 +261,7 @@ func nameAndLabels(rule Rule) string {
|
||||
// Rules are matched based on their name and labels. If there are duplicates, the
|
||||
// first is matched with the first, second with the second etc.
|
||||
func (g *RuleTask) CopyState(fromTask Task) error {
|
||||
|
||||
from, ok := fromTask.(*RuleTask)
|
||||
if !ok {
|
||||
return errors.NewInternalf(errors.CodeInternal, "invalid from task for copy")
|
||||
@@ -298,6 +306,7 @@ func (g *RuleTask) CopyState(fromTask Task) error {
|
||||
|
||||
// Eval runs a single evaluation cycle in which all rules are evaluated sequentially.
|
||||
func (g *RuleTask) Eval(ctx context.Context, ts time.Time) {
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
g.logger.ErrorContext(
|
||||
@@ -309,11 +318,31 @@ func (g *RuleTask) Eval(ctx context.Context, ts time.Time) {
|
||||
|
||||
g.logger.DebugContext(ctx, "rule task eval started", "name", g.name, "start_time", ts)
|
||||
|
||||
maintenance, err := g.maintenanceStore.ListPlannedMaintenance(ctx, g.orgID.StringValue())
|
||||
|
||||
if err != nil {
|
||||
g.logger.ErrorContext(ctx, "error in processing sql query", errors.Attr(err))
|
||||
}
|
||||
|
||||
for i, rule := range g.rules {
|
||||
if rule == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
shouldSkip := false
|
||||
for _, m := range maintenance {
|
||||
g.logger.InfoContext(ctx, "checking if rule should be skipped", slog.String("rule.id", rule.ID()), slog.Any("maintenance", m))
|
||||
if m.ShouldSkip(rule.ID(), ts) {
|
||||
shouldSkip = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if shouldSkip {
|
||||
g.logger.InfoContext(ctx, "rule should be skipped", slog.String("rule.id", rule.ID()))
|
||||
continue
|
||||
}
|
||||
|
||||
select {
|
||||
case <-g.done:
|
||||
return
|
||||
@@ -353,6 +382,7 @@ func (g *RuleTask) Eval(ctx context.Context, ts time.Time) {
|
||||
}
|
||||
|
||||
rule.SendAlerts(ctx, ts, g.opts.ResendDelay, g.frequency, g.notify)
|
||||
|
||||
}(i, rule)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package rules
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type TaskType string
|
||||
@@ -29,9 +32,9 @@ type Task interface {
|
||||
|
||||
// newTask returns an appropriate group for
|
||||
// rule type
|
||||
func newTask(taskType TaskType, name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc) Task {
|
||||
func newTask(taskType TaskType, name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) Task {
|
||||
if taskType == TaskTypeCh {
|
||||
return NewRuleTask(name, file, frequency, rules, opts, notify)
|
||||
return NewRuleTask(name, file, frequency, rules, opts, notify, maintenanceStore, orgID)
|
||||
}
|
||||
return NewPromRuleTask(name, file, frequency, rules, opts, notify)
|
||||
return NewPromRuleTask(name, file, frequency, rules, opts, notify, maintenanceStore, orgID)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ type Handler interface {
|
||||
DeleteRuleByID(http.ResponseWriter, *http.Request)
|
||||
PatchRuleByID(http.ResponseWriter, *http.Request)
|
||||
TestRule(http.ResponseWriter, *http.Request)
|
||||
MuteRule(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListDowntimeSchedules(http.ResponseWriter, *http.Request)
|
||||
GetDowntimeScheduleByID(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -45,5 +44,5 @@ type Ruler interface {
|
||||
// MaintenanceStore returns the store for planned maintenance / downtime schedules.
|
||||
// TODO: expose downtime CRUD as methods on Ruler directly instead of leaking the
|
||||
// store interface. The handler should not call store methods directly.
|
||||
MaintenanceStore() alertmanagertypes.MaintenanceStore
|
||||
MaintenanceStore() ruletypes.MaintenanceStore
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package sqlalertmanagerstore
|
||||
package sqlrulestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
@@ -16,12 +16,12 @@ type maintenance struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewMaintenanceStore(store sqlstore.SQLStore) alertmanagertypes.MaintenanceStore {
|
||||
func NewMaintenanceStore(store sqlstore.SQLStore) ruletypes.MaintenanceStore {
|
||||
return &maintenance{sqlstore: store}
|
||||
}
|
||||
|
||||
func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string) ([]*alertmanagertypes.PlannedMaintenance, error) {
|
||||
gettableMaintenancesRules := make([]*alertmanagertypes.PlannedMaintenanceWithRules, 0)
|
||||
func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string) ([]*ruletypes.PlannedMaintenance, error) {
|
||||
gettableMaintenancesRules := make([]*ruletypes.PlannedMaintenanceWithRules, 0)
|
||||
err := r.sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
@@ -33,7 +33,7 @@ func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
gettablePlannedMaintenance := make([]*alertmanagertypes.PlannedMaintenance, 0)
|
||||
gettablePlannedMaintenance := make([]*ruletypes.PlannedMaintenance, 0)
|
||||
for _, gettableMaintenancesRule := range gettableMaintenancesRules {
|
||||
gettablePlannedMaintenance = append(gettablePlannedMaintenance, gettableMaintenancesRule.ToPlannedMaintenance())
|
||||
}
|
||||
@@ -41,8 +41,8 @@ func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string)
|
||||
return gettablePlannedMaintenance, nil
|
||||
}
|
||||
|
||||
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
|
||||
storableMaintenanceRule := new(alertmanagertypes.PlannedMaintenanceWithRules)
|
||||
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*ruletypes.PlannedMaintenance, error) {
|
||||
storableMaintenanceRule := new(ruletypes.PlannedMaintenanceWithRules)
|
||||
err := r.sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
@@ -57,13 +57,13 @@ func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.U
|
||||
return storableMaintenanceRule.ToPlannedMaintenance(), nil
|
||||
}
|
||||
|
||||
func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance *alertmanagertypes.PostablePlannedMaintenance) (*alertmanagertypes.PlannedMaintenance, error) {
|
||||
func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance *ruletypes.PostablePlannedMaintenance) (*ruletypes.PlannedMaintenance, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storablePlannedMaintenance := alertmanagertypes.StorablePlannedMaintenance{
|
||||
storablePlannedMaintenance := ruletypes.StorablePlannedMaintenance{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
@@ -75,21 +75,20 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
|
||||
CreatedBy: claims.Email,
|
||||
UpdatedBy: claims.Email,
|
||||
},
|
||||
Name: maintenance.Name,
|
||||
Description: maintenance.Description,
|
||||
Schedule: maintenance.Schedule,
|
||||
OrgID: claims.OrgID,
|
||||
LabelExpression: maintenance.LabelExpression,
|
||||
Name: maintenance.Name,
|
||||
Description: maintenance.Description,
|
||||
Schedule: maintenance.Schedule,
|
||||
OrgID: claims.OrgID,
|
||||
}
|
||||
|
||||
maintenanceRules := make([]*alertmanagertypes.StorablePlannedMaintenanceRule, 0)
|
||||
maintenanceRules := make([]*ruletypes.StorablePlannedMaintenanceRule, 0)
|
||||
for _, ruleIDStr := range maintenance.AlertIds {
|
||||
ruleID, err := valuer.NewUUID(ruleIDStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maintenanceRules = append(maintenanceRules, &alertmanagertypes.StorablePlannedMaintenanceRule{
|
||||
maintenanceRules = append(maintenanceRules, &ruletypes.StorablePlannedMaintenanceRule{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
@@ -114,6 +113,7 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
|
||||
NewInsert().
|
||||
Model(&maintenanceRules).
|
||||
Exec(ctx)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -125,17 +125,16 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &alertmanagertypes.PlannedMaintenance{
|
||||
ID: storablePlannedMaintenance.ID,
|
||||
Name: storablePlannedMaintenance.Name,
|
||||
Description: storablePlannedMaintenance.Description,
|
||||
Schedule: storablePlannedMaintenance.Schedule,
|
||||
RuleIDs: maintenance.AlertIds,
|
||||
LabelExpression: maintenance.LabelExpression,
|
||||
CreatedAt: storablePlannedMaintenance.CreatedAt,
|
||||
CreatedBy: storablePlannedMaintenance.CreatedBy,
|
||||
UpdatedAt: storablePlannedMaintenance.UpdatedAt,
|
||||
UpdatedBy: storablePlannedMaintenance.UpdatedBy,
|
||||
return &ruletypes.PlannedMaintenance{
|
||||
ID: storablePlannedMaintenance.ID,
|
||||
Name: storablePlannedMaintenance.Name,
|
||||
Description: storablePlannedMaintenance.Description,
|
||||
Schedule: storablePlannedMaintenance.Schedule,
|
||||
RuleIDs: maintenance.AlertIds,
|
||||
CreatedAt: storablePlannedMaintenance.CreatedAt,
|
||||
CreatedBy: storablePlannedMaintenance.CreatedBy,
|
||||
UpdatedAt: storablePlannedMaintenance.UpdatedAt,
|
||||
UpdatedBy: storablePlannedMaintenance.UpdatedBy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -143,7 +142,7 @@ func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UU
|
||||
_, err := r.sqlstore.
|
||||
BunDB().
|
||||
NewDelete().
|
||||
Model(new(alertmanagertypes.StorablePlannedMaintenance)).
|
||||
Model(new(ruletypes.StorablePlannedMaintenance)).
|
||||
Where("id = ?", id.StringValue()).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
@@ -153,7 +152,7 @@ func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UU
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance *alertmanagertypes.PostablePlannedMaintenance, id valuer.UUID) error {
|
||||
func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance *ruletypes.PostablePlannedMaintenance, id valuer.UUID) error {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -164,7 +163,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
|
||||
return err
|
||||
}
|
||||
|
||||
storablePlannedMaintenance := alertmanagertypes.StorablePlannedMaintenance{
|
||||
storablePlannedMaintenance := ruletypes.StorablePlannedMaintenance{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: id,
|
||||
},
|
||||
@@ -176,21 +175,20 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
|
||||
CreatedBy: existing.CreatedBy,
|
||||
UpdatedBy: claims.Email,
|
||||
},
|
||||
Name: maintenance.Name,
|
||||
Description: maintenance.Description,
|
||||
Schedule: maintenance.Schedule,
|
||||
OrgID: claims.OrgID,
|
||||
LabelExpression: maintenance.LabelExpression,
|
||||
Name: maintenance.Name,
|
||||
Description: maintenance.Description,
|
||||
Schedule: maintenance.Schedule,
|
||||
OrgID: claims.OrgID,
|
||||
}
|
||||
|
||||
storablePlannedMaintenanceRules := make([]*alertmanagertypes.StorablePlannedMaintenanceRule, 0)
|
||||
storablePlannedMaintenanceRules := make([]*ruletypes.StorablePlannedMaintenanceRule, 0)
|
||||
for _, ruleIDStr := range maintenance.AlertIds {
|
||||
ruleID, err := valuer.NewUUID(ruleIDStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storablePlannedMaintenanceRules = append(storablePlannedMaintenanceRules, &alertmanagertypes.StorablePlannedMaintenanceRule{
|
||||
storablePlannedMaintenanceRules = append(storablePlannedMaintenanceRules, &ruletypes.StorablePlannedMaintenanceRule{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
@@ -213,9 +211,10 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
|
||||
_, err = r.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(new(alertmanagertypes.StorablePlannedMaintenanceRule)).
|
||||
Model(new(ruletypes.StorablePlannedMaintenanceRule)).
|
||||
Where("planned_maintenance_id = ?", storablePlannedMaintenance.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -232,6 +231,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/http/binding"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -185,53 +184,6 @@ func (handler *handler) TestRule(rw http.ResponseWriter, req *http.Request) {
|
||||
render.Success(rw, http.StatusOK, ruletypes.GettableTestRule{AlertCount: alertCount, Message: "notification sent"})
|
||||
}
|
||||
|
||||
// MuteRule mutes an alert rule until the provided endTime by creating a
|
||||
// fixed-window planned maintenance scoped to that rule. The created
|
||||
// PlannedMaintenance is returned so the client can later unmute by deleting
|
||||
// it via DELETE /api/v1/downtime_schedules/{id}.
|
||||
func (handler *handler) MuteRule(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
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"))
|
||||
return
|
||||
}
|
||||
|
||||
rule, err := handler.ruler.GetRule(ctx, id)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
payload := new(ruletypes.PostableMuteRule)
|
||||
if err := binding.JSON.BindBody(req.Body, payload); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := payload.Validate(now); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
schedule := payload.ToPostablePlannedMaintenance(id.StringValue(), rule.AlertName, now)
|
||||
if err := schedule.Validate(); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
created, err := handler.ruler.MaintenanceStore().CreatePlannedMaintenance(ctx, schedule)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func (handler *handler) ListDowntimeSchedules(rw http.ResponseWriter, req *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -242,7 +194,7 @@ func (handler *handler) ListDowntimeSchedules(rw http.ResponseWriter, req *http.
|
||||
return
|
||||
}
|
||||
|
||||
var params alertmanagertypes.ListPlannedMaintenanceParams
|
||||
var params ruletypes.ListPlannedMaintenanceParams
|
||||
if err := binding.Query.BindQuery(req.URL.Query(), ¶ms); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -255,7 +207,7 @@ func (handler *handler) ListDowntimeSchedules(rw http.ResponseWriter, req *http.
|
||||
}
|
||||
|
||||
if params.Active != nil {
|
||||
activeSchedules := make([]*alertmanagertypes.PlannedMaintenance, 0)
|
||||
activeSchedules := make([]*ruletypes.PlannedMaintenance, 0)
|
||||
for _, schedule := range schedules {
|
||||
now := time.Now().In(time.FixedZone(schedule.Schedule.Timezone, 0))
|
||||
if schedule.IsActive(now) == *params.Active {
|
||||
@@ -266,7 +218,7 @@ func (handler *handler) ListDowntimeSchedules(rw http.ResponseWriter, req *http.
|
||||
}
|
||||
|
||||
if params.Recurring != nil {
|
||||
recurringSchedules := make([]*alertmanagertypes.PlannedMaintenance, 0)
|
||||
recurringSchedules := make([]*ruletypes.PlannedMaintenance, 0)
|
||||
for _, schedule := range schedules {
|
||||
if schedule.IsRecurring() == *params.Recurring {
|
||||
recurringSchedules = append(recurringSchedules, schedule)
|
||||
@@ -301,7 +253,7 @@ func (handler *handler) CreateDowntimeSchedule(rw http.ResponseWriter, req *http
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
schedule := new(alertmanagertypes.PostablePlannedMaintenance)
|
||||
schedule := new(ruletypes.PostablePlannedMaintenance)
|
||||
if err := binding.JSON.BindBody(req.Body, schedule); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -331,7 +283,7 @@ func (handler *handler) UpdateDowntimeScheduleByID(rw http.ResponseWriter, req *
|
||||
return
|
||||
}
|
||||
|
||||
schedule := new(alertmanagertypes.PostablePlannedMaintenance)
|
||||
schedule := new(ruletypes.PostablePlannedMaintenance)
|
||||
if err := binding.JSON.BindBody(req.Body, schedule); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
|
||||
"github.com/SigNoz/signoz/pkg/cache"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
@@ -17,7 +16,6 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -46,7 +44,7 @@ func NewFactory(
|
||||
) factory.ProviderFactory[ruler.Ruler, ruler.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config ruler.Config) (ruler.Ruler, error) {
|
||||
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
|
||||
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
|
||||
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
|
||||
|
||||
managerOpts := &rules.ManagerOptions{
|
||||
TelemetryStore: telemetryStore,
|
||||
@@ -131,6 +129,6 @@ func (provider *provider) TestNotification(ctx context.Context, orgID valuer.UUI
|
||||
return provider.manager.TestNotification(ctx, orgID, ruleStr)
|
||||
}
|
||||
|
||||
func (provider *provider) MaintenanceStore() alertmanagertypes.MaintenanceStore {
|
||||
func (provider *provider) MaintenanceStore() ruletypes.MaintenanceStore {
|
||||
return provider.manager.MaintenanceStore()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
|
||||
@@ -40,8 +39,7 @@ func TestNewHandlers(t *testing.T) {
|
||||
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
|
||||
notificationManager := nfmanagertest.NewMock()
|
||||
require.NoError(t, err)
|
||||
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
|
||||
alertmanager, err := signozalertmanager.New(providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager, maintenanceStore)
|
||||
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager)
|
||||
require.NoError(t, err)
|
||||
tokenizer := tokenizertest.NewMockTokenizer(t)
|
||||
emailing := emailingtest.New()
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/signozalertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/emailing/emailingtest"
|
||||
@@ -41,8 +40,7 @@ func TestNewModules(t *testing.T) {
|
||||
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
|
||||
notificationManager := nfmanagertest.NewMock()
|
||||
require.NoError(t, err)
|
||||
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
|
||||
alertmanager, err := signozalertmanager.New(providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager, maintenanceStore)
|
||||
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager)
|
||||
require.NoError(t, err)
|
||||
tokenizer := tokenizertest.NewMockTokenizer(t)
|
||||
emailing := emailingtest.New()
|
||||
|
||||
@@ -200,7 +200,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewAddServiceAccountManagedRoleTransactionsFactory(sqlstore),
|
||||
sqlmigration.NewAddSpanMapperFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddLLMPricingRulesFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewAddLabelExpressionToPlannedMaintenanceFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -227,14 +226,9 @@ func NewNotificationManagerProviderFactories(routeStore alertmanagertypes.RouteS
|
||||
)
|
||||
}
|
||||
|
||||
func NewAlertmanagerProviderFactories(
|
||||
sqlstore sqlstore.SQLStore,
|
||||
orgGetter organization.Getter,
|
||||
nfManager nfmanager.NotificationManager,
|
||||
maintenanceStore alertmanagertypes.MaintenanceStore,
|
||||
) factory.NamedMap[factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config]] {
|
||||
func NewAlertmanagerProviderFactories(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, nfManager nfmanager.NotificationManager) factory.NamedMap[factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
signozalertmanager.NewFactory(sqlstore, orgGetter, nfManager, maintenanceStore),
|
||||
signozalertmanager.NewFactory(sqlstore, orgGetter, nfManager),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfmanagertest"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
@@ -60,11 +59,9 @@ func TestNewProviderFactories(t *testing.T) {
|
||||
})
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
store := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
|
||||
orgGetter := implorganization.NewGetter(implorganization.NewStore(store), nil)
|
||||
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)), nil)
|
||||
notificationManager := nfmanagertest.NewMock()
|
||||
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(store)
|
||||
NewAlertmanagerProviderFactories(store, orgGetter, notificationManager, maintenanceStore)
|
||||
NewAlertmanagerProviderFactories(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), orgGetter, notificationManager)
|
||||
})
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/nfmanager/nfroutingstore/sqlroutingstore"
|
||||
"github.com/SigNoz/signoz/pkg/analytics"
|
||||
@@ -370,14 +369,12 @@ func New(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
|
||||
|
||||
// Initialize alertmanager from the available alertmanager provider factories
|
||||
alertmanager, err := factory.NewProviderFromNamedMap(
|
||||
ctx,
|
||||
providerSettings,
|
||||
config.Alertmanager,
|
||||
NewAlertmanagerProviderFactories(sqlstore, orgGetter, nfManager, maintenanceStore),
|
||||
NewAlertmanagerProviderFactories(sqlstore, orgGetter, nfManager),
|
||||
config.Alertmanager.Provider,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
@@ -61,7 +61,7 @@ type existingMaintenance struct {
|
||||
Name string `bun:"name,type:text,notnull"`
|
||||
Description string `bun:"description,type:text"`
|
||||
AlertIDs *AlertIds `bun:"alert_ids,type:text"`
|
||||
Schedule *alertmanagertypes.Schedule `bun:"schedule,type:text,notnull"`
|
||||
Schedule *ruletypes.Schedule `bun:"schedule,type:text,notnull"`
|
||||
CreatedAt time.Time `bun:"created_at,type:datetime,notnull"`
|
||||
CreatedBy string `bun:"created_by,type:text,notnull"`
|
||||
UpdatedAt time.Time `bun:"updated_at,type:datetime,notnull"`
|
||||
@@ -75,7 +75,7 @@ type newMaintenance struct {
|
||||
types.UserAuditable
|
||||
Name string `bun:"name,type:text,notnull"`
|
||||
Description string `bun:"description,type:text"`
|
||||
Schedule *alertmanagertypes.Schedule `bun:"schedule,type:text,notnull"`
|
||||
Schedule *ruletypes.Schedule `bun:"schedule,type:text,notnull"`
|
||||
OrgID string `bun:"org_id,type:text"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addLabelExpressionToPlannedMaintenance struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
func NewAddLabelExpressionToPlannedMaintenanceFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("add_label_expr_to_planned"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addLabelExpressionToPlannedMaintenance{
|
||||
sqlstore: sqlstore,
|
||||
sqlschema: sqlschema,
|
||||
}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (migration *addLabelExpressionToPlannedMaintenance) Register(migrations *migrate.Migrations) error {
|
||||
if err := migrations.Register(migration.Up, migration.Down); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (migration *addLabelExpressionToPlannedMaintenance) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
table, _, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("planned_maintenance"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
column := &sqlschema.Column{
|
||||
Name: sqlschema.ColumnName("label_expression"),
|
||||
DataType: sqlschema.DataTypeText,
|
||||
Nullable: true,
|
||||
}
|
||||
|
||||
sqls := migration.sqlschema.Operator().AddColumn(table, nil, column, nil)
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addLabelExpressionToPlannedMaintenance) Down(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
table, _, err := migration.sqlschema.GetTable(ctx, sqlschema.TableName("planned_maintenance"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
column := &sqlschema.Column{
|
||||
Name: sqlschema.ColumnName("label_expression"),
|
||||
DataType: sqlschema.DataTypeText,
|
||||
Nullable: true,
|
||||
}
|
||||
|
||||
sqls := migration.sqlschema.Operator().DropColumn(table, column)
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -170,7 +170,6 @@ func NewGettableAlertsFromAlertProvider(
|
||||
cfg *Config,
|
||||
getAlertStatusFunc func(model.Fingerprint) types.AlertStatus,
|
||||
setAlertStatusFunc func(model.LabelSet),
|
||||
mutedByFunc func(model.LabelSet) []string,
|
||||
params GettableAlertsParams,
|
||||
) (GettableAlerts, error) {
|
||||
res := GettableAlerts{}
|
||||
@@ -220,7 +219,7 @@ func NewGettableAlertsFromAlertProvider(
|
||||
continue
|
||||
}
|
||||
|
||||
alert := v2.AlertToOpenAPIAlert(alertData, getAlertStatusFunc(alertData.Fingerprint()), receivers, mutedByFunc(alertData.Labels))
|
||||
alert := v2.AlertToOpenAPIAlert(alertData, getAlertStatusFunc(alertData.Fingerprint()), receivers, nil)
|
||||
|
||||
res = append(res, alert)
|
||||
}
|
||||
|
||||
12
pkg/types/alertmanagertypes/marker.go
Normal file
12
pkg/types/alertmanagertypes/marker.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
type MemMarker = types.MemMarker
|
||||
|
||||
func NewMarker(r prometheus.Registerer) *MemMarker {
|
||||
return types.NewMarker(r)
|
||||
}
|
||||
@@ -1,58 +1,19 @@
|
||||
package alertmanagertypes
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/expr-lang/expr"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/uptrace/bun"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
var ErrCodeInvalidPlannedMaintenancePayload = errors.MustNewCode("invalid_planned_maintenance_payload")
|
||||
var ErrCodeInvalidMutePayload = errors.MustNewCode("invalid_mute_payload")
|
||||
|
||||
// PostableMuteRule is the input payload for muting an alert rule. A mute is a
|
||||
// shortcut for creating a fixed (non-recurring) planned maintenance scoped to a
|
||||
// single rule, covering [now, EndTime].
|
||||
type PostableMuteRule struct {
|
||||
EndTime time.Time `json:"endTime" required:"true"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (p *PostableMuteRule) Validate(now time.Time) error {
|
||||
if p.EndTime.IsZero() {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidMutePayload, "missing endTime in the payload")
|
||||
}
|
||||
if !p.EndTime.After(now) {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidMutePayload, "endTime must be in the future")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToPostablePlannedMaintenance builds the underlying planned maintenance
|
||||
// payload for muting the given rule until p.EndTime.
|
||||
func (p *PostableMuteRule) ToPostablePlannedMaintenance(ruleID string, ruleName string, now time.Time) *PostablePlannedMaintenance {
|
||||
name := "Mute: " + ruleName
|
||||
if ruleName == "" {
|
||||
name = "Mute for rule " + ruleID
|
||||
}
|
||||
return &PostablePlannedMaintenance{
|
||||
Name: name,
|
||||
Description: p.Description,
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: now.UTC(),
|
||||
EndTime: p.EndTime.UTC(),
|
||||
},
|
||||
AlertIds: []string{ruleID},
|
||||
}
|
||||
}
|
||||
var (
|
||||
ErrCodeInvalidPlannedMaintenancePayload = errors.MustNewCode("invalid_planned_maintenance_payload")
|
||||
)
|
||||
|
||||
type MaintenanceStatus struct {
|
||||
valuer.String
|
||||
@@ -95,37 +56,34 @@ type StorablePlannedMaintenance struct {
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
Name string `bun:"name,type:text,notnull"`
|
||||
Description string `bun:"description,type:text"`
|
||||
Schedule *Schedule `bun:"schedule,type:text,notnull"`
|
||||
OrgID string `bun:"org_id,type:text"`
|
||||
LabelExpression string `bun:"label_expression,type:text"`
|
||||
Name string `bun:"name,type:text,notnull"`
|
||||
Description string `bun:"description,type:text"`
|
||||
Schedule *Schedule `bun:"schedule,type:text,notnull"`
|
||||
OrgID string `bun:"org_id,type:text"`
|
||||
}
|
||||
|
||||
type PlannedMaintenance struct {
|
||||
ID valuer.UUID `json:"id" required:"true"`
|
||||
Name string `json:"name" required:"true"`
|
||||
Description string `json:"description"`
|
||||
Schedule *Schedule `json:"schedule" required:"true"`
|
||||
RuleIDs []string `json:"alertIds"`
|
||||
LabelExpression string `json:"labelExpression,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
Status MaintenanceStatus `json:"status" required:"true"`
|
||||
Kind MaintenanceKind `json:"kind" required:"true"`
|
||||
ID valuer.UUID `json:"id" required:"true"`
|
||||
Name string `json:"name" required:"true"`
|
||||
Description string `json:"description"`
|
||||
Schedule *Schedule `json:"schedule" required:"true"`
|
||||
RuleIDs []string `json:"alertIds"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
Status MaintenanceStatus `json:"status" required:"true"`
|
||||
Kind MaintenanceKind `json:"kind" required:"true"`
|
||||
}
|
||||
|
||||
// PostablePlannedMaintenance is the input payload for creating or updating a
|
||||
// planned maintenance. Server-owned fields (id, timestamps, audit users,
|
||||
// derived status / kind) are deliberately not accepted from the client.
|
||||
type PostablePlannedMaintenance struct {
|
||||
Name string `json:"name" required:"true"`
|
||||
Description string `json:"description"`
|
||||
Schedule *Schedule `json:"schedule" required:"true"`
|
||||
AlertIds []string `json:"alertIds"`
|
||||
LabelExpression string `json:"labelExpression"`
|
||||
Name string `json:"name" required:"true"`
|
||||
Description string `json:"description"`
|
||||
Schedule *Schedule `json:"schedule" required:"true"`
|
||||
AlertIds []string `json:"alertIds"`
|
||||
}
|
||||
|
||||
func (p *PostablePlannedMaintenance) Validate() error {
|
||||
@@ -142,11 +100,11 @@ func (p *PostablePlannedMaintenance) Validate() error {
|
||||
if _, err := time.LoadLocation(p.Schedule.Timezone); err != nil {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "invalid timezone in the payload")
|
||||
}
|
||||
if p.Schedule.StartTime.IsZero() {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing start time in the payload")
|
||||
}
|
||||
if !p.Schedule.EndTime.IsZero() && p.Schedule.StartTime.After(p.Schedule.EndTime) {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "start time cannot be after end time")
|
||||
|
||||
if !p.Schedule.StartTime.IsZero() && !p.Schedule.EndTime.IsZero() {
|
||||
if p.Schedule.StartTime.After(p.Schedule.EndTime) {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "start time cannot be after end time")
|
||||
}
|
||||
}
|
||||
|
||||
if p.Schedule.Recurrence != nil {
|
||||
@@ -156,10 +114,8 @@ func (p *PostablePlannedMaintenance) Validate() error {
|
||||
if p.Schedule.Recurrence.Duration.IsZero() {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing duration in the payload")
|
||||
}
|
||||
}
|
||||
if p.LabelExpression != "" {
|
||||
if _, err := expr.Compile(p.LabelExpression, expr.AllowUndefinedVariables(), expr.AsBool()); err != nil {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "invalid label expression: %v", err)
|
||||
if p.Schedule.Recurrence.EndTime != nil && p.Schedule.Recurrence.EndTime.Before(p.Schedule.Recurrence.StartTime) {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "end time cannot be before start time")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -177,7 +133,7 @@ type PlannedMaintenanceWithRules struct {
|
||||
Rules []*StorablePlannedMaintenanceRule `bun:"rel:has-many,join:id=planned_maintenance_id"`
|
||||
}
|
||||
|
||||
func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time, lset model.LabelSet) bool {
|
||||
func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time) bool {
|
||||
// Check if the alert ID is in the maintenance window
|
||||
found := false
|
||||
if len(m.RuleIDs) > 0 {
|
||||
@@ -197,87 +153,60 @@ func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time, lset model
|
||||
return false
|
||||
}
|
||||
|
||||
if !m.isScheduleActive(now) {
|
||||
return false
|
||||
}
|
||||
|
||||
// lset is empty when called from IsActive (no instance labels available);
|
||||
// skip expression filtering in that case.
|
||||
if m.LabelExpression != "" && len(lset) != 0 {
|
||||
if !evalLabelExpression(m.LabelExpression, lset) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// isScheduleActive reports whether now falls inside the maintenance window's schedule.
|
||||
func (m *PlannedMaintenance) isScheduleActive(now time.Time) bool {
|
||||
// If alert is found, we check if it should be skipped based on the schedule
|
||||
loc, err := time.LoadLocation(m.Schedule.Timezone)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// TODO(jatinderjit): `In(loc)` conversions seem redundant
|
||||
currentTime := now.In(loc)
|
||||
startTime := m.Schedule.StartTime.In(loc)
|
||||
endTime := m.Schedule.EndTime.In(loc)
|
||||
|
||||
// Maintenance window hasn't yet started
|
||||
if currentTime.Before(startTime) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Maintenance window has ended
|
||||
if !endTime.IsZero() && currentTime.After(endTime) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Fixed schedule (startTime <= currentTime <= endTime)
|
||||
if m.Schedule.Recurrence == nil {
|
||||
return true
|
||||
// fixed schedule
|
||||
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
|
||||
startTime := m.Schedule.StartTime.In(loc)
|
||||
endTime := m.Schedule.EndTime.In(loc)
|
||||
if currentTime.Equal(startTime) || currentTime.Equal(endTime) ||
|
||||
(currentTime.After(startTime) && currentTime.Before(endTime)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// recurring schedule
|
||||
switch m.Schedule.Recurrence.RepeatType {
|
||||
case RepeatTypeDaily:
|
||||
return m.checkDaily(currentTime, m.Schedule.Recurrence, loc)
|
||||
case RepeatTypeWeekly:
|
||||
return m.checkWeekly(currentTime, m.Schedule.Recurrence, loc)
|
||||
case RepeatTypeMonthly:
|
||||
return m.checkMonthly(currentTime, m.Schedule.Recurrence, loc)
|
||||
if m.Schedule.Recurrence != nil {
|
||||
start := m.Schedule.Recurrence.StartTime
|
||||
|
||||
// Make sure the recurrence has started
|
||||
if currentTime.Before(start.In(loc)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if recurrence has expired
|
||||
if m.Schedule.Recurrence.EndTime != nil {
|
||||
endTime := *m.Schedule.Recurrence.EndTime
|
||||
if !endTime.IsZero() && currentTime.After(endTime.In(loc)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
switch m.Schedule.Recurrence.RepeatType {
|
||||
case RepeatTypeDaily:
|
||||
return m.checkDaily(currentTime, m.Schedule.Recurrence, loc)
|
||||
case RepeatTypeWeekly:
|
||||
return m.checkWeekly(currentTime, m.Schedule.Recurrence, loc)
|
||||
case RepeatTypeMonthly:
|
||||
return m.checkMonthly(currentTime, m.Schedule.Recurrence, loc)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// evalLabelExpression compiles and runs the expression against the provided labels.
|
||||
// Returns false on any error (safety-first: don't suppress on a bad expression).
|
||||
func evalLabelExpression(expression string, lset model.LabelSet) bool {
|
||||
env := make(map[string]interface{}, len(lset))
|
||||
for k, v := range lset {
|
||||
env[string(k)] = string(v)
|
||||
}
|
||||
program, err := expr.Compile(expression, expr.Env(env), expr.AllowUndefinedVariables())
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
output, err := expr.Run(program, env)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
result, ok := output.(bool)
|
||||
return ok && result
|
||||
}
|
||||
|
||||
// checkDaily rebases the recurrence start to today (or yesterday if needed)
|
||||
// and returns true if currentTime is within [candidate, candidate+Duration].
|
||||
func (m *PlannedMaintenance) checkDaily(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
|
||||
candidate := time.Date(
|
||||
currentTime.Year(), currentTime.Month(), currentTime.Day(),
|
||||
m.Schedule.StartTime.Hour(), m.Schedule.StartTime.Minute(), 0, 0,
|
||||
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
|
||||
loc,
|
||||
)
|
||||
if candidate.After(currentTime) {
|
||||
@@ -305,7 +234,7 @@ func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, rec *Recurrence,
|
||||
// Build a candidate occurrence by rebasing today's date to the allowed weekday.
|
||||
candidate := time.Date(
|
||||
currentTime.Year(), currentTime.Month(), currentTime.Day(),
|
||||
m.Schedule.StartTime.Hour(), m.Schedule.StartTime.Minute(), 0, 0,
|
||||
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
|
||||
loc,
|
||||
).AddDate(0, 0, delta)
|
||||
// If the candidate is in the future, subtract 7 days.
|
||||
@@ -322,8 +251,7 @@ func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, rec *Recurrence,
|
||||
// checkMonthly rebases the candidate occurrence using the recurrence's day-of-month.
|
||||
// If the candidate for the current month is in the future, it uses the previous month.
|
||||
func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
|
||||
startTime := m.Schedule.StartTime
|
||||
refDay := startTime.Day()
|
||||
refDay := rec.StartTime.Day()
|
||||
year, month, _ := currentTime.Date()
|
||||
lastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, loc).Day()
|
||||
day := refDay
|
||||
@@ -331,7 +259,7 @@ func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, rec *Recurrence
|
||||
day = lastDay
|
||||
}
|
||||
candidate := time.Date(year, month, day,
|
||||
startTime.Hour(), startTime.Minute(), startTime.Second(), startTime.Nanosecond(),
|
||||
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
|
||||
loc,
|
||||
)
|
||||
if candidate.After(currentTime) {
|
||||
@@ -341,12 +269,12 @@ func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, rec *Recurrence
|
||||
lastDayPrev := time.Date(y, m+1, 0, 0, 0, 0, 0, loc).Day()
|
||||
if refDay > lastDayPrev {
|
||||
candidate = time.Date(y, m, lastDayPrev,
|
||||
startTime.Hour(), startTime.Minute(), startTime.Second(), startTime.Nanosecond(),
|
||||
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
|
||||
loc,
|
||||
)
|
||||
} else {
|
||||
candidate = time.Date(y, m, refDay,
|
||||
startTime.Hour(), startTime.Minute(), startTime.Second(), startTime.Nanosecond(),
|
||||
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
|
||||
loc,
|
||||
)
|
||||
}
|
||||
@@ -359,7 +287,7 @@ func (m *PlannedMaintenance) IsActive(now time.Time) bool {
|
||||
if len(m.RuleIDs) > 0 {
|
||||
ruleID = (m.RuleIDs)[0]
|
||||
}
|
||||
return m.ShouldSkip(ruleID, now, nil)
|
||||
return m.ShouldSkip(ruleID, now)
|
||||
}
|
||||
|
||||
func (m *PlannedMaintenance) IsUpcoming() bool {
|
||||
@@ -368,7 +296,14 @@ func (m *PlannedMaintenance) IsUpcoming() bool {
|
||||
return false
|
||||
}
|
||||
now := time.Now().In(loc)
|
||||
return now.Before(m.Schedule.StartTime)
|
||||
|
||||
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
|
||||
return now.Before(m.Schedule.StartTime)
|
||||
}
|
||||
if m.Schedule.Recurrence != nil {
|
||||
return now.Before(m.Schedule.Recurrence.StartTime)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *PlannedMaintenance) IsRecurring() bool {
|
||||
@@ -385,16 +320,16 @@ func (m *PlannedMaintenance) Validate() error {
|
||||
if m.Schedule.Timezone == "" {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing timezone in the payload")
|
||||
}
|
||||
|
||||
_, err := time.LoadLocation(m.Schedule.Timezone)
|
||||
if err != nil {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "invalid timezone in the payload")
|
||||
}
|
||||
if m.Schedule.StartTime.IsZero() {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing start time in the payload")
|
||||
}
|
||||
|
||||
if !m.Schedule.EndTime.IsZero() && m.Schedule.StartTime.After(m.Schedule.EndTime) {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "start time cannot be after end time")
|
||||
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
|
||||
if m.Schedule.StartTime.After(m.Schedule.EndTime) {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "start time cannot be after end time")
|
||||
}
|
||||
}
|
||||
|
||||
if m.Schedule.Recurrence != nil {
|
||||
@@ -404,6 +339,9 @@ func (m *PlannedMaintenance) Validate() error {
|
||||
if m.Schedule.Recurrence.Duration.IsZero() {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing duration in the payload")
|
||||
}
|
||||
if m.Schedule.Recurrence.EndTime != nil && m.Schedule.Recurrence.EndTime.Before(m.Schedule.Recurrence.StartTime) {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "end time cannot be before start time")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -427,31 +365,29 @@ func (m PlannedMaintenance) MarshalJSON() ([]byte, error) {
|
||||
}
|
||||
|
||||
return json.Marshal(struct {
|
||||
ID valuer.UUID `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description" db:"description"`
|
||||
Schedule *Schedule `json:"schedule" db:"schedule"`
|
||||
AlertIds []string `json:"alertIds" db:"alert_ids"`
|
||||
LabelExpression string `json:"labelExpression,omitempty" db:"label_expression"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
CreatedBy string `json:"createdBy" db:"created_by"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
UpdatedBy string `json:"updatedBy" db:"updated_by"`
|
||||
Status MaintenanceStatus `json:"status"`
|
||||
Kind MaintenanceKind `json:"kind"`
|
||||
ID valuer.UUID `json:"id" db:"id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Description string `json:"description" db:"description"`
|
||||
Schedule *Schedule `json:"schedule" db:"schedule"`
|
||||
AlertIds []string `json:"alertIds" db:"alert_ids"`
|
||||
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
||||
CreatedBy string `json:"createdBy" db:"created_by"`
|
||||
UpdatedAt time.Time `json:"updatedAt" db:"updated_at"`
|
||||
UpdatedBy string `json:"updatedBy" db:"updated_by"`
|
||||
Status MaintenanceStatus `json:"status"`
|
||||
Kind MaintenanceKind `json:"kind"`
|
||||
}{
|
||||
ID: m.ID,
|
||||
Name: m.Name,
|
||||
Description: m.Description,
|
||||
Schedule: m.Schedule,
|
||||
AlertIds: m.RuleIDs,
|
||||
LabelExpression: m.LabelExpression,
|
||||
CreatedAt: m.CreatedAt,
|
||||
CreatedBy: m.CreatedBy,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
UpdatedBy: m.UpdatedBy,
|
||||
Status: status,
|
||||
Kind: kind,
|
||||
ID: m.ID,
|
||||
Name: m.Name,
|
||||
Description: m.Description,
|
||||
Schedule: m.Schedule,
|
||||
AlertIds: m.RuleIDs,
|
||||
CreatedAt: m.CreatedAt,
|
||||
CreatedBy: m.CreatedBy,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
UpdatedBy: m.UpdatedBy,
|
||||
Status: status,
|
||||
Kind: kind,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -464,16 +400,15 @@ func (m *PlannedMaintenanceWithRules) ToPlannedMaintenance() *PlannedMaintenance
|
||||
}
|
||||
|
||||
return &PlannedMaintenance{
|
||||
ID: m.ID,
|
||||
Name: m.Name,
|
||||
Description: m.Description,
|
||||
Schedule: m.Schedule,
|
||||
RuleIDs: ruleIDs,
|
||||
LabelExpression: m.LabelExpression,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
CreatedBy: m.CreatedBy,
|
||||
UpdatedBy: m.UpdatedBy,
|
||||
ID: m.ID,
|
||||
Name: m.Name,
|
||||
Description: m.Description,
|
||||
Schedule: m.Schedule,
|
||||
RuleIDs: ruleIDs,
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
CreatedBy: m.CreatedBy,
|
||||
UpdatedBy: m.UpdatedBy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
package alertmanagertypes
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/common/model"
|
||||
)
|
||||
|
||||
// Helper function to create a time pointer.
|
||||
func timePtr(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
func TestShouldSkipMaintenance(t *testing.T) {
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
maintenance *PlannedMaintenance
|
||||
@@ -19,9 +24,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "only-on-saturday",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "Europe/London",
|
||||
StartTime: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC),
|
||||
Timezone: "Europe/London",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("24h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday, RepeatOnTuesday, RepeatOnWednesday, RepeatOnThursday, RepeatOnFriday, RepeatOnSunday},
|
||||
@@ -36,10 +41,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "weekly-across-midnight-previous-day",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
Duration: valuer.MustParseTextDuration("4h"), // Until Tuesday 02:00
|
||||
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
|
||||
Duration: valuer.MustParseTextDuration("4h"), // Until Tuesday 02:00
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday
|
||||
},
|
||||
@@ -53,10 +58,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "weekly-across-midnight-previous-day",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
Duration: valuer.MustParseTextDuration("4h"), // Until Tuesday 02:00
|
||||
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
|
||||
Duration: valuer.MustParseTextDuration("4h"), // Until Tuesday 02:00
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday
|
||||
},
|
||||
@@ -70,10 +75,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "weekly-across-midnight-previous-day",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
Duration: valuer.MustParseTextDuration("52h"), // Until Thursday 02:00
|
||||
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
|
||||
Duration: valuer.MustParseTextDuration("52h"), // Until Thursday 02:00
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday
|
||||
},
|
||||
@@ -87,10 +92,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "weekly-across-midnight-previous-day-not-in-repeaton",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 4, 2, 22, 0, 0, 0, time.UTC), // Tuesday 22:00
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
Duration: valuer.MustParseTextDuration("4h"), // Until Wednesday 02:00
|
||||
StartTime: time.Date(2024, 4, 2, 22, 0, 0, 0, time.UTC), // Tuesday 22:00
|
||||
Duration: valuer.MustParseTextDuration("4h"), // Until Wednesday 02:00
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnTuesday}, // Only Tuesday
|
||||
},
|
||||
@@ -104,10 +109,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "daily-maintenance-across-midnight",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 1, 1, 23, 0, 0, 0, time.UTC), // 23:00
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
Duration: valuer.MustParseTextDuration("2h"), // Until 01:00 next day
|
||||
StartTime: time.Date(2024, 1, 1, 23, 0, 0, 0, time.UTC), // 23:00
|
||||
Duration: valuer.MustParseTextDuration("2h"), // Until 01:00 next day
|
||||
RepeatType: RepeatTypeDaily,
|
||||
},
|
||||
},
|
||||
@@ -120,9 +125,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "at-start-time-boundary",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeDaily,
|
||||
},
|
||||
@@ -136,9 +141,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "at-end-time-boundary",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeDaily,
|
||||
},
|
||||
@@ -152,9 +157,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "monthly-multi-day-duration",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 1, 28, 12, 0, 0, 0, time.UTC),
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 1, 28, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("72h"), // 3 days
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
@@ -168,9 +173,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "weekly-multi-day-duration",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 1, 28, 12, 0, 0, 0, time.UTC),
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 1, 28, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("72h"), // 3 days
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnSunday},
|
||||
@@ -185,9 +190,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "monthly-crosses-to-next-month",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 1, 30, 12, 0, 0, 0, time.UTC),
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 1, 30, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("48h"), // 2 days, crosses to Feb 1
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
@@ -201,9 +206,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "timezone-offset-test",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "America/New_York", // UTC-5 or UTC-4 depending on DST
|
||||
StartTime: time.Date(2024, 1, 1, 22, 0, 0, 0, time.FixedZone("America/New_York", -5*3600)),
|
||||
Timezone: "America/New_York", // UTC-5 or UTC-4 depending on DST
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 1, 1, 22, 0, 0, 0, time.FixedZone("America/New_York", -5*3600)),
|
||||
Duration: valuer.MustParseTextDuration("4h"),
|
||||
RepeatType: RepeatTypeDaily,
|
||||
},
|
||||
@@ -217,9 +222,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "daily-maintenance-time-outside-window",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeDaily,
|
||||
},
|
||||
@@ -233,10 +238,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "recurring-maintenance-with-past-end-date",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
EndTime: time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC),
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
EndTime: timePtr(time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC)),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeDaily,
|
||||
},
|
||||
@@ -250,10 +255,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "monthly-maintenance-spans-month-end",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 3, 31, 22, 0, 0, 0, time.UTC), // March 31, 22:00
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
Duration: valuer.MustParseTextDuration("6h"), // Until April 1, 04:00
|
||||
StartTime: time.Date(2024, 3, 31, 22, 0, 0, 0, time.UTC), // March 31, 22:00
|
||||
Duration: valuer.MustParseTextDuration("6h"), // Until April 1, 04:00
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
},
|
||||
@@ -266,9 +271,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "weekly-empty-repeaton",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{}, // Empty - should apply to all days
|
||||
@@ -283,9 +288,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "monthly-maintenance-february-fewer-days",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
@@ -298,9 +303,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "daily-maintenance-crosses-midnight",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 1, 1, 23, 30, 0, 0, time.UTC),
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 1, 1, 23, 30, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("1h"), // Crosses to 00:30 next day
|
||||
RepeatType: RepeatTypeDaily,
|
||||
},
|
||||
@@ -313,9 +318,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "monthly-maintenance-crosses-month-end",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
@@ -328,9 +333,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "monthly-maintenance-crosses-month-end-and-duration-is-2-days",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 1, 30, 12, 0, 0, 0, time.UTC),
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 1, 30, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("48h"), // 2 days duration
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
@@ -343,10 +348,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "weekly-maintenance-crosses-midnight",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 4, 1, 23, 0, 0, 0, time.UTC), // Monday 23:00
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
Duration: valuer.MustParseTextDuration("2h"), // Until Tuesday 01:00
|
||||
StartTime: time.Date(2024, 4, 1, 23, 0, 0, 0, time.UTC), // Monday 23:00
|
||||
Duration: valuer.MustParseTextDuration("2h"), // Until Tuesday 01:00
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday
|
||||
},
|
||||
@@ -359,9 +364,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "monthly-maintenance-crosses-month-end-and-duration-is-2-days",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
@@ -374,9 +379,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "daily-maintenance-crosses-midnight",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 1, 1, 22, 0, 0, 0, time.UTC),
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 1, 1, 22, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("4h"), // Until 02:00 next day
|
||||
RepeatType: RepeatTypeDaily,
|
||||
},
|
||||
@@ -389,9 +394,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "monthly-maintenance-crosses-month-end-and-duration-is-2-hours",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC),
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
@@ -440,9 +445,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "recurring maintenance, repeat sunday, saturday, weekly for 24 hours, in Us/Eastern timezone",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "US/Eastern",
|
||||
StartTime: time.Date(2025, 3, 29, 20, 0, 0, 0, time.FixedZone("US/Eastern", -4*3600)),
|
||||
Timezone: "US/Eastern",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2025, 3, 29, 20, 0, 0, 0, time.FixedZone("US/Eastern", -4*3600)),
|
||||
Duration: valuer.MustParseTextDuration("24h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnSunday, RepeatOnSaturday},
|
||||
@@ -456,9 +461,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "recurring maintenance, repeat daily from 12:00 to 14:00",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeDaily,
|
||||
},
|
||||
@@ -471,9 +476,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "recurring maintenance, repeat daily from 12:00 to 14:00",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeDaily,
|
||||
},
|
||||
@@ -486,182 +491,146 @@ func TestShouldSkipMaintenance(t *testing.T) {
|
||||
name: "recurring maintenance, repeat daily from 12:00 to 14:00",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeDaily,
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 4, 1, 12, 10, 0, 0, time.UTC),
|
||||
ts: time.Date(2024, 04, 1, 12, 10, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
|
||||
Recurrence: &Recurrence{
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 4, 15, 12, 10, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
|
||||
Recurrence: &Recurrence{
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 4, 14, 12, 10, 0, 0, time.UTC), // 14th 04 is sunday
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
|
||||
Recurrence: &Recurrence{
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 4, 16, 12, 10, 0, 0, time.UTC), // 16th 04 is tuesday
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
|
||||
Recurrence: &Recurrence{
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 5, 6, 12, 10, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
|
||||
Recurrence: &Recurrence{
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 5, 6, 14, 0, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "recurring maintenance, repeat monthly on 4th from 12:00 to 14:00",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
|
||||
Recurrence: &Recurrence{
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 4, 4, 12, 10, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "recurring maintenance, repeat monthly on 4th from 12:00 to 14:00",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
|
||||
Recurrence: &Recurrence{
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 4, 4, 14, 10, 0, 0, time.UTC),
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
name: "recurring maintenance, repeat monthly on 4th from 12:00 to 14:00",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
|
||||
Recurrence: &Recurrence{
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 5, 4, 12, 10, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
// The recurrence should govern, when set. Not the fixed range.
|
||||
{
|
||||
name: "recurring-daily-with-fixed-times-outside-daily-window",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
// These fixed fields should be ignored when Recurrence is set.
|
||||
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC), // daily at 14:00
|
||||
EndTime: time.Date(2026, 4, 30, 18, 0, 0, 0, time.UTC),
|
||||
Recurrence: &Recurrence{
|
||||
Duration: valuer.MustParseTextDuration("2h"), // until 16:00
|
||||
RepeatType: RepeatTypeDaily,
|
||||
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
// 2026-04-15 11:00 is inside the fixed range but outside the daily 14:00-16:00 window.
|
||||
ts: time.Date(2026, 4, 15, 11, 0, 0, 0, time.UTC),
|
||||
ts: time.Date(2024, 04, 15, 12, 10, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 04, 14, 12, 10, 0, 0, time.UTC), // 14th 04 is sunday
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
name: "recurring-daily-with-fixed-times-inside-daily-window",
|
||||
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC),
|
||||
EndTime: time.Date(2026, 4, 30, 18, 0, 0, 0, time.UTC),
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeDaily,
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
// 15:00 is inside the daily 14:00-16:00 window. Should skip.
|
||||
ts: time.Date(2026, 4, 15, 15, 0, 0, 0, time.UTC),
|
||||
ts: time.Date(2024, 04, 16, 12, 10, 0, 0, time.UTC), // 16th 04 is tuesday
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 05, 06, 12, 10, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 01, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeWeekly,
|
||||
RepeatOn: []RepeatOn{RepeatOnMonday},
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 05, 06, 14, 00, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "recurring maintenance, repeat monthly on 4th from 12:00 to 14:00",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 04, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 04, 04, 12, 10, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "recurring maintenance, repeat monthly on 4th from 12:00 to 14:00",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 04, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 04, 04, 14, 10, 0, 0, time.UTC),
|
||||
skip: false,
|
||||
},
|
||||
{
|
||||
name: "recurring maintenance, repeat monthly on 4th from 12:00 to 14:00",
|
||||
maintenance: &PlannedMaintenance{
|
||||
Schedule: &Schedule{
|
||||
Timezone: "UTC",
|
||||
Recurrence: &Recurrence{
|
||||
StartTime: time.Date(2024, 04, 04, 12, 0, 0, 0, time.UTC),
|
||||
Duration: valuer.MustParseTextDuration("2h"),
|
||||
RepeatType: RepeatTypeMonthly,
|
||||
},
|
||||
},
|
||||
},
|
||||
ts: time.Date(2024, 05, 04, 12, 10, 0, 0, time.UTC),
|
||||
skip: true,
|
||||
},
|
||||
}
|
||||
|
||||
for idx, c := range cases {
|
||||
result := c.maintenance.ShouldSkip(c.name, c.ts, model.LabelSet{})
|
||||
result := c.maintenance.ShouldSkip(c.name, c.ts)
|
||||
if result != c.skip {
|
||||
t.Errorf("skip %v, got %v, case:%d - %s", c.skip, result, idx, c.name)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package alertmanagertypes
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
@@ -67,6 +67,8 @@ var RepeatOnAllMap = map[RepeatOn]time.Weekday{
|
||||
}
|
||||
|
||||
type Recurrence struct {
|
||||
StartTime time.Time `json:"startTime" required:"true"`
|
||||
EndTime *time.Time `json:"endTime,omitempty"`
|
||||
Duration valuer.TextDuration `json:"duration" required:"true"`
|
||||
RepeatType RepeatType `json:"repeatType" required:"true"`
|
||||
RepeatOn []RepeatOn `json:"repeatOn"`
|
||||
@@ -103,16 +105,33 @@ func (s Schedule) MarshalJSON() ([]byte, error) {
|
||||
endTime = time.Date(s.EndTime.Year(), s.EndTime.Month(), s.EndTime.Day(), s.EndTime.Hour(), s.EndTime.Minute(), s.EndTime.Second(), s.EndTime.Nanosecond(), loc)
|
||||
}
|
||||
|
||||
var recurrence *Recurrence
|
||||
if s.Recurrence != nil {
|
||||
recStartTime := time.Date(s.Recurrence.StartTime.Year(), s.Recurrence.StartTime.Month(), s.Recurrence.StartTime.Day(), s.Recurrence.StartTime.Hour(), s.Recurrence.StartTime.Minute(), s.Recurrence.StartTime.Second(), s.Recurrence.StartTime.Nanosecond(), loc)
|
||||
var recEndTime *time.Time
|
||||
if s.Recurrence.EndTime != nil {
|
||||
end := time.Date(s.Recurrence.EndTime.Year(), s.Recurrence.EndTime.Month(), s.Recurrence.EndTime.Day(), s.Recurrence.EndTime.Hour(), s.Recurrence.EndTime.Minute(), s.Recurrence.EndTime.Second(), s.Recurrence.EndTime.Nanosecond(), loc)
|
||||
recEndTime = &end
|
||||
}
|
||||
recurrence = &Recurrence{
|
||||
StartTime: recStartTime,
|
||||
EndTime: recEndTime,
|
||||
Duration: s.Recurrence.Duration,
|
||||
RepeatType: s.Recurrence.RepeatType,
|
||||
RepeatOn: s.Recurrence.RepeatOn,
|
||||
}
|
||||
}
|
||||
|
||||
return json.Marshal(&struct {
|
||||
Timezone string `json:"timezone"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
EndTime time.Time `json:"endTime,omitzero"`
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
Recurrence *Recurrence `json:"recurrence,omitempty"`
|
||||
}{
|
||||
Timezone: s.Timezone,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
Recurrence: s.Recurrence,
|
||||
StartTime: startTime.Format(time.RFC3339),
|
||||
EndTime: endTime.Format(time.RFC3339),
|
||||
Recurrence: recurrence,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -147,11 +166,34 @@ func (s *Schedule) UnmarshalJSON(data []byte) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// TODO(jatinderjit): if endTime.IsZero() then we should not set the endTime
|
||||
s.EndTime = time.Date(endTime.Year(), endTime.Month(), endTime.Day(), endTime.Hour(), endTime.Minute(), endTime.Second(), endTime.Nanosecond(), loc)
|
||||
}
|
||||
|
||||
s.Timezone = aux.Timezone
|
||||
s.Recurrence = aux.Recurrence
|
||||
|
||||
if aux.Recurrence != nil {
|
||||
recStartTime, err := time.Parse(time.RFC3339, aux.Recurrence.StartTime.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var recEndTime *time.Time
|
||||
if aux.Recurrence.EndTime != nil {
|
||||
end, err := time.Parse(time.RFC3339, aux.Recurrence.EndTime.Format(time.RFC3339))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endConverted := time.Date(end.Year(), end.Month(), end.Day(), end.Hour(), end.Minute(), end.Second(), end.Nanosecond(), loc)
|
||||
recEndTime = &endConverted
|
||||
}
|
||||
|
||||
s.Recurrence = &Recurrence{
|
||||
StartTime: time.Date(recStartTime.Year(), recStartTime.Month(), recStartTime.Day(), recStartTime.Hour(), recStartTime.Minute(), recStartTime.Second(), recStartTime.Nanosecond(), loc),
|
||||
EndTime: recEndTime,
|
||||
Duration: aux.Recurrence.Duration,
|
||||
RepeatType: aux.Recurrence.RepeatType,
|
||||
RepeatOn: aux.Recurrence.RepeatOn,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package alertmanagertypes
|
||||
package ruletypes
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
|
||||
type Schedule struct {
|
||||
Timezone string `json:"timezone" required:"true"`
|
||||
StartTime time.Time `json:"startTime" required:"true"`
|
||||
EndTime time.Time `json:"endTime,omitzero"`
|
||||
StartTime time.Time `json:"startTime,omitempty"`
|
||||
EndTime time.Time `json:"endTime,omitempty"`
|
||||
Recurrence *Recurrence `json:"recurrence"`
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ pytest_plugins = [
|
||||
"fixtures.seeder",
|
||||
"fixtures.serviceaccount",
|
||||
"fixtures.role",
|
||||
"fixtures.seed_golden_dataset",
|
||||
]
|
||||
|
||||
|
||||
|
||||
13
tests/e2e/bootstrap/global.setup.ts
Normal file
13
tests/e2e/bootstrap/global.setup.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { expect, test as setup } from '@playwright/test';
|
||||
|
||||
const seederUrl = process.env.SIGNOZ_E2E_SEEDER_URL ?? '';
|
||||
|
||||
setup('refresh golden dataset', async ({ request }) => {
|
||||
expect(seederUrl, 'SIGNOZ_E2E_SEEDER_URL not set').not.toBe('');
|
||||
const response = await request.post(`${seederUrl}/seed/golden`, {
|
||||
timeout: 120_000,
|
||||
});
|
||||
expect(response.ok()).toBeTruthy();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[setup] refreshed golden dataset: ${await response.text()}`);
|
||||
});
|
||||
14
tests/e2e/bootstrap/global.teardown.ts
Normal file
14
tests/e2e/bootstrap/global.teardown.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { expect, test as teardown } from '@playwright/test';
|
||||
|
||||
const seederUrl = process.env.SIGNOZ_E2E_SEEDER_URL ?? '';
|
||||
|
||||
teardown('clear seeded telemetry', async ({ request }) => {
|
||||
expect(seederUrl, 'SIGNOZ_E2E_SEEDER_URL not set').not.toBe('');
|
||||
for (const signal of ['metrics', 'traces', 'logs'] as const) {
|
||||
const response = await request.delete(
|
||||
`${seederUrl}/telemetry/${signal}`,
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
expect(response.ok()).toBeTruthy();
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
@@ -38,6 +39,13 @@ def test_teardown(
|
||||
signoz: types.SigNoz, # pylint: disable=unused-argument
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
apply_license: types.Operation, # pylint: disable=unused-argument
|
||||
seeder: types.TestContainerDocker, # pylint: disable=unused-argument
|
||||
seeder: types.TestContainerDocker,
|
||||
) -> None:
|
||||
"""Fixture dependencies trigger container teardown via --teardown."""
|
||||
"""Truncate seeded telemetry; containers come down via fixture
|
||||
dependency under `--teardown`."""
|
||||
base = seeder.host_configs["8080"].base().rstrip("/")
|
||||
for signal in ("metrics", "traces", "logs"):
|
||||
try:
|
||||
requests.delete(f"{base}/telemetry/{signal}", timeout=30).raise_for_status()
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
print(f"seeder DELETE /telemetry/{signal} failed: {e}")
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import path from 'path';
|
||||
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
||||
import { expect, type APIRequestContext, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import apmMetricsTemplate from '../testdata/apm-metrics.json';
|
||||
import chartDataTemplate from '../testdata/chart-data-dashboard.json';
|
||||
import variablesTemplate from '../testdata/variables-dashboard.json';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -83,6 +85,211 @@ export async function createDashboardViaApi(
|
||||
return postDashboard(page, { title, uploadedGrafana: false });
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generic helper: POST a dashboard with the given title, then PUT the full
|
||||
* `data` payload (variables / widgets / layout / version) at
|
||||
* `/dashboards/<id>`. The two-step dance is required because POST silently
|
||||
* drops everything except `{title, uploadedGrafana, version}` — the SigNoz UI
|
||||
* itself uses the same pattern.
|
||||
*/
|
||||
async function loadDashboardFromTemplate(
|
||||
page: Page,
|
||||
title: string,
|
||||
template: Record<string, unknown>,
|
||||
): Promise<string> {
|
||||
const id = await postDashboard(page, { title, uploadedGrafana: false });
|
||||
const token = await authToken(page);
|
||||
const putRes = await page.request.put(`/api/v1/dashboards/${id}`, {
|
||||
data: { ...template, title },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!putRes.ok()) {
|
||||
throw new Error(
|
||||
`PUT /dashboards/${id} ${putRes.status()}: ${await putRes.text()}`,
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a dashboard exercising every variable type (TEXTBOX × 2, CUSTOM × 3,
|
||||
* QUERY × 2, DYNAMIC × 1) via the JSON fixture under
|
||||
* `tests/e2e/testdata/variables-dashboard.json`. Used by Group 3
|
||||
* (detail-variables) and Group 9 (detail-configure "lists existing
|
||||
* variables") tests. URL state keys variables by `name`, not `id`, so the
|
||||
* assertions look up `tb_env` / `cu_env_all` / etc. directly.
|
||||
*/
|
||||
export async function createVariablesDashboardViaApi(
|
||||
page: Page,
|
||||
title: string,
|
||||
): Promise<string> {
|
||||
return loadDashboardFromTemplate(
|
||||
page,
|
||||
title,
|
||||
variablesTemplate as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed APM Metrics directly via the API — much faster than driving the
|
||||
* Import-JSON UI flow. Use this for any test that just needs APM Metrics on
|
||||
* the canvas; reserve `importApmMetricsDashboardViaUI` for tests that
|
||||
* actually exercise the import flow itself.
|
||||
*/
|
||||
export async function createApmMetricsDashboardViaApi(
|
||||
page: Page,
|
||||
): Promise<string> {
|
||||
return loadDashboardFromTemplate(
|
||||
page,
|
||||
APM_METRICS_TITLE,
|
||||
apmMetricsTemplate as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a single-panel "E2E Metric RPS" dashboard that queries the
|
||||
* `signoz_e2e_metric` counter without any variable substitution. Pair with
|
||||
* `seedMetricsViaSeeder` to populate the metric, then assert chart-data
|
||||
* rendering. Title is fixed by the JSON fixture.
|
||||
*/
|
||||
export async function createChartDataDashboardViaApi(
|
||||
page: Page,
|
||||
): Promise<string> {
|
||||
return loadDashboardFromTemplate(
|
||||
page,
|
||||
(chartDataTemplate as { title: string }).title,
|
||||
chartDataTemplate as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Seeder API ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// The pytest harness brings up an HTTP seeder container exposing
|
||||
// POST/DELETE on /telemetry/{traces,logs,metrics}. Its URL is written to
|
||||
// `tests/e2e/.env.local` as `SIGNOZ_E2E_SEEDER_URL` and read here from the
|
||||
// process environment.
|
||||
|
||||
/** Minimal shape the seeder accepts for a single metric sample. */
|
||||
export interface SeederMetric {
|
||||
metric_name: string;
|
||||
labels: Record<string, string>;
|
||||
timestamp: string;
|
||||
value: number;
|
||||
temporality?: 'Cumulative' | 'Delta' | 'Unspecified';
|
||||
type_?: 'Sum' | 'Gauge' | 'Histogram' | 'Summary';
|
||||
is_monotonic?: boolean;
|
||||
description?: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
function seederUrl(): string {
|
||||
const url = process.env.SIGNOZ_E2E_SEEDER_URL;
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
'SIGNOZ_E2E_SEEDER_URL not set — pytest test_setup must be running.',
|
||||
);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST a batch of metrics into the seeder. The seeder writes them directly
|
||||
* into ClickHouse, bypassing the OTLP collector. Use this for tests that need
|
||||
* panel queries to return non-empty results.
|
||||
*/
|
||||
export async function seedMetricsViaSeeder(
|
||||
page: Page,
|
||||
metrics: SeederMetric[],
|
||||
): Promise<void> {
|
||||
const res = await page.request.post(`${seederUrl()}/telemetry/metrics`, {
|
||||
data: metrics,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`seeder POST /telemetry/metrics ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate the metrics tables in ClickHouse via the seeder. Use in
|
||||
* `afterAll` for tests that mutate global telemetry state — the bootstrap
|
||||
* stack is shared across specs, so leftover seeded rows could affect
|
||||
* neighbouring suites.
|
||||
*/
|
||||
export async function clearMetricsViaSeeder(page: Page): Promise<void> {
|
||||
await page.request.delete(`${seederUrl()}/telemetry/metrics`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for every variable in the persisted dashboard JSON to have a
|
||||
* "resolved" state — `selectedValue` populated, or `allSelected: true` for
|
||||
* showALLOption variables. This is the seam tests should cross before
|
||||
* acting: if a variable has a default in the seed, it's resolved immediately;
|
||||
* if it has no default (QUERY / DYNAMIC depending on backend resolution), the
|
||||
* UI's variable-select widget queries the backend, then writes the resolved
|
||||
* value back into the dashboard's variables map. Tests that share a dashboard
|
||||
* via `mode: 'serial'` must call this between tests so they don't race
|
||||
* against an in-flight resolve.
|
||||
*
|
||||
* Variables listed in `skipNames` are exempt — typically those that depend on
|
||||
* seeded telemetry the bootstrap stack does not produce (Dynamic; cascading
|
||||
* Query against an unresolved parent). Pass them so the wait does not block
|
||||
* indefinitely on values that can never appear.
|
||||
*/
|
||||
export async function awaitVariablesResolved(
|
||||
page: Page,
|
||||
dashboardId: string,
|
||||
options?: { skipNames?: string[]; timeout?: number },
|
||||
): Promise<void> {
|
||||
const skip = new Set(options?.skipNames ?? []);
|
||||
const timeout = options?.timeout ?? 15_000;
|
||||
const token = await authToken(page);
|
||||
|
||||
const isResolved = (v: Record<string, unknown>): boolean => {
|
||||
if (skip.has(String(v.name))) {
|
||||
return true;
|
||||
}
|
||||
if (v.allSelected === true) {
|
||||
return true;
|
||||
}
|
||||
const sv = v.selectedValue;
|
||||
if (sv === undefined || sv === null) {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(sv)) {
|
||||
return sv.length > 0;
|
||||
}
|
||||
return typeof sv === 'string' ? sv.length > 0 : sv !== null;
|
||||
};
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await page.request.get(
|
||||
`/api/v1/dashboards/${dashboardId}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (!res.ok()) {
|
||||
return false;
|
||||
}
|
||||
const body = (await res.json()) as {
|
||||
data?: { data?: { variables?: Record<string, Record<string, unknown>> } };
|
||||
};
|
||||
const vars = body?.data?.data?.variables ?? {};
|
||||
return Object.values(vars).every(isResolved);
|
||||
},
|
||||
{
|
||||
timeout,
|
||||
message:
|
||||
'awaitVariablesResolved: dashboard.variables[*].selectedValue did not stabilise — pass `skipNames` for variables that require seeded telemetry',
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the APM Metrics dashboard by driving the real "Import JSON" UI flow:
|
||||
* opens the New-dashboard dropdown, picks Import JSON, uploads the fixture
|
||||
|
||||
@@ -50,12 +50,36 @@ export default defineConfig({
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
|
||||
// Browser projects. No project-level auth — specs opt in via the
|
||||
// authedPage fixture in tests/e2e/fixtures/auth.ts, which logs a user
|
||||
// in on first use and caches the resulting storageState per worker.
|
||||
// `setup` runs `bootstrap/global.setup.ts` once before any browser
|
||||
// project — refreshes the golden dataset so chart-data assertions
|
||||
// land inside default panel time windows. Per
|
||||
// https://playwright.dev/docs/test-global-setup-teardown#option-1-project-dependencies.
|
||||
projects: [
|
||||
{ name: 'chromium', use: devices['Desktop Chrome'] },
|
||||
{ name: 'firefox', use: devices['Desktop Firefox'] },
|
||||
{ name: 'webkit', use: devices['Desktop Safari'] },
|
||||
{
|
||||
name: 'setup',
|
||||
testDir: './bootstrap',
|
||||
testMatch: /global\.setup\.ts/,
|
||||
teardown: 'teardown',
|
||||
},
|
||||
{
|
||||
name: 'teardown',
|
||||
testDir: './bootstrap',
|
||||
testMatch: /global\.teardown\.ts/,
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
use: devices['Desktop Chrome'],
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: devices['Desktop Firefox'],
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: devices['Desktop Safari'],
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
84
tests/e2e/testdata/chart-data-dashboard.json
vendored
Normal file
84
tests/e2e/testdata/chart-data-dashboard.json
vendored
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"title": "detail-chart-data-suite",
|
||||
"description": "Single Time Series panel querying `signoz_calls_total` (in the bootstrap golden seed) with no variable substitution. Used by chart-data assertion tests to verify the panel renders data without inline seeding.",
|
||||
"tags": [],
|
||||
"widgets": [
|
||||
{
|
||||
"bucketCount": 30,
|
||||
"bucketWidth": 0,
|
||||
"columnUnits": {},
|
||||
"description": "",
|
||||
"fillSpans": false,
|
||||
"id": "11111111-1111-4111-8111-111111111111",
|
||||
"isStacked": false,
|
||||
"mergeAllActiveQueries": false,
|
||||
"nullZeroValues": "zero",
|
||||
"opacity": "1",
|
||||
"panelTypes": "graph",
|
||||
"query": {
|
||||
"builder": {
|
||||
"queryData": [
|
||||
{
|
||||
"aggregateAttribute": {
|
||||
"dataType": "float64",
|
||||
"id": "signoz_calls_total--float64--Sum--true",
|
||||
"isColumn": true,
|
||||
"isJSON": false,
|
||||
"key": "signoz_calls_total",
|
||||
"type": "Sum"
|
||||
},
|
||||
"aggregateOperator": "sum_rate",
|
||||
"dataSource": "metrics",
|
||||
"disabled": false,
|
||||
"expression": "A",
|
||||
"filters": { "items": [], "op": "AND" },
|
||||
"functions": [],
|
||||
"groupBy": [],
|
||||
"having": [],
|
||||
"legend": "rps",
|
||||
"limit": null,
|
||||
"orderBy": [],
|
||||
"queryName": "A",
|
||||
"reduceTo": "avg",
|
||||
"spaceAggregation": "sum",
|
||||
"stepInterval": 60,
|
||||
"timeAggregation": "rate"
|
||||
}
|
||||
],
|
||||
"queryFormulas": []
|
||||
},
|
||||
"clickhouse_sql": [
|
||||
{ "disabled": false, "legend": "", "name": "A", "query": "" }
|
||||
],
|
||||
"id": "22222222-2222-4222-8222-222222222222",
|
||||
"promql": [
|
||||
{ "disabled": false, "legend": "", "name": "A", "query": "" }
|
||||
],
|
||||
"queryType": "builder"
|
||||
},
|
||||
"selectedLogFields": [
|
||||
{ "dataType": "string", "name": "body", "type": "" },
|
||||
{ "dataType": "string", "name": "timestamp", "type": "" }
|
||||
],
|
||||
"selectedTracesFields": [],
|
||||
"softMax": null,
|
||||
"softMin": null,
|
||||
"stackedBarChart": false,
|
||||
"thresholds": [],
|
||||
"timePreferance": "GLOBAL_TIME",
|
||||
"title": "E2E Metric RPS",
|
||||
"yAxisUnit": "none"
|
||||
}
|
||||
],
|
||||
"layout": [
|
||||
{
|
||||
"i": "11111111-1111-4111-8111-111111111111",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 12,
|
||||
"h": 6
|
||||
}
|
||||
],
|
||||
"variables": {},
|
||||
"version": "v4"
|
||||
}
|
||||
136
tests/e2e/testdata/variables-dashboard.json
vendored
Normal file
136
tests/e2e/testdata/variables-dashboard.json
vendored
Normal file
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"title": "detail-variables-suite",
|
||||
"description": "Seed dashboard exercising every variable type — used by detail-variables and detail-configure specs.",
|
||||
"tags": [],
|
||||
"layout": [],
|
||||
"widgets": [],
|
||||
"version": "v4",
|
||||
"variables": {
|
||||
"00000000-0000-4000-8000-000000000001": {
|
||||
"id": "00000000-0000-4000-8000-000000000001",
|
||||
"name": "tb_env",
|
||||
"order": 0,
|
||||
"type": "TEXTBOX",
|
||||
"description": "",
|
||||
"textboxValue": "otel-demo",
|
||||
"selectedValue": "otel-demo",
|
||||
"customValue": "",
|
||||
"queryValue": "",
|
||||
"multiSelect": false,
|
||||
"showALLOption": false,
|
||||
"allSelected": false,
|
||||
"sort": "DISABLED",
|
||||
"modificationUUID": "00000000-0000-4000-8000-000000000101"
|
||||
},
|
||||
"00000000-0000-4000-8000-000000000002": {
|
||||
"id": "00000000-0000-4000-8000-000000000002",
|
||||
"name": "tb_service",
|
||||
"order": 1,
|
||||
"type": "TEXTBOX",
|
||||
"description": "",
|
||||
"textboxValue": "frontend",
|
||||
"selectedValue": "frontend",
|
||||
"customValue": "",
|
||||
"queryValue": "",
|
||||
"multiSelect": false,
|
||||
"showALLOption": false,
|
||||
"allSelected": false,
|
||||
"sort": "DISABLED",
|
||||
"modificationUUID": "00000000-0000-4000-8000-000000000102"
|
||||
},
|
||||
"00000000-0000-4000-8000-000000000003": {
|
||||
"id": "00000000-0000-4000-8000-000000000003",
|
||||
"name": "cu_single",
|
||||
"order": 2,
|
||||
"type": "CUSTOM",
|
||||
"description": "",
|
||||
"textboxValue": "",
|
||||
"selectedValue": "otel-demo",
|
||||
"customValue": "otel-demo,mq-kafka,production",
|
||||
"queryValue": "",
|
||||
"multiSelect": false,
|
||||
"showALLOption": false,
|
||||
"allSelected": false,
|
||||
"sort": "DISABLED",
|
||||
"modificationUUID": "00000000-0000-4000-8000-000000000103"
|
||||
},
|
||||
"00000000-0000-4000-8000-000000000004": {
|
||||
"id": "00000000-0000-4000-8000-000000000004",
|
||||
"name": "cu_env_all",
|
||||
"order": 3,
|
||||
"type": "CUSTOM",
|
||||
"description": "",
|
||||
"textboxValue": "",
|
||||
"customValue": "otel-demo,mq-kafka,production",
|
||||
"queryValue": "",
|
||||
"multiSelect": true,
|
||||
"showALLOption": true,
|
||||
"allSelected": true,
|
||||
"sort": "DISABLED",
|
||||
"modificationUUID": "00000000-0000-4000-8000-000000000104"
|
||||
},
|
||||
"00000000-0000-4000-8000-000000000005": {
|
||||
"id": "00000000-0000-4000-8000-000000000005",
|
||||
"name": "cu_services",
|
||||
"order": 4,
|
||||
"type": "CUSTOM",
|
||||
"description": "",
|
||||
"textboxValue": "",
|
||||
"selectedValue": ["adservice", "cartservice"],
|
||||
"customValue": "adservice,cartservice,frontend",
|
||||
"queryValue": "",
|
||||
"multiSelect": true,
|
||||
"showALLOption": false,
|
||||
"allSelected": false,
|
||||
"sort": "DISABLED",
|
||||
"modificationUUID": "00000000-0000-4000-8000-000000000105"
|
||||
},
|
||||
"00000000-0000-4000-8000-000000000006": {
|
||||
"id": "00000000-0000-4000-8000-000000000006",
|
||||
"name": "q_env",
|
||||
"order": 5,
|
||||
"type": "QUERY",
|
||||
"description": "",
|
||||
"textboxValue": "",
|
||||
"customValue": "",
|
||||
"queryValue": "SELECT DISTINCT JSONExtractString(labels, 'deployment.environment') AS `deployment.environment` FROM signoz_metrics.time_series_v4_1day GROUP BY `deployment.environment`",
|
||||
"multiSelect": false,
|
||||
"showALLOption": false,
|
||||
"allSelected": false,
|
||||
"sort": "DISABLED",
|
||||
"modificationUUID": "00000000-0000-4000-8000-000000000106"
|
||||
},
|
||||
"00000000-0000-4000-8000-000000000007": {
|
||||
"id": "00000000-0000-4000-8000-000000000007",
|
||||
"name": "q_service",
|
||||
"order": 6,
|
||||
"type": "QUERY",
|
||||
"description": "",
|
||||
"textboxValue": "",
|
||||
"customValue": "",
|
||||
"queryValue": "SELECT DISTINCT JSONExtractString(labels, 'service.name') AS `service.name` FROM signoz_metrics.time_series_v4_1day WHERE deployment_environment = $q_env GROUP BY `service.name`",
|
||||
"multiSelect": true,
|
||||
"showALLOption": false,
|
||||
"allSelected": false,
|
||||
"sort": "DISABLED",
|
||||
"modificationUUID": "00000000-0000-4000-8000-000000000107"
|
||||
},
|
||||
"00000000-0000-4000-8000-000000000008": {
|
||||
"id": "00000000-0000-4000-8000-000000000008",
|
||||
"name": "d_namespace",
|
||||
"order": 7,
|
||||
"type": "DYNAMIC",
|
||||
"description": "",
|
||||
"textboxValue": "",
|
||||
"customValue": "",
|
||||
"queryValue": "",
|
||||
"multiSelect": false,
|
||||
"showALLOption": false,
|
||||
"allSelected": false,
|
||||
"sort": "DISABLED",
|
||||
"dynamicVariablesAttribute": "k8s.namespace.name",
|
||||
"dynamicVariablesSource": "metrics",
|
||||
"modificationUUID": "00000000-0000-4000-8000-000000000108"
|
||||
}
|
||||
}
|
||||
}
|
||||
214
tests/e2e/tests/dashboards/details/03-viewing.spec.ts
Normal file
214
tests/e2e/tests/dashboards/details/03-viewing.spec.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
APM_METRICS_TITLE,
|
||||
authToken,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
createApmMetricsDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
const BASE_TITLE = 'detail-viewing-base';
|
||||
let baseDashboardId = '';
|
||||
let apmDashboardId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
baseDashboardId = await createDashboardViaApi(page, BASE_TITLE);
|
||||
seedIds.add(baseDashboardId);
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmDashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function gotoDetail(page: Page, id: string): Promise<void> {
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail Page — Viewing', () => {
|
||||
test('TC-01 page chrome — breadcrumb, title, toolbar buttons render', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Use the APM dashboard rather than the empty base — empty dashboards
|
||||
// render an onboarding canvas with its own Configure / New Panel
|
||||
// buttons, which duplicate the toolbar testids.
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Dashboard \// }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
await expect(page).toHaveTitle(new RegExp(APM_METRICS_TITLE));
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: /Last \d+/ }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'sync' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'caret-down' }),
|
||||
).toBeVisible();
|
||||
// `lock-unlock-dashboard` lives inside the Settings popover, not the
|
||||
// toolbar itself — the popover trigger is the `options` button below.
|
||||
await expect(page.getByTestId('options')).toBeVisible();
|
||||
await expect(page.getByTestId('show-drawer')).toBeVisible();
|
||||
await expect(page.getByTestId('add-panel-header')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Feedback' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 breadcrumb returns to /dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, baseDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Dashboard \// }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Dashboard /' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 tags bar renders for an imported dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// `exact: true` is load-bearing — `apm` is a substring of the
|
||||
// breadcrumb title `APM Metrics`, so a loose match would collide.
|
||||
for (const tag of ['apm', 'latency', 'error rate', 'throughput']) {
|
||||
await expect(page.getByText(tag, { exact: true })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-04 section row headers render for APM Metrics', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
|
||||
// known behaviour: APM Metrics fixture has two sections both named
|
||||
// "Overview" — `.first()` deliberately matches whichever renders first.
|
||||
await expect(
|
||||
page.getByText('Overview', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('DB Metrics', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('External calls', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 at least one panel container renders', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-06 no JS pageerror during initial load', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const errors: Error[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err));
|
||||
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ─── Cross-spec: connection with the dashboards-list page ────────────────
|
||||
|
||||
test('TC-07 navigating from the dashboards list lands on the detail page', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// Mirror what the list spec validates — but as the entry path into the
|
||||
// detail page. The two suites share `apm-metrics.json` and the same
|
||||
// helpers, so a green TC here proves the seam between list and detail
|
||||
// is intact.
|
||||
await page.goto('/dashboard');
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
|
||||
// Filter to the seeded APM dashboard via the list search and use the
|
||||
// row action menu's View button — the documented navigation path the
|
||||
// list spec exercises.
|
||||
await page
|
||||
.getByPlaceholder('Search by name, description, or tags...')
|
||||
.fill(APM_METRICS_TITLE);
|
||||
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
|
||||
const actionIcon = page.getByTestId('dashboard-action-icon').first();
|
||||
await actionIcon.scrollIntoViewIfNeeded();
|
||||
await actionIcon.click();
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'View' })
|
||||
.click();
|
||||
|
||||
// Land on the detail page — breadcrumb and at least one panel render.
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
441
tests/e2e/tests/dashboards/details/12-sections.spec.ts
Normal file
441
tests/e2e/tests/dashboards/details/12-sections.spec.ts
Normal file
@@ -0,0 +1,441 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
// Tests in this file mutate section state on a single APM Metrics seed
|
||||
// (collapse / rename / add / remove). Run them serially within the worker so
|
||||
// state from one test does not leak into the next.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// ─── Suite-level seed registry ───────────────────────────────────────────
|
||||
//
|
||||
// One APM Metrics dashboard powers every TC in this file (4 sections, 16
|
||||
// panels — including the duplicate-named "Overview" sections, which the
|
||||
// fixture intentionally ships). A single `afterAll` deletes every dashboard
|
||||
// the suite touched.
|
||||
const seedIds = new Set<string>();
|
||||
let apmDashboardId: string;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmDashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Resolve the `.row-panel` container for a section by traversing up from its
|
||||
* title text. The fixture ships two sections both literally named "Overview"
|
||||
* — pass `index` to disambiguate. Two `..` hops reach `.row-panel`, which
|
||||
* holds both the chevron and the settings-icon for that row.
|
||||
*/
|
||||
function sectionRow(
|
||||
page: Page,
|
||||
name: string | RegExp,
|
||||
index = 0,
|
||||
): ReturnType<Page['locator']> {
|
||||
return page
|
||||
.getByText(name, { exact: typeof name === 'string' })
|
||||
.nth(index)
|
||||
.locator('..')
|
||||
.locator('..');
|
||||
}
|
||||
|
||||
async function gotoApmDashboard(page: Page): Promise<void> {
|
||||
await page.goto(`/dashboard/${apmDashboardId}`);
|
||||
await page
|
||||
.getByRole('button', { name: /dashboard-icon APM Metrics/ })
|
||||
.waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Sections', () => {
|
||||
// ─── Collapse / expand chevron and widget-count suffix ───────────────────
|
||||
|
||||
test('TC-01 collapsing a section hides panels and shows widget count', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
await sectionRow(page, 'DB Metrics').locator('.lucide-chevron-up').click();
|
||||
|
||||
// After collapse the section title is rewritten to include the count
|
||||
// suffix; assert with a regex so the test is robust to widget-count
|
||||
// drift in the fixture.
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore: chevron-down is the row-icon variant rendered for collapsed
|
||||
// sections. Re-resolve via the new (suffixed) title.
|
||||
await sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/)
|
||||
.locator('.lucide-chevron-down.row-icon')
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-02 widget count matches number of panels visible before collapse', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// The first Overview section in the APM fixture holds these four
|
||||
// panels — they're our ground truth for the count assertion below.
|
||||
await expect(page.getByText('Latency', { exact: true }).first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Request rate', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Error percentage', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Top operations', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await sectionRow(page, 'Overview', 0).locator('.lucide-chevron-up').click();
|
||||
|
||||
await expect(
|
||||
page.getByText('Overview (4 widgets)', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore.
|
||||
await sectionRow(page, 'Overview (4 widgets)')
|
||||
.locator('.lucide-chevron-down.row-icon')
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText('Overview (4 widgets)', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-03 expanding restores panels', async ({ authedPage: page }) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// Collapse "DB Metrics" instead of the first Overview — its widgets
|
||||
// have unique titles ("DB Calls RPS" / "Database Calls Avg Duration")
|
||||
// so collapse/expand transitions can be asserted without colliding
|
||||
// with the duplicate-titled panels in the two Overview sections.
|
||||
// "DB Metrics" lives further down the canvas; scroll into view first
|
||||
// so the panels actually mount (the canvas virtualises off-screen).
|
||||
const dbCalls = page.getByText('DB Calls RPS', { exact: true }).first();
|
||||
await dbCalls.scrollIntoViewIfNeeded();
|
||||
await expect(dbCalls).toBeVisible({ timeout: 15_000 });
|
||||
await sectionRow(page, 'DB Metrics').locator('.lucide-chevron-up').click();
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// While collapsed, "DB Calls RPS" should fully unmount.
|
||||
await expect(page.getByText('DB Calls RPS', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
await sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/)
|
||||
.locator('.lucide-chevron-down.row-icon')
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByText('DB Calls RPS', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ─── Section options menu (Rename / New Panel / Remove Section) ──────────
|
||||
|
||||
test('TC-04 section options menu shows Rename / New Panel / Remove Section', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// Use DB Metrics — its settings popover is guaranteed to render all
|
||||
// three buttons when the section is expanded. WidgetRow.tsx hides
|
||||
// "Remove Section" while a section is collapsed.
|
||||
await sectionRow(page, 'DB Metrics').locator('.settings-icon').click();
|
||||
|
||||
const tooltip = page.getByRole('tooltip');
|
||||
await expect(tooltip).toBeVisible();
|
||||
await expect(tooltip.getByRole('button', { name: 'Rename' })).toBeVisible();
|
||||
await expect(
|
||||
tooltip.getByRole('button', { name: 'New Panel', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
tooltip.getByRole('button', { name: 'Remove Section' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('TC-05 rename a section, restore original name', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
const renamed = `Renamed Section ${Date.now()}`;
|
||||
|
||||
// DB Metrics has a unique name, avoiding the duplicate-Overview snag.
|
||||
await sectionRow(page, 'DB Metrics').locator('.settings-icon').click();
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Rename' })
|
||||
.click();
|
||||
|
||||
const renameDialog = page.getByRole('dialog', { name: 'Rename Section' });
|
||||
await expect(renameDialog).toBeVisible();
|
||||
const nameInput = renameDialog.getByPlaceholder('Enter row name here...');
|
||||
await nameInput.click();
|
||||
await nameInput.fill(renamed);
|
||||
await renameDialog.getByRole('button', { name: 'Apply Changes' }).click();
|
||||
await expect(renameDialog).not.toBeVisible();
|
||||
|
||||
await expect(page.getByText(renamed, { exact: true }).first()).toBeVisible();
|
||||
|
||||
// Restore.
|
||||
await sectionRow(page, renamed).locator('.settings-icon').click();
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Rename' })
|
||||
.click();
|
||||
const restoreDialog = page.getByRole('dialog', { name: 'Rename Section' });
|
||||
const restoreInput = restoreDialog.getByPlaceholder(
|
||||
'Enter row name here...',
|
||||
);
|
||||
await restoreInput.click();
|
||||
await restoreInput.fill('DB Metrics');
|
||||
await restoreDialog.getByRole('button', { name: 'Apply Changes' }).click();
|
||||
await expect(restoreDialog).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText('DB Metrics', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(renamed, { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-06 cancel section rename leaves name unchanged', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
await sectionRow(page, 'External calls').locator('.settings-icon').click();
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Rename' })
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Rename Section' });
|
||||
await expect(dialog).toBeVisible();
|
||||
const input = dialog.getByPlaceholder('Enter row name here...');
|
||||
await input.click();
|
||||
await input.fill('Should Not Be Applied');
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText('External calls', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Should Not Be Applied')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-07 add a new panel to a section, then delete it', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
const panelName = `Test Panel ${Date.now()}`;
|
||||
|
||||
await sectionRow(page, 'DB Metrics').locator('.settings-icon').click();
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'New Panel', exact: true })
|
||||
.click();
|
||||
|
||||
const panelTypeDialog = page.getByRole('dialog', { name: 'New Panel' });
|
||||
await expect(panelTypeDialog).toBeVisible();
|
||||
await panelTypeDialog.getByTestId('panel-type-graph').click();
|
||||
|
||||
// We're now in the panel editor at /dashboard/:id/new?widgetId=…
|
||||
await page.waitForURL(/\/new/);
|
||||
await expect(page.getByTestId('new-widget-save')).toBeVisible();
|
||||
|
||||
await page.getByTestId('panel-name-input').fill(panelName);
|
||||
|
||||
await page.getByTestId('new-widget-save').click();
|
||||
const saveDialog = page.getByRole('dialog', { name: 'Save Widget' });
|
||||
await expect(saveDialog).toBeVisible();
|
||||
|
||||
// PUT confirms the panel persisted server-side — more reliable than
|
||||
// waiting on redux state to propagate before navigating back.
|
||||
const putResponse = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await saveDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await putResponse;
|
||||
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(page.getByText(panelName, { exact: true }).first()).toBeVisible();
|
||||
|
||||
|
||||
// Cleanup: open the new panel's ⋮ menu and delete via the confirm
|
||||
// dialog. The PUT-on-OK pattern again ensures the canvas has settled
|
||||
// before the test ends.
|
||||
const panelTitle = page.getByText(panelName, { exact: true }).first();
|
||||
await panelTitle.hover();
|
||||
const panelContainer = panelTitle.locator('../..');
|
||||
await panelContainer.getByTestId('widget-header-options').click();
|
||||
await page.getByRole('menuitem', { name: 'delete Delete' }).click();
|
||||
|
||||
const deleteDialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await expect(deleteDialog).toBeVisible();
|
||||
|
||||
const deletePut = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await deleteDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await deletePut;
|
||||
await expect(deleteDialog).not.toBeVisible();
|
||||
await expect(page.getByText(panelName, { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ─── New section in edit mode ────────────────────────────────────────────
|
||||
|
||||
test('TC-08 add a new section via edit mode, then remove it', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
const sectionName = `Temp Section ${Date.now()}`;
|
||||
|
||||
// Enter edit mode via the toolbar options popup. The "New section"
|
||||
// button only appears once edit mode is unlocked.
|
||||
await page.getByTestId('options').click();
|
||||
await page.getByRole('button', { name: 'New section' }).click();
|
||||
|
||||
const newSectionDialog = page.getByRole('dialog', { name: 'New Section' });
|
||||
await expect(newSectionDialog).toBeVisible();
|
||||
await newSectionDialog.getByTestId('section-name').fill(sectionName);
|
||||
await newSectionDialog
|
||||
.getByRole('button', { name: 'Create Section' })
|
||||
.click();
|
||||
await expect(newSectionDialog).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(sectionName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Remove the section via its options menu. "Delete Row" is the
|
||||
// admin-only confirm dialog; verify the title before clicking OK so
|
||||
// the test fails loudly if the dialog name regresses.
|
||||
await sectionRow(page, sectionName).locator('.settings-icon').click();
|
||||
await page
|
||||
.getByRole('tooltip')
|
||||
.getByRole('button', { name: 'Remove Section' })
|
||||
.click();
|
||||
|
||||
const deleteRowDialog = page.getByRole('dialog', { name: 'Delete Row' });
|
||||
await expect(deleteRowDialog).toBeVisible();
|
||||
await deleteRowDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(deleteRowDialog).not.toBeVisible();
|
||||
|
||||
await expect(page.getByText(sectionName, { exact: true })).toHaveCount(0);
|
||||
|
||||
// Original sections are untouched.
|
||||
await expect(
|
||||
page.getByText('Overview', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('DB Metrics', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('External calls', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-09 collapsing two sections in sequence shows both as collapsed', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
await sectionRow(page, 'DB Metrics').locator('.lucide-chevron-up').click();
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
await sectionRow(page, 'External calls')
|
||||
.locator('.lucide-chevron-up')
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText(/^External calls \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore both so the test leaves no state behind.
|
||||
await sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/)
|
||||
.locator('.lucide-chevron-down.row-icon')
|
||||
.click();
|
||||
await sectionRow(page, /^External calls \(\d+ widgets?\)$/)
|
||||
.locator('.lucide-chevron-down.row-icon')
|
||||
.click();
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText(/^External calls \(\d+ widgets?\)$/),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-10 panels inside a collapsed section are not in the DOM', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoApmDashboard(page);
|
||||
|
||||
// "DB Calls RPS" is a unique panel inside the "DB Metrics" section.
|
||||
const dbPanel = page.getByText('DB Calls RPS', { exact: true });
|
||||
await dbPanel.first().scrollIntoViewIfNeeded();
|
||||
await expect(dbPanel.first()).toBeVisible();
|
||||
|
||||
await sectionRow(page, 'DB Metrics').locator('.lucide-chevron-up').click();
|
||||
await expect(
|
||||
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Panels inside the collapsed section unmount, not just hidden.
|
||||
await expect(dbPanel).toHaveCount(0);
|
||||
|
||||
// Restore.
|
||||
await sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/)
|
||||
.locator('.lucide-chevron-down.row-icon')
|
||||
.click();
|
||||
await expect(dbPanel.first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
600
tests/e2e/tests/dashboards/details/21-panel-actions.spec.ts
Normal file
600
tests/e2e/tests/dashboards/details/21-panel-actions.spec.ts
Normal file
@@ -0,0 +1,600 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
createChartDataDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
// Tests in this file mutate the same dashboard (clone / delete panels). Run
|
||||
// them serially within the worker so state from one test does not leak into
|
||||
// another's assertions.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let apmDashboardId = '';
|
||||
|
||||
const TIME_SERIES_PANEL = 'Latency';
|
||||
const TABLE_PANEL = 'Top operations';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmDashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function gotoDetail(page: Page, id: string): Promise<void> {
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the panel container (`.widget-graph-component-container`) for the
|
||||
* panel with the given title. The title is exposed via `data-testid={title}`
|
||||
* on the inner `Typography.Text` — traverse upward to the container so we
|
||||
* can scope the ⋮ icon, search icon, etc. to this panel only.
|
||||
*
|
||||
* Multiple panels with the same title (e.g. cloned `Latency` panels) are
|
||||
* disambiguated by `index`, defaulting to the first match in DOM order.
|
||||
*/
|
||||
function panelContainer(page: Page, title: string, index = 0): Locator {
|
||||
return page
|
||||
.getByTestId(title)
|
||||
.nth(index)
|
||||
.locator(
|
||||
'xpath=ancestor::div[contains(@class, "widget-graph-component-container")][1]',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover the panel header (the ⋮ icon is CSS-hidden until the row is hovered)
|
||||
* and open the action dropdown. Returns the opened menu locator.
|
||||
*
|
||||
* The antd `<Dropdown>` wrapping the ⋮ icon uses `trigger={['hover']}` (see
|
||||
* `WidgetHeader/index.tsx`), so the menu opens on hover, not click —
|
||||
* dispatching a click is a no-op. We hover the container first to reveal the
|
||||
* icon (it's CSS-hidden until then) and then hover the icon itself to fire
|
||||
* the antd Dropdown's mouseenter handler.
|
||||
*/
|
||||
async function openPanelMoreMenu(
|
||||
page: Page,
|
||||
title: string,
|
||||
index = 0,
|
||||
): Promise<Locator> {
|
||||
const container = panelContainer(page, title, index);
|
||||
await container.scrollIntoViewIfNeeded();
|
||||
await container.hover();
|
||||
const moreOptions = container.getByTestId('widget-header-options');
|
||||
await moreOptions.hover();
|
||||
const menu = page.getByRole('menu');
|
||||
await menu.waitFor({ state: 'visible' });
|
||||
return menu;
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail Page — Panel Actions', () => {
|
||||
// ─── ⋮ menu contents ─────────────────────────────────────────────────────
|
||||
|
||||
test('TC-01 panel ⋮ menu shows the 5 actions for a Time Series panel', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
|
||||
// Time Series headerMenuList = ViewMenuAction + EditMenuAction
|
||||
// = [View, Clone, Delete, Edit, CreateAlerts]. Download is hidden
|
||||
// because panelTypes !== TABLE.
|
||||
await expect(menu.getByRole('menuitem')).toHaveCount(5);
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'fullscreen View' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'edit Edit' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'copy Clone' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: 'delete Delete' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: /Create Alerts/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('TC-02 Table panel ⋮ menu replaces Create Alerts with Download as CSV', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TABLE_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TABLE_PANEL);
|
||||
|
||||
// Table panels filter CreateAlerts out of the menu (see GridCard
|
||||
// `menuList`) and the Download item turns visible because
|
||||
// panelTypes === TABLE.
|
||||
await expect(
|
||||
menu.getByRole('menuitem', {
|
||||
name: 'cloud-download Download as CSV',
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole('menuitem', { name: /Create Alerts/ }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
// ─── View / Fullscreen ───────────────────────────────────────────────────
|
||||
|
||||
test('TC-03 View action opens fullscreen with `expandedWidgetId` URL param', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const viewItem = menu.getByRole('menuitem', { name: 'fullscreen View' });
|
||||
// The View menuitem is `disabled: queryResponse.isFetching` — wait
|
||||
// for it to become enabled before clicking, otherwise the click is a
|
||||
// no-op and the dialog never opens.
|
||||
await expect(viewItem).toBeEnabled();
|
||||
await viewItem.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: TIME_SERIES_PANEL });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page).toHaveURL(/expandedWidgetId=/);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(page).not.toHaveURL(/expandedWidgetId=/);
|
||||
});
|
||||
|
||||
test('TC-04 fullscreen panel renders chart canvas or "No Data"', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const viewItem = menu.getByRole('menuitem', { name: 'fullscreen View' });
|
||||
await expect(viewItem).toBeEnabled();
|
||||
await viewItem.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: TIME_SERIES_PANEL });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// known behaviour: the bootstrap stack ingests no telemetry, so a
|
||||
// fully-rendered chart and a "No Data" empty state are both valid
|
||||
// terminal states. Both can also coexist (the chart canvas mounts
|
||||
// before the empty-state overlay paints), so assert that at least
|
||||
// one of the two is reachable rather than using `.or().toBeVisible()`
|
||||
// — that combination triggers strict-mode violations when both
|
||||
// matches resolve.
|
||||
const canvas = dialog.locator('canvas');
|
||||
const noData = dialog.getByText(/no data/i);
|
||||
await expect
|
||||
.poll(
|
||||
async () => (await canvas.count()) + (await noData.count()),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Table search ────────────────────────────────────────────────────────
|
||||
|
||||
test('TC-05 Table panel search icon reveals search input', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TABLE_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const container = panelContainer(page, TABLE_PANEL);
|
||||
await container.scrollIntoViewIfNeeded();
|
||||
await container.hover();
|
||||
|
||||
// The search icon is hover-revealed; click it to swap the title row
|
||||
// out for the search input.
|
||||
const searchIcon = container.getByTestId('widget-header-search');
|
||||
await searchIcon.click();
|
||||
|
||||
// When `showGlobalSearch` is true, the WidgetHeader unmounts the
|
||||
// Typography.Text that carries the title's `data-testid`, so the
|
||||
// `panelContainer` ancestor chain no longer resolves. Look up the
|
||||
// search input by its testid directly — only one search input is
|
||||
// ever open at a time on a dashboard.
|
||||
const searchInput = page.getByTestId('widget-header-search-input');
|
||||
await expect(searchInput).toBeVisible();
|
||||
await searchInput.fill('test');
|
||||
await expect(searchInput).toHaveValue('test');
|
||||
});
|
||||
|
||||
// ─── Download as CSV ─────────────────────────────────────────────────────
|
||||
|
||||
test('TC-06 Download as CSV triggers a file download', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TABLE_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TABLE_PANEL);
|
||||
|
||||
// known behaviour: with no telemetry, the CSV may contain only the
|
||||
// header row — asserting on `suggestedFilename()` is the resilient
|
||||
// cross-environment signal that the download actually fired.
|
||||
const [download] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
menu
|
||||
.getByRole('menuitem', {
|
||||
name: 'cloud-download Download as CSV',
|
||||
})
|
||||
.click(),
|
||||
]);
|
||||
|
||||
expect(download.suggestedFilename().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ─── Clone / Delete ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Clone unconditionally navigates to the panel editor (`/new`) — see
|
||||
// `onCloneHandler` in WidgetGraphComponent. Saving from the editor
|
||||
// returns to the dashboard with the duplicated panel persisted.
|
||||
|
||||
test('TC-07 Clone a panel creates a duplicate', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
|
||||
await expect(titleLocator.first()).toBeVisible();
|
||||
const beforeCount = await titleLocator.count();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const cloneItem = menu.getByRole('menuitem', { name: 'copy Clone' });
|
||||
await expect(cloneItem).toBeEnabled();
|
||||
await cloneItem.click();
|
||||
|
||||
// The clone handler PUTs the new layout, then redirects to /new.
|
||||
await page.waitForURL(/\/new/);
|
||||
await expect(page.getByTestId('new-widget-save')).toBeVisible();
|
||||
|
||||
await page.getByTestId('new-widget-save').click();
|
||||
|
||||
// The Save dialog title varies — "Save Widget" if the query is
|
||||
// untouched (the case here, since clone preserves the original
|
||||
// query) or "Unsaved Changes" otherwise. Match either by clicking
|
||||
// OK in whichever dialog appears.
|
||||
const saveDialog = page.getByRole('dialog');
|
||||
await expect(saveDialog).toBeVisible();
|
||||
await saveDialog.getByRole('button', { name: 'OK' }).click();
|
||||
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
// Per-test cleanup: delete the newly cloned panel so it does not
|
||||
// leak into subsequent tests. The clone is the last panel with this
|
||||
// title in DOM order — index `beforeCount`.
|
||||
const cleanupMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await cleanupMenu
|
||||
.getByRole('menuitem', { name: 'delete Delete' })
|
||||
.click();
|
||||
const confirmDialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await expect(confirmDialog).toBeVisible();
|
||||
await confirmDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(titleLocator).toHaveCount(beforeCount);
|
||||
});
|
||||
|
||||
test('TC-08 Delete confirm dialog removes a cloned panel', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
|
||||
await expect(titleLocator.first()).toBeVisible();
|
||||
const beforeCount = await titleLocator.count();
|
||||
|
||||
// Clone a disposable panel — never mutate the seed's original
|
||||
// `Latency` panel because sibling specs depend on it.
|
||||
const cloneMenu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
await cloneMenu.getByRole('menuitem', { name: 'copy Clone' }).click();
|
||||
await page.waitForURL(/\/new/);
|
||||
await page.getByTestId('new-widget-save').click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'OK' })
|
||||
.click();
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
// Delete the clone — last `Latency` in DOM order.
|
||||
const deleteMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await deleteMenu
|
||||
.getByRole('menuitem', { name: 'delete Delete' })
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText(/are you sure/i);
|
||||
|
||||
await dialog.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(titleLocator).toHaveCount(beforeCount);
|
||||
});
|
||||
|
||||
test('TC-09 Cancel delete keeps the panel', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
|
||||
await expect(titleLocator.first()).toBeVisible();
|
||||
const beforeCount = await titleLocator.count();
|
||||
|
||||
// Clone a disposable panel to operate on.
|
||||
const cloneMenu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
await cloneMenu.getByRole('menuitem', { name: 'copy Clone' }).click();
|
||||
await page.waitForURL(/\/new/);
|
||||
await page.getByTestId('new-widget-save').click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'OK' })
|
||||
.click();
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
const deleteMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await deleteMenu
|
||||
.getByRole('menuitem', { name: 'delete Delete' })
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
// Cancel keeps the clone in place — count unchanged from the
|
||||
// post-clone state.
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
// Per-test cleanup: actually delete the clone we just kept so
|
||||
// subsequent tests start from the seeded count.
|
||||
const cleanupMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await cleanupMenu
|
||||
.getByRole('menuitem', { name: 'delete Delete' })
|
||||
.click();
|
||||
const confirmDialog = page.getByRole('dialog', { name: 'Delete' });
|
||||
await confirmDialog.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(titleLocator).toHaveCount(beforeCount);
|
||||
});
|
||||
|
||||
// ─── Create Alerts ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-10 Create Alerts menuitem on a Time Series panel navigates to the alerts editor', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const createAlerts = menu.getByRole('menuitem', {
|
||||
name: /Create Alerts/,
|
||||
});
|
||||
await expect(createAlerts).toBeEnabled();
|
||||
|
||||
// known behaviour: `useCreateAlerts` opens the alerts editor in a
|
||||
// new tab via `window.open(...)` — the current page's URL does not
|
||||
// change. Wait for the new browser tab on the context, not the
|
||||
// existing page.
|
||||
const [newPage] = await Promise.all([
|
||||
page.context().waitForEvent('page'),
|
||||
createAlerts.click(),
|
||||
]);
|
||||
await newPage.waitForLoadState();
|
||||
await expect(newPage).toHaveURL(/\/alerts\/new/);
|
||||
await newPage.close();
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-11 fullscreen URL deep-link opens the panel modal directly', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// First navigate normally and capture the panel's widgetId from the
|
||||
// View action's URL transition — we cannot hard-code a uuid.
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
const viewItem = menu.getByRole('menuitem', { name: 'fullscreen View' });
|
||||
await expect(viewItem).toBeEnabled();
|
||||
await viewItem.click();
|
||||
await expect(page).toHaveURL(/expandedWidgetId=/);
|
||||
const expandedUrl = page.url();
|
||||
await page.getByRole('dialog', { name: TIME_SERIES_PANEL }).getByRole('button', { name: 'Close' }).click();
|
||||
await expect(page).not.toHaveURL(/expandedWidgetId=/);
|
||||
|
||||
// Now hard-navigate to the captured deep-link in a fresh page state.
|
||||
await page.goto(expandedUrl);
|
||||
await expect(
|
||||
page.getByRole('dialog', { name: TIME_SERIES_PANEL }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/expandedWidgetId=/);
|
||||
});
|
||||
|
||||
test('TC-12 Table panel search filters rows in real time', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
const tableTitle = page.getByText(TABLE_PANEL, { exact: true }).first();
|
||||
await expect(tableTitle).toBeVisible();
|
||||
|
||||
const container = panelContainer(page, TABLE_PANEL);
|
||||
await container.scrollIntoViewIfNeeded();
|
||||
await container.hover();
|
||||
await container.getByTestId('widget-header-search').click();
|
||||
|
||||
const searchInput = page.getByTestId('widget-header-search-input');
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
// known behaviour: the bootstrap stack ingests no telemetry, so the
|
||||
// table body may be empty. The contract this TC guards is "typing in
|
||||
// the search updates the input value live and does not throw" — a
|
||||
// rendered row count check only fires when telemetry happens to seed
|
||||
// rows. We log no console errors during the search keystrokes either.
|
||||
const errors: Error[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err));
|
||||
|
||||
await searchInput.fill('foo');
|
||||
await expect(searchInput).toHaveValue('foo');
|
||||
await searchInput.fill('');
|
||||
await expect(searchInput).toHaveValue('');
|
||||
await searchInput.fill('bar-baz');
|
||||
await expect(searchInput).toHaveValue('bar-baz');
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
// TC-13 asserts the panel renders chart data from the bootstrap golden
|
||||
// seed (Playwright globalSetup refreshes timestamps before every test
|
||||
// session, so the data is always within default panel windows).
|
||||
|
||||
test('TC-13 panel renders chart data from the bootstrap golden seed', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const chartId = await createChartDataDashboardViaApi(page);
|
||||
seedIds.add(chartId);
|
||||
|
||||
await page.goto(`/dashboard/${chartId}`);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: /dashboard-icon detail-chart-data-suite/,
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
const panel = page
|
||||
.getByText('E2E Metric RPS', { exact: true })
|
||||
.first()
|
||||
.locator(
|
||||
'xpath=ancestor::div[contains(@class,"widget-graph-component-container")][1]',
|
||||
);
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel.locator('canvas').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const dimensions = await panel
|
||||
.locator('canvas')
|
||||
.first()
|
||||
.evaluate((el) => {
|
||||
const c = el as HTMLCanvasElement;
|
||||
return { w: c.width, h: c.height };
|
||||
});
|
||||
expect(dimensions.w).toBeGreaterThan(0);
|
||||
expect(dimensions.h).toBeGreaterThan(0);
|
||||
|
||||
// Empty-state must NOT render — proves the golden seed landed and
|
||||
// the panel query found rows.
|
||||
await expect(panel.getByText(/no data/i)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-14 Delete only removes the targeted panel — siblings remain', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
|
||||
// "DB Calls RPS" is a single-instance Time Series panel in APM Metrics
|
||||
// — a stable sibling we can assert is still on the canvas after a
|
||||
// clone+delete round-trip on the Latency panel. Scroll into view since
|
||||
// it lives in a later section.
|
||||
const sibling = page.getByText('DB Calls RPS', { exact: true }).first();
|
||||
await sibling.scrollIntoViewIfNeeded();
|
||||
await expect(sibling).toBeVisible();
|
||||
|
||||
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
|
||||
const beforeCount = await titleLocator.count();
|
||||
|
||||
// Clone first so the test is read-only at the seed level.
|
||||
const cloneMenu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
|
||||
await cloneMenu.getByRole('menuitem', { name: 'copy Clone' }).click();
|
||||
await page.waitForURL(/\/new/);
|
||||
await page.getByTestId('new-widget-save').click();
|
||||
await page
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: 'OK' })
|
||||
.click();
|
||||
await page.waitForURL((url) => !url.pathname.includes('/new'));
|
||||
await expect(titleLocator).toHaveCount(beforeCount + 1);
|
||||
|
||||
// Delete the clone (last in DOM order).
|
||||
const deleteMenu = await openPanelMoreMenu(
|
||||
page,
|
||||
TIME_SERIES_PANEL,
|
||||
beforeCount,
|
||||
);
|
||||
await deleteMenu
|
||||
.getByRole('menuitem', { name: 'delete Delete' })
|
||||
.click();
|
||||
await page
|
||||
.getByRole('dialog', { name: 'Delete' })
|
||||
.getByRole('button', { name: 'OK' })
|
||||
.click();
|
||||
|
||||
// Originals + siblings still present.
|
||||
await expect(titleLocator).toHaveCount(beforeCount);
|
||||
await expect(sibling).toBeVisible();
|
||||
});
|
||||
});
|
||||
124
tests/e2e/tests/dashboards/details/35-add-panel.spec.ts
Normal file
124
tests/e2e/tests/dashboards/details/35-add-panel.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
// Scope: dashboard-side seams only —
|
||||
// 1. The toolbar "New Panel" button opens a dialog listing every panel type
|
||||
// the app supports (the dashboard's responsibility).
|
||||
// 2. A panel created from the dialog actually lands on the canvas and
|
||||
// survives a hard reload (the dashboard's persistence contract).
|
||||
//
|
||||
// Editor-internal behaviour (Query Builder vs ClickHouse tab, Panel Settings,
|
||||
// y-axis units, panel-type changes, etc.) belongs in a separate panel-editor
|
||||
// spec — do NOT add those here.
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Dashboard Detail — Add Panel (entry-point + persistence)', () => {
|
||||
test('TC-01 New Panel toolbar button opens a dialog listing all 7 panel types', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const id = await createDashboardViaApi(page, `add-panel-dialog-${ts}`);
|
||||
seedIds.add(id);
|
||||
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
// Empty dashboards render an onboarding canvas with a duplicate
|
||||
// `add-panel-header` CTA. Scope to the toolbar (`.right-section`).
|
||||
await page
|
||||
.locator('.dashboard-details .right-section')
|
||||
.getByTestId('add-panel-header')
|
||||
.click();
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'New Panel' });
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
for (const tile of [
|
||||
'panel-type-graph',
|
||||
'panel-type-value',
|
||||
'panel-type-table',
|
||||
'panel-type-list',
|
||||
'panel-type-bar',
|
||||
'panel-type-pie',
|
||||
'panel-type-histogram',
|
||||
]) {
|
||||
await expect(dialog.getByTestId(tile)).toBeVisible();
|
||||
}
|
||||
|
||||
// Dialog dismisses via the Close (×) button — confirms the user can
|
||||
// back out without entering the editor (no /new navigation happens
|
||||
// until a tile is picked).
|
||||
await dialog.getByRole('button', { name: 'Close' }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page).not.toHaveURL(/\/new/);
|
||||
});
|
||||
|
||||
test('TC-02 saving a new panel persists it on the canvas across reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const id = await createDashboardViaApi(page, `add-panel-persist-${ts}`);
|
||||
seedIds.add(id);
|
||||
const panelName = `e2e-panel-${ts}`;
|
||||
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await page
|
||||
.locator('.dashboard-details .right-section')
|
||||
.getByTestId('add-panel-header')
|
||||
.click();
|
||||
await page
|
||||
.getByRole('dialog', { name: 'New Panel' })
|
||||
.getByTestId('panel-type-graph')
|
||||
.click();
|
||||
|
||||
// We're now on the editor; minimal interaction — set the name and save.
|
||||
// Anything else (queries, panel-type changes, units) is editor-internal
|
||||
// and belongs in a panel-editor spec.
|
||||
await expect(page.getByTestId('new-widget-save')).toBeVisible();
|
||||
await page.getByTestId('panel-name-input').fill(panelName);
|
||||
|
||||
const savePut = page.waitForResponse(
|
||||
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
);
|
||||
await page.getByTestId('new-widget-save').click();
|
||||
await page
|
||||
.getByRole('dialog', { name: 'Save Widget' })
|
||||
.getByRole('button', { name: 'OK' })
|
||||
.click();
|
||||
const putResp = await savePut;
|
||||
expect(putResp.ok()).toBeTruthy();
|
||||
|
||||
// Back on the dashboard — the new panel must render with the typed name.
|
||||
await expect(page).not.toHaveURL(/\/new/);
|
||||
await expect(
|
||||
page.getByText(panelName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Persistence — hard reload, panel still there.
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByText(panelName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
78
tests/e2e/tests/dashboards/details/44-edit-panel.spec.ts
Normal file
78
tests/e2e/tests/dashboards/details/44-edit-panel.spec.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
// This file's scope is intentionally narrow: prove that the detail page's
|
||||
// "Edit panel" entry-point lands the user in the panel editor at
|
||||
// `/dashboard/:id/new?widgetId=…`. Editor-internal behaviour (Query Builder
|
||||
// pre-population, ClickHouse tab, Panel Settings rename, query-edit + revert,
|
||||
// y-axis units, panel-type changes, etc.) is the responsibility of a separate
|
||||
// panel-editor spec — keep this file as the dashboard-side seam only.
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let apmDashboardId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmDashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe('Dashboard Detail — Edit Panel (entry-point only)', () => {
|
||||
test('TC-01 Edit menu item on a panel navigates to the panel editor', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmDashboardId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
// "DB Calls RPS" is the only single-instance panel name in the APM
|
||||
// Metrics fixture (other titles like "Latency" repeat across sections),
|
||||
// so it round-trips uniquely without `.first()` gymnastics.
|
||||
const panelTitle = page.getByText('DB Calls RPS', { exact: true }).first();
|
||||
await panelTitle.scrollIntoViewIfNeeded();
|
||||
|
||||
// Walk up to the widget-graph container. Its `:hover` flips the ⋮ icon
|
||||
// from `visibility: hidden` to visible (see GridCardLayout.styles.scss
|
||||
// rule on `.widget-graph-component-container:hover .options-action`).
|
||||
const container = panelTitle.locator(
|
||||
'xpath=ancestor::*[contains(@class,"widget-graph-component-container")][1]',
|
||||
);
|
||||
await container.hover();
|
||||
|
||||
const options = container.getByTestId('widget-header-options');
|
||||
// The ⋮ uses an antd `Dropdown` with `trigger=['hover']`; firing a real
|
||||
// hover (not `dispatchEvent('click')`) is what opens the menu.
|
||||
await options.hover({ force: true });
|
||||
|
||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+\/new\?.*widgetId=/);
|
||||
await expect(page.getByTestId('new-widget-save')).toBeVisible();
|
||||
});
|
||||
});
|
||||
245
tests/e2e/tests/dashboards/details/56-time-range.spec.ts
Normal file
245
tests/e2e/tests/dashboards/details/56-time-range.spec.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let apmId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function openTimePicker(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByRole('textbox', { name: /Last \d+/ })
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Time Range', () => {
|
||||
test('TC-01 selecting a preset updates the textbox label and URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await openTimePicker(page);
|
||||
|
||||
const refetch = page.waitForResponse((r) =>
|
||||
r.url().includes('/query_range'),
|
||||
);
|
||||
await page.getByRole('button', { name: 'Last 1 hour 1h' }).click();
|
||||
const response = await refetch;
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Last 1 hour' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/relativeTime=1h/);
|
||||
// Without seeded telemetry the backend may return 4xx for query_range
|
||||
// (panels render "No Data" — a known harness limitation, not a test
|
||||
// bug). Cancelled in-flight responses also surface here as non-ok.
|
||||
// Only 5xx is a real failure; the URL + textbox label assertions
|
||||
// above already prove the preset click took effect.
|
||||
expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
|
||||
test('TC-02 switching presets twice updates the label both times', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await openTimePicker(page);
|
||||
await page.getByRole('button', { name: 'Last 6 hours 6h' }).click();
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Last 6 hours' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/relativeTime=6h/);
|
||||
|
||||
await openTimePicker(page);
|
||||
await page.getByRole('button', { name: 'Last 1 day 1d' }).click();
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Last 1 day' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/relativeTime=1d/);
|
||||
await expect(page).not.toHaveURL(/relativeTime=6h/);
|
||||
});
|
||||
|
||||
test('TC-03 custom date range picker reflects selected dates and switches URL to absolute timestamps', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await openTimePicker(page);
|
||||
await page.getByRole('button', { name: 'Custom Date Range' }).click();
|
||||
|
||||
const prevMonth = page.getByRole('button', {
|
||||
name: 'Go to the Previous Month',
|
||||
});
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
await prevMonth.click();
|
||||
}
|
||||
|
||||
// Calendar day buttons have accessible names like "Saturday, March
|
||||
// 14th, 2026" (the rendered label is "14" but a11y appends the suffix
|
||||
// + month + year). Pick a known day by its long-form name regex
|
||||
// against the gridcell — `\b14th\b` is unambiguous and avoids
|
||||
// matching siblings like "14" inside "2014".
|
||||
await page
|
||||
.getByRole('gridcell', { name: /\b14th\b/ })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const refetch = page.waitForResponse((r) =>
|
||||
r.url().includes('/query_range'),
|
||||
);
|
||||
await page.getByRole('button', { name: 'Apply' }).click();
|
||||
const response = await refetch;
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: /\d{2}\/\d{2}\/\d{4}/ }).first(),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/startTime=\d+/);
|
||||
await expect(page).toHaveURL(/endTime=\d+/);
|
||||
// As TC-01: backend 4xx (no telemetry) is acceptable; only 5xx is
|
||||
// failure. Apply triggered the refetch, which is what we verify.
|
||||
expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
|
||||
test('TC-04 timezone change updates the toolbar timezone label', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await openTimePicker(page);
|
||||
await page.getByRole('button', { name: 'Change Timezone' }).click();
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Search timezones...' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: /Coordinated Universal Time —/ })
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(page.getByText('UTC', { exact: true }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 refresh-interval popup contents', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'caret-down' }).click();
|
||||
|
||||
const autoRefresh = page.getByRole('checkbox', { name: 'Auto Refresh' });
|
||||
await expect(autoRefresh).toBeVisible();
|
||||
await expect(autoRefresh).not.toBeChecked();
|
||||
|
||||
// Labels match the live build (no `15 minutes` / `12 hours` — the
|
||||
// test plan's enumeration was approximate).
|
||||
for (const label of [
|
||||
'5 seconds',
|
||||
'10 seconds',
|
||||
'30 seconds',
|
||||
'1 minute',
|
||||
'5 minutes',
|
||||
'10 minutes',
|
||||
'30 minutes',
|
||||
'1 hour',
|
||||
'2 hours',
|
||||
'1 day',
|
||||
]) {
|
||||
await expect(
|
||||
page.getByRole('button', { name: label, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('TC-06 toggling auto-refresh on then changing the interval', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'caret-down' }).click();
|
||||
|
||||
const autoRefresh = page.getByRole('checkbox', { name: 'Auto Refresh' });
|
||||
await autoRefresh.click();
|
||||
await expect(autoRefresh).toBeChecked();
|
||||
|
||||
await page.getByRole('button', { name: '1 minute', exact: true }).click();
|
||||
await page.getByRole('button', { name: '5 minutes', exact: true }).click();
|
||||
await expect(autoRefresh).toBeChecked();
|
||||
|
||||
await autoRefresh.click();
|
||||
await expect(autoRefresh).not.toBeChecked();
|
||||
});
|
||||
|
||||
test('TC-07 manual sync triggers a query_range refetch', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto(`/dashboard/${apmId}`);
|
||||
await expect(
|
||||
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
const refetch = page.waitForResponse((r) =>
|
||||
r.url().includes('/query_range'),
|
||||
);
|
||||
await page.getByRole('button', { name: 'sync' }).click();
|
||||
const response = await refetch;
|
||||
// 4xx is expected without seeded telemetry; only 5xx is a failure.
|
||||
// The sync click successfully triggering a query_range fetch is the
|
||||
// behaviour under test.
|
||||
expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
497
tests/e2e/tests/dashboards/details/67-variables.spec.ts
Normal file
497
tests/e2e/tests/dashboards/details/67-variables.spec.ts
Normal file
@@ -0,0 +1,497 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
awaitVariablesResolved,
|
||||
createVariablesDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
import variablesTemplate from '../../../testdata/variables-dashboard.json';
|
||||
|
||||
// Variables that depend on backend resolution against seeded telemetry the
|
||||
// bootstrap stack does not produce. Skip them so `awaitVariablesResolved`
|
||||
// does not block on values that can never appear.
|
||||
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
let varDashboardId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
varDashboardId = await createVariablesDashboardViaApi(
|
||||
page,
|
||||
'detail-variables-suite',
|
||||
);
|
||||
seedIds.add(varDashboardId);
|
||||
// Per the framework contract: every variable with a default has its
|
||||
// `selectedValue` set in the seed JSON; backend-resolved variables
|
||||
// (Query / Dynamic) cannot resolve without seeded telemetry, so we
|
||||
// list them in `skipNames`. Tests must not race ahead of seed
|
||||
// materialisation — this gate ensures the persisted dashboard is in
|
||||
// a known state before any test runs.
|
||||
await awaitVariablesResolved(page, varDashboardId, {
|
||||
skipNames: TELEMETRY_DEPENDENT_VARS,
|
||||
});
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
function variablesQueryParam(state: Record<string, unknown>): string {
|
||||
return encodeURIComponent(encodeURIComponent(JSON.stringify(state)));
|
||||
}
|
||||
|
||||
async function gotoVariablesDashboard(
|
||||
page: Page,
|
||||
urlState?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const url = urlState
|
||||
? `/dashboard/${varDashboardId}?variables=${variablesQueryParam(urlState)}`
|
||||
: `/dashboard/${varDashboardId}`;
|
||||
await page.goto(url);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: /dashboard-icon detail-variables-suite/,
|
||||
}),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Variables', () => {
|
||||
test('TC-01 variables bar renders all four types', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
for (const name of [
|
||||
'$tb_env',
|
||||
'$tb_service',
|
||||
'$cu_single',
|
||||
'$cu_env_all',
|
||||
'$cu_services',
|
||||
'$q_env',
|
||||
'$q_service',
|
||||
'$d_namespace',
|
||||
]) {
|
||||
await expect(page.getByText(name, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
// Textbox variables expose their current value via `value` and `title`
|
||||
// attributes (the antd Input has no accessible name matching the value),
|
||||
// so we match on input[value="..."] rather than getByRole+name.
|
||||
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
|
||||
await expect(page.locator('input[value="frontend"]')).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('variable-select')).toHaveCount(6);
|
||||
});
|
||||
|
||||
test('TC-02 selecting a value in a single-value Custom variable updates URL and aria-selected', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// $cu_single (nth(0)) — single-select Custom with three static
|
||||
// options. Driving Custom rather than Query keeps the test
|
||||
// deterministic regardless of seeded telemetry.
|
||||
const dropdown = page.getByTestId('variable-select').nth(0);
|
||||
await dropdown.click();
|
||||
await page.getByRole('option', { name: 'mq-kafka' }).click();
|
||||
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/variables=.*mq-kafka/);
|
||||
|
||||
await dropdown.click();
|
||||
await expect(page.getByRole('option', { name: 'mq-kafka' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('TC-03 multi-select renders chips and URL encodes array', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// URL state seeds adservice + cartservice as initial selection; this also
|
||||
// guarantees the URL contains the encoded array so we can assert on it
|
||||
// without relying on the seeded server-side selection rendering identically
|
||||
// across reloads.
|
||||
await gotoVariablesDashboard(page, {
|
||||
cu_services: ['adservice', 'cartservice'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag adservice' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag cartservice' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/adservice/);
|
||||
await expect(page).toHaveURL(/cartservice/);
|
||||
});
|
||||
|
||||
test('TC-04 removing a chip updates URL', async ({ authedPage: page }) => {
|
||||
await gotoVariablesDashboard(page, {
|
||||
cu_services: ['adservice', 'cartservice'],
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag adservice' }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Remove tag adservice' }).click();
|
||||
|
||||
// Removing a chip on a multi-select expands the dropdown; URL state
|
||||
// only commits when the dropdown closes (onDropdownVisibleChange =>
|
||||
// false). The CustomMultiSelect swallows Escape, so click outside the
|
||||
// dropdown to dismiss it.
|
||||
await page.locator('img[alt="dashboard-img"]').click();
|
||||
await expect(page.getByRole('listbox')).toBeHidden();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag adservice' }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Remove tag cartservice' }),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveURL(/variables=/);
|
||||
await expect(page).not.toHaveURL(/adservice/);
|
||||
});
|
||||
|
||||
test('TC-05 ALL option on a Custom variable', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page, { cu_env_all: 'otel-demo' });
|
||||
|
||||
// $cu_env_all (nth(1)) — multi-select Custom with showALLOption: true,
|
||||
// so the dropdown exposes an "ALL" toggle alongside the static options.
|
||||
const dropdown = page.getByTestId('variable-select').nth(1);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', {
|
||||
hasText: 'otel-demo',
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await dropdown.click();
|
||||
await page.getByRole('option', { name: 'ALL' }).click();
|
||||
|
||||
// When ALL is selected, the multi-select renders an "ALL" badge in a
|
||||
// custom container (not the standard .ant-select-selection-item), so
|
||||
// match on the option's checked state inside the dropdown listbox
|
||||
// rather than on the closed-state chip.
|
||||
await expect(
|
||||
page.getByRole('option', { name: 'ALL' }),
|
||||
).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page).toHaveURL(/variables=/);
|
||||
});
|
||||
|
||||
test('TC-06 textbox variable update propagates to URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// Locate by the testid wrapping a stable id, since `input[value="..."]`
|
||||
// becomes stale the moment we fill('') the field.
|
||||
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
|
||||
const tb = page.getByPlaceholder('Enter value').first();
|
||||
await tb.click();
|
||||
await tb.fill('');
|
||||
await tb.fill('production');
|
||||
await tb.press('Enter');
|
||||
|
||||
await expect(page.locator('input[value="production"]')).toBeVisible();
|
||||
await expect(page).toHaveURL(/variables=.*production/);
|
||||
});
|
||||
|
||||
test('TC-07 cascading: child variable listbox opens after parent change', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page, { q_env: 'otel-demo' });
|
||||
|
||||
// q_service (nth(4)) is cascaded from q_env (nth(3)).
|
||||
const child = page.getByTestId('variable-select').nth(4);
|
||||
await child.click();
|
||||
|
||||
// known behaviour: the child's option list requires seeded telemetry —
|
||||
// the bootstrap stack has none, so we only assert that the listbox
|
||||
// renders without crashing rather than checking specific options.
|
||||
await expect(page.getByRole('listbox').first()).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(page).toHaveURL(/otel-demo/);
|
||||
});
|
||||
|
||||
test('TC-08 URL deep-link restores variable state on hard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page, { cu_env_all: 'mq-kafka' });
|
||||
|
||||
const dropdown = page.getByTestId('variable-select').nth(1);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: /dashboard-icon detail-variables-suite/,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/variables=%257B/);
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-09 ALL → specific value → ALL round-trip preserves URL state', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
const dropdown = page.getByTestId('variable-select').nth(1); // cu_env_all
|
||||
|
||||
// Seed defaults to ALL — open, pick a specific value, assert URL.
|
||||
await dropdown.click();
|
||||
await page.getByRole('option', { name: 'mq-kafka' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page).toHaveURL(/mq-kafka/);
|
||||
|
||||
// Re-open, switch back to ALL — URL must update again.
|
||||
await dropdown.click();
|
||||
const allOption = page.getByRole('option', { name: 'ALL' });
|
||||
await allOption.click();
|
||||
await expect(allOption).toHaveAttribute('aria-selected', 'true');
|
||||
await page.keyboard.press('Escape');
|
||||
// `mq-kafka` should no longer appear in the URL after reverting to ALL.
|
||||
await expect(page).not.toHaveURL(/mq-kafka/);
|
||||
});
|
||||
|
||||
test('TC-10 two variables changed in sequence both encode in URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// cu_single — pick `production`.
|
||||
const single = page.getByTestId('variable-select').nth(0);
|
||||
await single.click();
|
||||
await page.getByRole('option', { name: 'production' }).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page).toHaveURL(/production/);
|
||||
|
||||
// q_service — open the multi-select, dismiss without picking. The URL
|
||||
// should still contain the previous selection.
|
||||
const cuServices = page.getByTestId('variable-select').nth(2);
|
||||
await cuServices.click();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page).toHaveURL(/production/);
|
||||
await expect(page).toHaveURL(/cu_single/);
|
||||
});
|
||||
|
||||
test('TC-11 navigating away and back preserves the URL-encoded state', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page, { cu_single: 'mq-kafka' });
|
||||
const dropdown = page.getByTestId('variable-select').nth(0);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
const stateUrl = page.url();
|
||||
|
||||
// Leave to the list, come back via browser back — URL is restored.
|
||||
await page.getByRole('button', { name: 'Dashboard /' }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(stateUrl);
|
||||
await expect(
|
||||
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── TBD coverage — placeholders to fill in when each feature lands ──────
|
||||
//
|
||||
// Each `test.skip` below marks a behaviour the spec does NOT yet exercise.
|
||||
// They are intentional gaps, not bugs — when the feature ships or the seed
|
||||
// gains telemetry, replace `test.skip` with `test`, drop the comment, and
|
||||
// implement.
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-12 Custom variable without a default prompts user to select a value', async () => {
|
||||
// Requires extending variables-dashboard.json with a Custom variable
|
||||
// that has no `selectedValue` and no `allSelected`. The UI should
|
||||
// render the dropdown empty/"Select value" until a user picks.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-13 Query variable with pre-seeded selectedValue renders without backend resolution', async () => {
|
||||
// Requires extending variables-dashboard.json with a Query variable
|
||||
// that ships with `selectedValue` already populated — the UI should
|
||||
// trust the seed and not block on a query.
|
||||
});
|
||||
|
||||
test('TC-14 multi-select Query variable without telemetry shows an empty option list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// q_service is the only multi-select Query in the seed (nth(4) in
|
||||
// the dropdown order). Without telemetry the option list is empty —
|
||||
// assert the empty-state explicitly.
|
||||
const child = page.getByTestId('variable-select').nth(4);
|
||||
await child.click();
|
||||
const listbox = page.getByRole('listbox').first();
|
||||
await expect(listbox).toBeVisible();
|
||||
await expect(listbox.getByRole('option')).toHaveCount(0);
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('TC-15 Dynamic variable resolves a seeded namespace value', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
// d_namespace's `dynamicVariablesAttribute` is `k8s.namespace.name`
|
||||
// over the `metrics` source. The bootstrap OTel collector ingests
|
||||
// the golden dataset which tags every resource with
|
||||
// `k8s.namespace.name=signoz-<service>` for 8 distinct services.
|
||||
// SigNoz's `signoz_metrics.distributed_metadata` table is populated
|
||||
// naturally by the collector's signozclickhousemetrics exporter, and
|
||||
// `/api/v1/fields/values?signal=metrics&name=k8s.namespace.name`
|
||||
// surfaces the values so the Dynamic variable auto-resolves.
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// d_namespace is the 6th dropdown variable in DOM order. The
|
||||
// closed-state of the combobox renders the auto-resolved value
|
||||
// inline next to the variable name. Match any of the 8 seeded
|
||||
// namespaces — ordering depends on the backend sort, so we accept
|
||||
// whichever it returns first.
|
||||
const dynamic = page.getByTestId('variable-select').nth(5);
|
||||
await expect(dynamic).toContainText(/signoz-\w+/, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-16 changing a variable referenced in a panel query refetches the panel data', async () => {
|
||||
// $service.name and $deployment.environment are referenced by APM
|
||||
// panel queries. Asserting that a variable change triggers a
|
||||
// query_range refetch with the new substitution requires either
|
||||
// seeded telemetry or a network-request listener that confirms the
|
||||
// outbound query body contains the new value. Defer until the
|
||||
// chart-data assertion path is in place.
|
||||
});
|
||||
|
||||
test('TC-17 variable bar order matches the `order` field in dashboard JSON', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// Extract DOM order of `$<name>` labels in the variables bar and
|
||||
// compare against the `order` sequence the seed JSON declares
|
||||
// (tb_env=0, tb_service=1, cu_single=2, cu_env_all=3, cu_services=4,
|
||||
// q_env=5, q_service=6, d_namespace=7).
|
||||
const expected = [
|
||||
'$tb_env',
|
||||
'$tb_service',
|
||||
'$cu_single',
|
||||
'$cu_env_all',
|
||||
'$cu_services',
|
||||
'$q_env',
|
||||
'$q_service',
|
||||
'$d_namespace',
|
||||
];
|
||||
// Read the on-screen label text in DOM order. Each `$<name>` label is
|
||||
// emitted as plain text inside the variables bar — `allInnerTexts()`
|
||||
// preserves their order. Filter to `$<word>` to exclude any other
|
||||
// transient text inside the bar.
|
||||
const allText = await page
|
||||
.locator('text=/^\\$\\w+$/')
|
||||
.allInnerTexts();
|
||||
const actual = allText.filter((t) => /^\$\w+$/.test(t));
|
||||
// Sort comparison is intentionally strict: an order regression would
|
||||
// silently swap pairs without a deep equal check.
|
||||
expect(actual.slice(0, expected.length)).toEqual(expected);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-18 reordering variables via drag persists to the dashboard JSON', async () => {
|
||||
// The Configure → Variables tab supports drag handles. After a
|
||||
// reorder, the persisted `order` fields should update and the
|
||||
// variables bar should re-render in the new order.
|
||||
});
|
||||
|
||||
test('TC-19 variable removed via Configure disappears from the variables bar', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoVariablesDashboard(page);
|
||||
|
||||
// `tb_service` is the easiest variable to remove cleanly — it's a
|
||||
// textbox, no dependents. Delete it via Configure → Variables tab.
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.dashboard-details .right-section')
|
||||
.getByTestId('show-drawer')
|
||||
.click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
|
||||
const nameCell = tabpanel.getByText('tb_service', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.delete-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
const confirm = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: /delete variable/i })
|
||||
.last();
|
||||
await confirm.getByRole('button', { name: 'OK' }).click();
|
||||
|
||||
// Configure list no longer shows it.
|
||||
await expect(tabpanel.getByText('tb_service', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
|
||||
// Variables bar no longer shows `$tb_service`.
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
// Restore — re-PUT the seed so subsequent serial-mode tests are
|
||||
// not affected. Only this test mutates the persisted variable map;
|
||||
// the rest only mutate URL state.
|
||||
const token = await authToken(page);
|
||||
await page.request.put(`/api/v1/dashboards/${varDashboardId}`, {
|
||||
data: { ...variablesTemplate, title: 'detail-variables-suite' },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
await page.reload();
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
354
tests/e2e/tests/dashboards/details/78-edit-mode.spec.ts
Normal file
354
tests/e2e/tests/dashboards/details/78-edit-mode.spec.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
async function seed(page: Page, title: string): Promise<string> {
|
||||
const id = await createDashboardViaApi(page, title);
|
||||
seedIds.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function openEditMode(page: Page): Promise<void> {
|
||||
await page.getByTestId('options').click();
|
||||
}
|
||||
|
||||
async function closeEditModeIfOpen(page: Page): Promise<void> {
|
||||
const lockBtn = page.getByRole('button', { name: 'Lock Dashboard' });
|
||||
if (await lockBtn.isVisible().catch(() => false)) {
|
||||
await lockBtn.click({ force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Edit Mode', () => {
|
||||
test('TC-01 edit-mode popup contains all six action buttons', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'edit-mode-popup');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Lock Dashboard' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Rename', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Full screen' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'New section' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Export JSON' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Copy as JSON' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Lock Dashboard' }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Lock Dashboard' }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-02 Lock Dashboard exits edit mode', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'edit-mode-lock');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Lock Dashboard' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Lock Dashboard' }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Rename', exact: true }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'New section' }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Export JSON' }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-03 rename dashboard — breadcrumb updates, then restore', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const original = `original-${ts}`;
|
||||
const renamed = `Renamed-${ts}`;
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Rename', exact: true }).click();
|
||||
|
||||
const renameDialog = page.getByRole('dialog', {
|
||||
name: 'Rename Dashboard',
|
||||
});
|
||||
await expect(renameDialog).toBeVisible();
|
||||
|
||||
const nameInput = renameDialog.getByTestId('dashboard-name');
|
||||
await nameInput.fill('');
|
||||
await nameInput.fill(renamed);
|
||||
await renameDialog
|
||||
.getByRole('button', { name: 'Rename Dashboard' })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${renamed}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// Restore.
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Rename', exact: true }).click();
|
||||
const restoreDialog = page.getByRole('dialog', {
|
||||
name: 'Rename Dashboard',
|
||||
});
|
||||
const restoreInput = restoreDialog.getByTestId('dashboard-name');
|
||||
await restoreInput.fill('');
|
||||
await restoreInput.fill(original);
|
||||
await restoreDialog
|
||||
.getByRole('button', { name: 'Rename Dashboard' })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${original}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 cancel rename leaves name unchanged', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const original = `cancel-rename-${ts}`;
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Rename', exact: true }).click();
|
||||
|
||||
const renameDialog = page.getByRole('dialog', {
|
||||
name: 'Rename Dashboard',
|
||||
});
|
||||
const nameInput = renameDialog.getByTestId('dashboard-name');
|
||||
await nameInput.fill('');
|
||||
await nameInput.fill('Should Not Be Saved');
|
||||
|
||||
await renameDialog.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${original}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Should Not Be Saved')).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-05 add a new section via edit mode, then remove it', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const id = await seed(page, `edit-mode-section-${ts}`);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'New section' }).click();
|
||||
|
||||
const sectionDialog = page.getByRole('dialog', { name: 'New Section' });
|
||||
const sectionName = `e2e-section-${ts}`;
|
||||
await sectionDialog.getByTestId('section-name').fill(sectionName);
|
||||
await sectionDialog
|
||||
.getByRole('button', { name: 'Create Section' })
|
||||
.click();
|
||||
|
||||
const sectionTitle = page
|
||||
.locator('.section-title')
|
||||
.filter({ hasText: sectionName });
|
||||
await expect(sectionTitle).toBeVisible();
|
||||
|
||||
// Cleanup — remove the section. The ellipsis trigger sits on the
|
||||
// `.row-panel` container alongside the section title; the popover it
|
||||
// opens has rootClassName="row-settings" and renders at body level.
|
||||
const sectionRow = sectionTitle.locator('xpath=ancestor::*[contains(@class, "row-panel")]');
|
||||
await sectionRow.hover();
|
||||
await sectionRow.locator('.settings-icon').click();
|
||||
const rowSettingsPopover = page.locator('.row-settings');
|
||||
await expect(rowSettingsPopover).toBeVisible();
|
||||
await rowSettingsPopover
|
||||
.getByRole('button', { name: 'Remove Section' })
|
||||
.click();
|
||||
|
||||
const deleteDialog = page.getByRole('dialog', { name: 'Delete Row' });
|
||||
await expect(deleteDialog).toBeVisible();
|
||||
await deleteDialog.getByRole('button', { name: 'OK' }).click();
|
||||
|
||||
await expect(sectionTitle).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-06 Export JSON triggers a .json download', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'edit-mode-export');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Export JSON' }).click();
|
||||
const download = await downloadPromise;
|
||||
|
||||
expect(download.suggestedFilename()).toMatch(/\.json$/);
|
||||
|
||||
await closeEditModeIfOpen(page);
|
||||
});
|
||||
|
||||
test('TC-07 Copy as JSON puts dashboard JSON on the clipboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page
|
||||
.context()
|
||||
.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
const ts = Date.now();
|
||||
const title = `edit-mode-copy-${ts}`;
|
||||
const id = await seed(page, title);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Copy as JSON' }).click();
|
||||
|
||||
const clipboardText = await page.evaluate(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
const parsed = JSON.parse(clipboardText) as { title?: string };
|
||||
expect(parsed.title ?? '').toContain(title);
|
||||
|
||||
await closeEditModeIfOpen(page);
|
||||
});
|
||||
|
||||
// known behaviour: headless Chromium does not honour the Fullscreen API,
|
||||
// so we cannot assert `document.fullscreenElement`. Verifying that the
|
||||
// click is benign (breadcrumb still rendered) is the strongest cross-env
|
||||
// check available.
|
||||
test('TC-08 Full screen — clicking does not crash the dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'edit-mode-fullscreen');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Full screen' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /Dashboard \// }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await closeEditModeIfOpen(page);
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-09 lock → unlock round-trip restores edit-mode controls', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'edit-mode-lock-roundtrip');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
// Lock the dashboard.
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Lock Dashboard' }).click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Lock Dashboard' }),
|
||||
).toBeHidden();
|
||||
|
||||
// Re-opening the popup after a lock shows the Unlock label instead of
|
||||
// Lock. The button label flips based on `isDashboardLocked`.
|
||||
await openEditMode(page);
|
||||
const unlockBtn = page.getByRole('button', { name: 'Unlock Dashboard' });
|
||||
await expect(unlockBtn).toBeVisible();
|
||||
await unlockBtn.click();
|
||||
|
||||
// After unlock, the popup should re-expose the original action buttons.
|
||||
await openEditMode(page);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Rename', exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'New section' }),
|
||||
).toBeVisible();
|
||||
await closeEditModeIfOpen(page);
|
||||
});
|
||||
|
||||
test('TC-10 rename persists across hard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const original = `rename-persist-${ts}`;
|
||||
const renamed = `Renamed-Persist-${ts}`;
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
await openEditMode(page);
|
||||
await page.getByRole('button', { name: 'Rename', exact: true }).click();
|
||||
const renameDialog = page.getByRole('dialog', {
|
||||
name: 'Rename Dashboard',
|
||||
});
|
||||
const nameInput = renameDialog.getByTestId('dashboard-name');
|
||||
await nameInput.fill('');
|
||||
await nameInput.fill(renamed);
|
||||
await renameDialog
|
||||
.getByRole('button', { name: 'Rename Dashboard' })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${renamed}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
// Hard reload — name must still be the renamed one.
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${renamed}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page).toHaveTitle(new RegExp(renamed));
|
||||
});
|
||||
});
|
||||
686
tests/e2e/tests/dashboards/details/87-configure.spec.ts
Normal file
686
tests/e2e/tests/dashboards/details/87-configure.spec.ts
Normal file
@@ -0,0 +1,686 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
authToken,
|
||||
awaitVariablesResolved,
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
|
||||
|
||||
// `createVariablesDashboardViaApi` is added by the group-3 spec. Import lazily
|
||||
// so this file still compiles while it is missing — tests that need it skip
|
||||
// at runtime.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const dashboardsHelpers = require('../../../helpers/dashboards') as {
|
||||
createVariablesDashboardViaApi?: (
|
||||
page: Page,
|
||||
title: string,
|
||||
) => Promise<string>;
|
||||
};
|
||||
const hasVariablesHelper =
|
||||
typeof dashboardsHelpers.createVariablesDashboardViaApi === 'function';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
|
||||
async function seed(page: Page, title: string): Promise<string> {
|
||||
const id = await createDashboardViaApi(page, title);
|
||||
seedIds.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function seedVariablesDashboard(
|
||||
page: Page,
|
||||
title: string,
|
||||
): Promise<string> {
|
||||
if (!dashboardsHelpers.createVariablesDashboardViaApi) {
|
||||
throw new Error('createVariablesDashboardViaApi helper is not available');
|
||||
}
|
||||
const id = await dashboardsHelpers.createVariablesDashboardViaApi(
|
||||
page,
|
||||
title,
|
||||
);
|
||||
seedIds.add(id);
|
||||
// Wait for the seeded dashboard's variables to fully resolve before any
|
||||
// caller test acts on them. Variables with defaults already have
|
||||
// `selectedValue` set; Query/Dynamic variables can't resolve without
|
||||
// telemetry and are skipped.
|
||||
await awaitVariablesResolved(page, id, {
|
||||
skipNames: TELEMETRY_DEPENDENT_VARS,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) return;
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function openConfigureDrawer(page: Page) {
|
||||
// An empty dashboard renders an onboarding canvas with a duplicate
|
||||
// `data-testid="show-drawer"` Configure CTA alongside the toolbar one.
|
||||
// Scope to the toolbar (`.dashboard-details .right-section`) to avoid the
|
||||
// strict-mode collision.
|
||||
await page
|
||||
.locator('.dashboard-details .right-section')
|
||||
.getByTestId('show-drawer')
|
||||
.click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function deleteVariableByName(page: Page, varName: string) {
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText(varName, { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
// Walk up to the surrounding row container to scope the delete-button
|
||||
// search; `.variable-item` (or the variable row container) wraps the
|
||||
// hover-revealed delete button.
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.delete-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
const confirm = page
|
||||
.getByRole('dialog')
|
||||
.filter({ hasText: /delete variable/i })
|
||||
.last();
|
||||
await confirm.getByRole('button', { name: 'OK' }).click();
|
||||
await expect(tabpanel.getByText(varName, { exact: true })).toHaveCount(0);
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail — Configure drawer', () => {
|
||||
test('TC-01 Configure drawer opens with three tabs and Overview is active', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'cfg-drawer-chrome');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
|
||||
await expect(dialog.getByText('Dashboard Configuration')).toBeVisible();
|
||||
await expect(dialog.getByRole('tab', { name: 'Overview' })).toBeVisible();
|
||||
await expect(dialog.getByRole('tab', { name: 'Variables' })).toBeVisible();
|
||||
await expect(dialog.getByRole('tab', { name: 'Publish' })).toBeVisible();
|
||||
|
||||
await expect(
|
||||
dialog.getByRole('tab', { name: 'Overview' }),
|
||||
).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(
|
||||
dialog.getByRole('tabpanel', { name: 'Overview' }),
|
||||
).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-02 update name, description, and tag — persists across reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const ts = Date.now();
|
||||
const original = `cfg-overview-save-${ts}`;
|
||||
const updated = `Configured-${ts}`;
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
|
||||
const nameInput = dialog.getByTestId('dashboard-name');
|
||||
await nameInput.click();
|
||||
await nameInput.fill('');
|
||||
await nameInput.fill(updated);
|
||||
|
||||
await dialog
|
||||
.getByTestId('dashboard-desc')
|
||||
.fill('Automated test description');
|
||||
|
||||
const tagInput = dialog.getByPlaceholder('Start typing your tag name');
|
||||
await tagInput.fill(`e2e-tag-${ts}`);
|
||||
await tagInput.press('Enter');
|
||||
|
||||
const saveBtn = dialog.getByRole('button', { name: 'Save' });
|
||||
await saveBtn.scrollIntoViewIfNeeded();
|
||||
const [putResp] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
|
||||
),
|
||||
saveBtn.click({ force: true }),
|
||||
]);
|
||||
expect(putResp.ok()).toBeTruthy();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${updated}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-03 Discard reverts unsaved Overview changes', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const original = 'cfg-overview-discard';
|
||||
const id = await seed(page, original);
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
const nameInput = dialog.getByTestId('dashboard-name');
|
||||
await expect(nameInput).toHaveValue(original);
|
||||
|
||||
await nameInput.fill('Temp Modified Name');
|
||||
const discard = dialog.getByRole('button', { name: 'Discard' });
|
||||
await expect(discard).toBeVisible();
|
||||
await discard.click();
|
||||
|
||||
await expect(nameInput).toHaveValue(original);
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Save' }),
|
||||
).not.toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-04 Variables tab lists existing variables', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-list');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
await expect(tabpanel).toBeVisible();
|
||||
|
||||
for (const varName of [
|
||||
'tb_env',
|
||||
'tb_service',
|
||||
'cu_env_all',
|
||||
'cu_services',
|
||||
'q_env',
|
||||
'q_service',
|
||||
'd_namespace',
|
||||
]) {
|
||||
// Variable rows render as plain text inside the Variables tab
|
||||
// (not a true Antd `Table` with role="row"). Locate via text.
|
||||
await expect(
|
||||
tabpanel.getByText(varName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-05 add a Textbox variable — appears in the variables bar and is interactive', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-add-textbox');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const ts = Date.now();
|
||||
const varName = `tb_var_${ts}`;
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog
|
||||
.getByPlaceholder('Unique name of the variable')
|
||||
.fill(varName);
|
||||
await dialog.getByRole('button', { name: 'Textbox' }).click();
|
||||
|
||||
const saveBtn = dialog.getByRole('button', { name: 'Save Variable' });
|
||||
await expect(saveBtn).toBeEnabled();
|
||||
await saveBtn.click({ force: true });
|
||||
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
await expect(
|
||||
tabpanel.getByText(varName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
await expect(page.getByText(`$${varName}`)).toBeVisible();
|
||||
|
||||
const newTextbox = page.locator('input[placeholder="Enter value"]').last();
|
||||
await newTextbox.fill('test-value');
|
||||
await newTextbox.press('Enter');
|
||||
await expect(page).toHaveURL(/test-value/);
|
||||
|
||||
await deleteVariableByName(page, varName);
|
||||
});
|
||||
|
||||
test('TC-06 add a Custom variable — appears in the list', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-add-custom');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const ts = Date.now();
|
||||
const varName = `custom_var_${ts}`;
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog
|
||||
.getByPlaceholder('Unique name of the variable')
|
||||
.fill(varName);
|
||||
await dialog.getByRole('button', { name: 'Custom' }).click();
|
||||
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save Variable' })
|
||||
.click({ force: true });
|
||||
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
await expect(
|
||||
tabpanel.getByText(varName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
|
||||
await deleteVariableByName(page, varName);
|
||||
});
|
||||
|
||||
// known limitation: TC-07 (add a Dynamic (Beta) variable) is intentionally
|
||||
// not implemented. Dynamic variables source from the SigNoz attribute
|
||||
// index — the bootstrap stack ingests no telemetry, so the field selector
|
||||
// renders an empty option list and Save Variable can never be enabled.
|
||||
// Re-add once the bootstrap seeds telemetry attributes.
|
||||
|
||||
test('TC-08 selecting Query type renders the query editor', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-variables-add-query');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const ts = Date.now();
|
||||
const varName = `query_var_${ts}`;
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog
|
||||
.getByPlaceholder('Unique name of the variable')
|
||||
.fill(varName);
|
||||
await dialog.getByRole('button', { name: /Query/ }).click();
|
||||
|
||||
await expect(dialog.locator('.monaco-editor').first()).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-09 Save Variable disabled when name is empty', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'cfg-variables-empty-name');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
const nameField = dialog.getByPlaceholder('Unique name of the variable');
|
||||
await expect(nameField).toHaveValue('');
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Save Variable' }),
|
||||
).toBeDisabled();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-10 Publish tab shows private message and Publish button', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const id = await seed(page, 'cfg-publish');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Publish' }).click();
|
||||
await expect(
|
||||
dialog.getByRole('tabpanel', { name: 'Publish' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
'This dashboard is private. Publish it to make it accessible to anyone with the link.',
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole('checkbox', { name: 'Enable time range' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByText("Dashboard variables won't work in public dashboards"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Publish dashboard' }),
|
||||
).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
// ─── TBD coverage — placeholders to fill in when each feature lands ──────
|
||||
//
|
||||
// `test.skip` placeholders for behaviours not yet covered. Replace with
|
||||
// `test` and implement when the corresponding feature ships or the seed
|
||||
// gains the necessary state.
|
||||
|
||||
test('TC-11 edit existing variable — rename', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-rename-variable');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
|
||||
// Hover the row to reveal the edit button (Pylon overlay can intercept,
|
||||
// so dispatchEvent fires the click directly on the React onClick).
|
||||
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
// Editor form mounts; rename and save.
|
||||
const renamed = `tb_env_renamed_${Date.now()}`;
|
||||
const nameInput = dialog.getByPlaceholder('Unique name of the variable');
|
||||
await expect(nameInput).toHaveValue('tb_env');
|
||||
await nameInput.fill(renamed);
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save Variable' })
|
||||
.click({ force: true });
|
||||
|
||||
// Variables bar reflects the rename; the original label is gone.
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(page.getByText(`$${renamed}`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('TC-12 edit existing variable — change type (CUSTOM → QUERY)', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-change-type');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText('cu_single', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
// Change type from Custom to Query and verify the form swaps to the
|
||||
// Query editor (Monaco SQL editor mounts where the comma-separated
|
||||
// values input used to live).
|
||||
await dialog.getByRole('button', { name: /Query/ }).click();
|
||||
await expect(dialog.locator('.monaco-editor').first()).toBeVisible();
|
||||
|
||||
// The previous Custom-specific fields must no longer be visible.
|
||||
await expect(
|
||||
dialog.getByPlaceholder(/Comma separated values/i),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Discard rather than save — saving without filling the new query
|
||||
// would leave a half-configured Query variable. The contract this TC
|
||||
// guards is "type switching swaps the form correctly", which the
|
||||
// assertions above already prove.
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
test('TC-13 edit existing variable — change default textbox value persists across reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-change-default');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
// Update the default textbox value. The Default Value input is the
|
||||
// second/third field (Name first); locate it via its placeholder.
|
||||
const defaultInput = dialog
|
||||
.getByPlaceholder(/Enter default value|Default value/i)
|
||||
.first();
|
||||
await defaultInput.fill('new-default');
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Save Variable' })
|
||||
.click({ force: true });
|
||||
|
||||
// Reload — the new default renders without URL state because it's
|
||||
// now the persisted seed value.
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await page.reload();
|
||||
await expect(page.locator('input[value="new-default"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-14 delete variable — removed from variables bar', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-delete-variable');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
|
||||
// Reuse the existing helper and assert the variables bar reflects
|
||||
// the deletion — `deleteVariableByName` covers the Configure-side
|
||||
// removal; the bar update is the new contract this TC adds.
|
||||
await deleteVariableByName(page, 'tb_env');
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toHaveCount(0);
|
||||
// Sibling textbox is unaffected.
|
||||
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-15 variable name validation — duplicate name keeps Save disabled', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-validate-duplicate');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
await dialog.getByTestId('add-new-variable').click();
|
||||
|
||||
await dialog
|
||||
.getByPlaceholder('Unique name of the variable')
|
||||
.fill('tb_env');
|
||||
await dialog.getByRole('button', { name: 'Textbox' }).click();
|
||||
|
||||
// Save Variable should refuse to enable while the name collides with
|
||||
// an existing variable. Assert the button stays disabled, OR a
|
||||
// validation message surfaces — UI may pick either signal.
|
||||
const saveBtn = dialog.getByRole('button', { name: 'Save Variable' });
|
||||
const errorMsg = dialog.getByText(/already exists|duplicate|in use/i);
|
||||
|
||||
// Either Save is disabled, or an explicit error is shown — both are
|
||||
// valid contracts. `Promise.race` between the two assertions tolerates
|
||||
// whichever the UI provides.
|
||||
await expect.poll(async () => {
|
||||
const disabled = await saveBtn.isDisabled().catch(() => false);
|
||||
const err = await errorMsg.isVisible().catch(() => false);
|
||||
return disabled || err;
|
||||
}).toBeTruthy();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-16 variable name validation — invalid characters / whitespace', async () => {
|
||||
// Names containing spaces, $-prefix, dots, etc. should be rejected
|
||||
// by the validator. Confirm Save Variable stays disabled with an
|
||||
// inline error message.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-17 reorder variables via drag persists `order` in JSON', async () => {
|
||||
// The Variables tab supports drag handles. After a reorder, the
|
||||
// persisted `data.variables[*].order` reflects the new sequence and
|
||||
// the variables bar re-renders accordingly.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-18 add a Dynamic (Beta) variable via Configure → pick seeded attribute', async () => {
|
||||
// Dynamic-variable resolution itself is covered by
|
||||
// `67-variables` TC-15 (seed metric → Dynamic dropdown lists the
|
||||
// namespace → URL state updates). What this TC adds is the Configure
|
||||
// drawer's *Add Variable → Dynamic* form, whose attribute-picker
|
||||
// uses a combobox whose stable locator hasn't been pinned in this
|
||||
// suite yet — leave skipped pending a snapshot pass.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-19 Variable description renders in tooltip / inline metadata', async () => {
|
||||
// `description` field on each variable should be surfaced in the
|
||||
// variables bar tooltip and in the Variables tab's row.
|
||||
});
|
||||
|
||||
// eslint-disable-next-line playwright/expect-expect
|
||||
test.skip('TC-20 Save Variable disabled while query is in flight', async () => {
|
||||
// For a Query variable mid-resolution, Save Variable should be
|
||||
// disabled until the query returns options. Otherwise we'd save
|
||||
// a variable with an empty option list.
|
||||
});
|
||||
|
||||
test('TC-21 cancel-mid-edit variable changes are not persisted', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
test.skip(
|
||||
!hasVariablesHelper,
|
||||
'createVariablesDashboardViaApi helper not available',
|
||||
);
|
||||
|
||||
const id = await seedVariablesDashboard(page, 'cfg-cancel-edit-variable');
|
||||
await page.goto(`/dashboard/${id}`);
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
|
||||
// Open the editor for tb_env and dirty the Name field.
|
||||
const dialog = await openConfigureDrawer(page);
|
||||
await dialog.getByRole('tab', { name: 'Variables' }).click();
|
||||
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
|
||||
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
|
||||
await nameCell.hover();
|
||||
await nameCell
|
||||
.locator(
|
||||
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
|
||||
)
|
||||
.locator('.edit-variable-button')
|
||||
.first()
|
||||
.dispatchEvent('click');
|
||||
|
||||
const nameInput = dialog.getByPlaceholder('Unique name of the variable');
|
||||
await expect(nameInput).toHaveValue('tb_env');
|
||||
await nameInput.fill('SHOULD_NOT_PERSIST');
|
||||
|
||||
// Discard, then re-open the same row. The Name must still be the
|
||||
// original — abandoned edits never reach the persisted JSON.
|
||||
await dialog.getByRole('button', { name: 'Discard' }).click();
|
||||
await dialog.getByRole('button', { name: /close/i }).first().click();
|
||||
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('$SHOULD_NOT_PERSIST', { exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Reopen Configure → tb_env still has the original name.
|
||||
const dialog2 = await openConfigureDrawer(page);
|
||||
await dialog2.getByRole('tab', { name: 'Variables' }).click();
|
||||
await expect(
|
||||
dialog2
|
||||
.getByRole('tabpanel', { name: 'Variables' })
|
||||
.getByText('tb_env', { exact: true })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
247
tests/e2e/tests/dashboards/details/95-edge-cases.spec.ts
Normal file
247
tests/e2e/tests/dashboards/details/95-edge-cases.spec.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '../../../fixtures/auth';
|
||||
import { newAdminContext } from '../../../helpers/auth';
|
||||
import {
|
||||
APM_METRICS_TITLE,
|
||||
authToken,
|
||||
createApmMetricsDashboardViaApi,
|
||||
createDashboardViaApi,
|
||||
createVariablesDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../../../helpers/dashboards';
|
||||
|
||||
const seedIds = new Set<string>();
|
||||
const VARIABLES_TITLE = 'detail-edge-cases-variables';
|
||||
let apmDashboardId = '';
|
||||
let variablesDashboardId = '';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
apmDashboardId = await createApmMetricsDashboardViaApi(page);
|
||||
seedIds.add(apmDashboardId);
|
||||
variablesDashboardId = await createVariablesDashboardViaApi(
|
||||
page,
|
||||
VARIABLES_TITLE,
|
||||
);
|
||||
seedIds.add(variablesDashboardId);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
if (seedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
const token = await authToken(page);
|
||||
for (const id of seedIds) {
|
||||
await deleteDashboardViaApi(ctx.request, id, token);
|
||||
seedIds.delete(id);
|
||||
}
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
|
||||
function encodeVariables(payload: Record<string, unknown>): string {
|
||||
return encodeURIComponent(encodeURIComponent(JSON.stringify(payload)));
|
||||
}
|
||||
|
||||
async function gotoDetail(
|
||||
page: Page,
|
||||
id: string,
|
||||
query = '',
|
||||
): Promise<void> {
|
||||
await page.goto(`/dashboard/${id}${query}`);
|
||||
}
|
||||
|
||||
test.describe('Dashboard Detail Page — Edge Cases', () => {
|
||||
test('TC-01 panels show "No Data" for a far-past time range without pageerror', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const errors: Error[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err));
|
||||
|
||||
await gotoDetail(
|
||||
page,
|
||||
apmDashboardId,
|
||||
'?startTime=1672531200000&endTime=1672531260000',
|
||||
);
|
||||
|
||||
// The dashboard chrome must render with the far-past range applied:
|
||||
// breadcrumb resolves the dashboard title, panel headers render, and the
|
||||
// time-range textbox reflects the URL.
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// known behaviour: with no variable values resolvable in the far-past
|
||||
// window, APM panels stay in a waiting-on-variable state and never
|
||||
// render the uplot "No Data" overlay. The contract this TC really
|
||||
// guards is that the page does not throw — assert no client-side
|
||||
// pageerror was raised.
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-02 nonexistent dashboard ID handled gracefully', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto('/dashboard/nonexistent-id-99999');
|
||||
|
||||
// The chrome (sidebar logo) must always render, regardless of whether
|
||||
// the app redirects to /dashboard or shows an in-place error shell.
|
||||
// The bogus-id breadcrumb must never resolve.
|
||||
await expect(page.getByRole('img', { name: 'SigNoz' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: /dashboard-icon nonexistent-id-99999/,
|
||||
}),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('TC-03 sidebar nav still works after hitting a nonexistent dashboard URL', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await page.goto('/dashboard/nonexistent-id-99999');
|
||||
await expect(page.getByRole('img', { name: 'SigNoz' })).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.nav-item')
|
||||
.filter({ hasText: /^Dashboards$/ })
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-04 variable URL deep-link survives hard reload', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const deepLink = `?variables=${encodeVariables({ q_env: 'otel-demo' })}`;
|
||||
await gotoDetail(page, variablesDashboardId, deepLink);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${VARIABLES_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('$q_env', { exact: true })).toBeVisible();
|
||||
|
||||
await expect(page).toHaveURL(/variables=%257B/);
|
||||
await expect(page).toHaveURL(/otel-demo/);
|
||||
|
||||
// Dropdown index — selects (in DOM order): 0=cu_single, 1=cu_env_all,
|
||||
// 2=cu_services, 3=q_env, 4=q_service, 5=d_namespace.
|
||||
const qEnv = page.getByTestId('variable-select').nth(3);
|
||||
await expect(
|
||||
qEnv.locator('.ant-select-selection-item', { hasText: 'otel-demo' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
|
||||
await expect(page).toHaveURL(/variables=%257B/);
|
||||
await expect(page).toHaveURL(/otel-demo/);
|
||||
await expect(
|
||||
qEnv.locator('.ant-select-selection-item', { hasText: 'otel-demo' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-05 a single broken time range does not crash the dashboard canvas', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const errors: Error[] = [];
|
||||
page.on('pageerror', (err) => errors.push(err));
|
||||
|
||||
// known behaviour: the app may either reject a swapped range
|
||||
// client-side or render error states per-panel — either way, the
|
||||
// dashboard chrome and at least one panel header must still render.
|
||||
await gotoDetail(page, apmDashboardId, '?startTime=999999&endTime=999998');
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Latency', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TC-06 sidebar Dashboards link from detail page navigates to /dashboard', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
await gotoDetail(page, apmDashboardId);
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.nav-item')
|
||||
.filter({ hasText: /^Dashboards$/ })
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard($|\?)/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Deep coverage ───────────────────────────────────────────────────────
|
||||
|
||||
test('TC-07 a 200-character dashboard name renders without breaking layout', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const longName = `LongName-${'x'.repeat(190)}`;
|
||||
const id = await createDashboardViaApi(page, longName);
|
||||
seedIds.add(id);
|
||||
|
||||
await gotoDetail(page, id);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: new RegExp(`dashboard-icon ${longName.slice(0, 30)}`),
|
||||
}),
|
||||
).toBeVisible();
|
||||
// The toolbar must still render — long titles cannot push the toolbar
|
||||
// off-screen or unmount it. Scope to `.right-section` because empty
|
||||
// dashboards render an onboarding canvas with duplicate testids.
|
||||
const toolbar = page.locator('.dashboard-details .right-section');
|
||||
await expect(toolbar.getByTestId('show-drawer')).toBeVisible();
|
||||
await expect(toolbar.getByTestId('add-panel-header')).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-08 special characters in the dashboard name round-trip via URL and breadcrumb', async ({
|
||||
authedPage: page,
|
||||
}) => {
|
||||
const trickyName = `Spec & Chars / "${Date.now()}" — émoji 🎯`;
|
||||
const id = await createDashboardViaApi(page, trickyName);
|
||||
seedIds.add(id);
|
||||
|
||||
await gotoDetail(page, id);
|
||||
|
||||
// The full title must round-trip through the breadcrumb without HTML
|
||||
// entity mangling (`&`, `"` are bugs we'd want to catch).
|
||||
await expect(
|
||||
page.getByText(trickyName, { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
// document.title is set from the dashboard name — confirm it is intact.
|
||||
await expect(page).toHaveTitle(new RegExp('Spec & Chars'));
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,6 @@
|
||||
"outDir": "./dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["tests/**/*.ts", "helpers/**/*.ts", "fixtures/**/*.ts", "playwright.config.ts"],
|
||||
"include": ["tests/**/*.ts", "helpers/**/*.ts", "fixtures/**/*.ts", "bootstrap/**/*.ts", "playwright.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
192
tests/fixtures/golden/otel-demo-logs-golden.jsonl
vendored
Normal file
192
tests/fixtures/golden/otel-demo-logs-golden.jsonl
vendored
Normal file
@@ -0,0 +1,192 @@
|
||||
{"body":"[adservice] Connection established","severity":"INFO","minutes_ago":360,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Connection established","severity":"INFO","minutes_ago":345,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Background job completed","severity":"INFO","minutes_ago":330,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Cache hit","severity":"INFO","minutes_ago":315,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Background job completed","severity":"INFO","minutes_ago":300,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Cache hit","severity":"INFO","minutes_ago":285,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Database query timed out","severity":"ERROR","minutes_ago":270,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Cache hit","severity":"INFO","minutes_ago":255,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Slow response detected","severity":"WARN","minutes_ago":240,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Cache hit","severity":"INFO","minutes_ago":225,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Connection established","severity":"INFO","minutes_ago":210,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Handled request","severity":"INFO","minutes_ago":195,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Cache miss","severity":"WARN","minutes_ago":180,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Background job completed","severity":"INFO","minutes_ago":165,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Connection established","severity":"INFO","minutes_ago":150,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Connection established","severity":"INFO","minutes_ago":135,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Upstream call failed","severity":"ERROR","minutes_ago":120,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Connection established","severity":"INFO","minutes_ago":105,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Background job completed","severity":"INFO","minutes_ago":90,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Background job completed","severity":"INFO","minutes_ago":75,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Background job completed","severity":"INFO","minutes_ago":60,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Cache hit","severity":"INFO","minutes_ago":45,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Retrying upstream call","severity":"WARN","minutes_ago":30,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[adservice] Cache hit","severity":"INFO","minutes_ago":15,"resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"logger.name":"adservice.app"}}
|
||||
{"body":"[cartservice] Connection established","severity":"INFO","minutes_ago":360,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Cache hit","severity":"INFO","minutes_ago":345,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Background job completed","severity":"INFO","minutes_ago":330,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Cache hit","severity":"INFO","minutes_ago":315,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Upstream call failed","severity":"ERROR","minutes_ago":300,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Connection established","severity":"INFO","minutes_ago":285,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Retrying upstream call","severity":"WARN","minutes_ago":270,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Cache hit","severity":"INFO","minutes_ago":255,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Background job completed","severity":"INFO","minutes_ago":240,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Background job completed","severity":"INFO","minutes_ago":225,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Connection established","severity":"INFO","minutes_ago":210,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Connection established","severity":"INFO","minutes_ago":195,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Cache hit","severity":"INFO","minutes_ago":180,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Connection established","severity":"INFO","minutes_ago":165,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Cache hit","severity":"INFO","minutes_ago":150,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Connection established","severity":"INFO","minutes_ago":135,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Retrying upstream call","severity":"WARN","minutes_ago":120,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Cache hit","severity":"INFO","minutes_ago":105,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Handled request","severity":"INFO","minutes_ago":90,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Background job completed","severity":"INFO","minutes_ago":75,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Connection established","severity":"INFO","minutes_ago":60,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Background job completed","severity":"INFO","minutes_ago":45,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Cache hit","severity":"INFO","minutes_ago":30,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[cartservice] Handled request","severity":"INFO","minutes_ago":15,"resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"logger.name":"cartservice.app"}}
|
||||
{"body":"[checkoutservice] Retrying upstream call","severity":"WARN","minutes_ago":360,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Cache hit","severity":"INFO","minutes_ago":345,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Background job completed","severity":"INFO","minutes_ago":330,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Connection established","severity":"INFO","minutes_ago":315,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Connection established","severity":"INFO","minutes_ago":300,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Background job completed","severity":"INFO","minutes_ago":285,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Background job completed","severity":"INFO","minutes_ago":270,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Background job completed","severity":"INFO","minutes_ago":255,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Connection established","severity":"INFO","minutes_ago":240,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Cache hit","severity":"INFO","minutes_ago":225,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Retrying upstream call","severity":"WARN","minutes_ago":210,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Connection established","severity":"INFO","minutes_ago":195,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Handled request","severity":"INFO","minutes_ago":180,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Connection established","severity":"INFO","minutes_ago":165,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Authorization failed","severity":"ERROR","minutes_ago":150,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Cache hit","severity":"INFO","minutes_ago":135,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Cache miss","severity":"WARN","minutes_ago":120,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Background job completed","severity":"INFO","minutes_ago":105,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Connection established","severity":"INFO","minutes_ago":90,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Connection established","severity":"INFO","minutes_ago":75,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Cache hit","severity":"INFO","minutes_ago":60,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Background job completed","severity":"INFO","minutes_ago":45,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Cache hit","severity":"INFO","minutes_ago":30,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[checkoutservice] Slow response detected","severity":"WARN","minutes_ago":15,"resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"logger.name":"checkoutservice.app"}}
|
||||
{"body":"[currencyservice] Background job completed","severity":"INFO","minutes_ago":360,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Cache hit","severity":"INFO","minutes_ago":345,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Connection established","severity":"INFO","minutes_ago":330,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Handled request","severity":"INFO","minutes_ago":315,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Background job completed","severity":"INFO","minutes_ago":300,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Connection established","severity":"INFO","minutes_ago":285,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Handled request","severity":"INFO","minutes_ago":270,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Connection established","severity":"INFO","minutes_ago":255,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Handled request","severity":"INFO","minutes_ago":240,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Connection established","severity":"INFO","minutes_ago":225,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Background job completed","severity":"INFO","minutes_ago":210,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Background job completed","severity":"INFO","minutes_ago":195,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Cache hit","severity":"INFO","minutes_ago":180,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Cache hit","severity":"INFO","minutes_ago":165,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Cache hit","severity":"INFO","minutes_ago":150,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Cache miss","severity":"WARN","minutes_ago":135,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Background job completed","severity":"INFO","minutes_ago":120,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Cache hit","severity":"INFO","minutes_ago":105,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Background job completed","severity":"INFO","minutes_ago":90,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Cache hit","severity":"INFO","minutes_ago":75,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Cache hit","severity":"INFO","minutes_ago":60,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Cache hit","severity":"INFO","minutes_ago":45,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Handled request","severity":"INFO","minutes_ago":30,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[currencyservice] Cache hit","severity":"INFO","minutes_ago":15,"resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"logger.name":"currencyservice.app"}}
|
||||
{"body":"[frontend] Slow response detected","severity":"WARN","minutes_ago":360,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Cache hit","severity":"INFO","minutes_ago":345,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Background job completed","severity":"INFO","minutes_ago":330,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Connection established","severity":"INFO","minutes_ago":315,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Background job completed","severity":"INFO","minutes_ago":300,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Connection established","severity":"INFO","minutes_ago":285,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Background job completed","severity":"INFO","minutes_ago":270,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Connection established","severity":"INFO","minutes_ago":255,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Handled request","severity":"INFO","minutes_ago":240,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Slow response detected","severity":"WARN","minutes_ago":225,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Cache hit","severity":"INFO","minutes_ago":210,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Handled request","severity":"INFO","minutes_ago":195,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Background job completed","severity":"INFO","minutes_ago":180,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Cache miss","severity":"WARN","minutes_ago":165,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Authorization failed","severity":"ERROR","minutes_ago":150,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Connection established","severity":"INFO","minutes_ago":135,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Connection established","severity":"INFO","minutes_ago":120,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Handled request","severity":"INFO","minutes_ago":105,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Cache hit","severity":"INFO","minutes_ago":90,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Background job completed","severity":"INFO","minutes_ago":75,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Connection established","severity":"INFO","minutes_ago":60,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Background job completed","severity":"INFO","minutes_ago":45,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Connection established","severity":"INFO","minutes_ago":30,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[frontend] Background job completed","severity":"INFO","minutes_ago":15,"resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"logger.name":"frontend.app"}}
|
||||
{"body":"[paymentservice] Connection established","severity":"INFO","minutes_ago":360,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Cache hit","severity":"INFO","minutes_ago":345,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Connection established","severity":"INFO","minutes_ago":330,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Handled request","severity":"INFO","minutes_ago":315,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Connection established","severity":"INFO","minutes_ago":300,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Connection established","severity":"INFO","minutes_ago":285,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Slow response detected","severity":"WARN","minutes_ago":270,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Authorization failed","severity":"ERROR","minutes_ago":255,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Handled request","severity":"INFO","minutes_ago":240,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Authorization failed","severity":"ERROR","minutes_ago":225,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Background job completed","severity":"INFO","minutes_ago":210,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Authorization failed","severity":"ERROR","minutes_ago":195,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Cache hit","severity":"INFO","minutes_ago":180,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Cache hit","severity":"INFO","minutes_ago":165,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Retrying upstream call","severity":"WARN","minutes_ago":150,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Handled request","severity":"INFO","minutes_ago":135,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Background job completed","severity":"INFO","minutes_ago":120,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Handled request","severity":"INFO","minutes_ago":105,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Authorization failed","severity":"ERROR","minutes_ago":90,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Connection established","severity":"INFO","minutes_ago":75,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Cache hit","severity":"INFO","minutes_ago":60,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Cache hit","severity":"INFO","minutes_ago":45,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Handled request","severity":"INFO","minutes_ago":30,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[paymentservice] Upstream call failed","severity":"ERROR","minutes_ago":15,"resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"logger.name":"paymentservice.app"}}
|
||||
{"body":"[productcatalogservice] Cache hit","severity":"INFO","minutes_ago":360,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Connection established","severity":"INFO","minutes_ago":345,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Background job completed","severity":"INFO","minutes_ago":330,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Cache hit","severity":"INFO","minutes_ago":315,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Cache hit","severity":"INFO","minutes_ago":300,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Connection established","severity":"INFO","minutes_ago":285,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Cache miss","severity":"WARN","minutes_ago":270,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Connection established","severity":"INFO","minutes_ago":255,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Background job completed","severity":"INFO","minutes_ago":240,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Background job completed","severity":"INFO","minutes_ago":225,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Cache hit","severity":"INFO","minutes_ago":210,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Cache hit","severity":"INFO","minutes_ago":195,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Background job completed","severity":"INFO","minutes_ago":180,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Cache hit","severity":"INFO","minutes_ago":165,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Background job completed","severity":"INFO","minutes_ago":150,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Background job completed","severity":"INFO","minutes_ago":135,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Cache hit","severity":"INFO","minutes_ago":120,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Handled request","severity":"INFO","minutes_ago":105,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Background job completed","severity":"INFO","minutes_ago":90,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Background job completed","severity":"INFO","minutes_ago":75,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Cache hit","severity":"INFO","minutes_ago":60,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Connection established","severity":"INFO","minutes_ago":45,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Cache hit","severity":"INFO","minutes_ago":30,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[productcatalogservice] Background job completed","severity":"INFO","minutes_ago":15,"resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"logger.name":"productcatalogservice.app"}}
|
||||
{"body":"[shippingservice] Database query timed out","severity":"ERROR","minutes_ago":360,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Connection established","severity":"INFO","minutes_ago":345,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Background job completed","severity":"INFO","minutes_ago":330,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Cache hit","severity":"INFO","minutes_ago":315,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Background job completed","severity":"INFO","minutes_ago":300,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Connection established","severity":"INFO","minutes_ago":285,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Handled request","severity":"INFO","minutes_ago":270,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Cache hit","severity":"INFO","minutes_ago":255,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Handled request","severity":"INFO","minutes_ago":240,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Cache miss","severity":"WARN","minutes_ago":225,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Connection established","severity":"INFO","minutes_ago":210,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Connection established","severity":"INFO","minutes_ago":195,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Connection established","severity":"INFO","minutes_ago":180,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Background job completed","severity":"INFO","minutes_ago":165,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Cache hit","severity":"INFO","minutes_ago":150,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Database query timed out","severity":"ERROR","minutes_ago":135,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Background job completed","severity":"INFO","minutes_ago":120,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Cache hit","severity":"INFO","minutes_ago":105,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Handled request","severity":"INFO","minutes_ago":90,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Handled request","severity":"INFO","minutes_ago":75,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Cache hit","severity":"INFO","minutes_ago":60,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Connection established","severity":"INFO","minutes_ago":45,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Background job completed","severity":"INFO","minutes_ago":30,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
{"body":"[shippingservice] Cache miss","severity":"WARN","minutes_ago":15,"resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"logger.name":"shippingservice.app"}}
|
||||
6624
tests/fixtures/golden/otel-demo-metrics-golden.jsonl
vendored
Normal file
6624
tests/fixtures/golden/otel-demo-metrics-golden.jsonl
vendored
Normal file
File diff suppressed because it is too large
Load Diff
180
tests/fixtures/golden/otel-demo-traces-golden.jsonl
vendored
Normal file
180
tests/fixtures/golden/otel-demo-traces-golden.jsonl
vendored
Normal file
@@ -0,0 +1,180 @@
|
||||
{"name":"adservice /ads/get","kind":"SERVER","minutes_ago":360,"duration_ms":380,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"GET","http.route":"/ads/get","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/get","kind":"SERVER","minutes_ago":330,"duration_ms":141,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"GET","http.route":"/ads/get","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/get","kind":"SERVER","minutes_ago":300,"duration_ms":419,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"GET","http.route":"/ads/get","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/get","kind":"SERVER","minutes_ago":270,"duration_ms":421,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"GET","http.route":"/ads/get","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/get","kind":"SERVER","minutes_ago":240,"duration_ms":245,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"GET","http.route":"/ads/get","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/get","kind":"SERVER","minutes_ago":210,"duration_ms":341,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"GET","http.route":"/ads/get","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/get","kind":"SERVER","minutes_ago":180,"duration_ms":403,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"GET","http.route":"/ads/get","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/get","kind":"SERVER","minutes_ago":150,"duration_ms":334,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"GET","http.route":"/ads/get","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/get","kind":"SERVER","minutes_ago":120,"duration_ms":402,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"GET","http.route":"/ads/get","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/get","kind":"SERVER","minutes_ago":90,"duration_ms":140,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"GET","http.route":"/ads/get","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/get","kind":"SERVER","minutes_ago":60,"duration_ms":350,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"GET","http.route":"/ads/get","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/get","kind":"SERVER","minutes_ago":30,"duration_ms":229,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"GET","http.route":"/ads/get","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/list","kind":"SERVER","minutes_ago":360,"duration_ms":421,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"POST","http.route":"/ads/list","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/list","kind":"SERVER","minutes_ago":330,"duration_ms":332,"status":"ERROR","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"POST","http.route":"/ads/list","http.status_code":"500"}}
|
||||
{"name":"adservice /ads/list","kind":"SERVER","minutes_ago":300,"duration_ms":70,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"POST","http.route":"/ads/list","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/list","kind":"SERVER","minutes_ago":270,"duration_ms":464,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"POST","http.route":"/ads/list","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/list","kind":"SERVER","minutes_ago":240,"duration_ms":77,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"POST","http.route":"/ads/list","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/list","kind":"SERVER","minutes_ago":210,"duration_ms":187,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"POST","http.route":"/ads/list","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/list","kind":"SERVER","minutes_ago":180,"duration_ms":278,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"POST","http.route":"/ads/list","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/list","kind":"SERVER","minutes_ago":150,"duration_ms":191,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"POST","http.route":"/ads/list","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/list","kind":"SERVER","minutes_ago":120,"duration_ms":433,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"POST","http.route":"/ads/list","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/list","kind":"SERVER","minutes_ago":90,"duration_ms":191,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"POST","http.route":"/ads/list","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/list","kind":"SERVER","minutes_ago":60,"duration_ms":104,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"POST","http.route":"/ads/list","http.status_code":"200"}}
|
||||
{"name":"adservice /ads/list","kind":"SERVER","minutes_ago":30,"duration_ms":189,"status":"OK","resource_attributes":{"service.name":"adservice","deployment.environment":"production","k8s.namespace.name":"signoz-adservice"},"attributes":{"http.method":"POST","http.route":"/ads/list","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/add","kind":"SERVER","minutes_ago":360,"duration_ms":303,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/add","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/add","kind":"SERVER","minutes_ago":330,"duration_ms":492,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/add","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/add","kind":"SERVER","minutes_ago":300,"duration_ms":271,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/add","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/add","kind":"SERVER","minutes_ago":270,"duration_ms":114,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/add","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/add","kind":"SERVER","minutes_ago":240,"duration_ms":245,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/add","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/add","kind":"SERVER","minutes_ago":210,"duration_ms":171,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/add","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/add","kind":"SERVER","minutes_ago":180,"duration_ms":145,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/add","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/add","kind":"SERVER","minutes_ago":150,"duration_ms":190,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/add","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/add","kind":"SERVER","minutes_ago":120,"duration_ms":166,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/add","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/add","kind":"SERVER","minutes_ago":90,"duration_ms":391,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/add","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/add","kind":"SERVER","minutes_ago":60,"duration_ms":361,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/add","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/add","kind":"SERVER","minutes_ago":30,"duration_ms":456,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/add","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/get","kind":"SERVER","minutes_ago":360,"duration_ms":173,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"GET","http.route":"/cart/get","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/get","kind":"SERVER","minutes_ago":330,"duration_ms":127,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"GET","http.route":"/cart/get","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/get","kind":"SERVER","minutes_ago":300,"duration_ms":342,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"GET","http.route":"/cart/get","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/get","kind":"SERVER","minutes_ago":270,"duration_ms":124,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"GET","http.route":"/cart/get","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/get","kind":"SERVER","minutes_ago":240,"duration_ms":499,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"GET","http.route":"/cart/get","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/get","kind":"SERVER","minutes_ago":210,"duration_ms":168,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"GET","http.route":"/cart/get","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/get","kind":"SERVER","minutes_ago":180,"duration_ms":292,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"GET","http.route":"/cart/get","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/get","kind":"SERVER","minutes_ago":150,"duration_ms":417,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"GET","http.route":"/cart/get","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/get","kind":"SERVER","minutes_ago":120,"duration_ms":107,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"GET","http.route":"/cart/get","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/get","kind":"SERVER","minutes_ago":90,"duration_ms":439,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"GET","http.route":"/cart/get","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/get","kind":"SERVER","minutes_ago":60,"duration_ms":441,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"GET","http.route":"/cart/get","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/get","kind":"SERVER","minutes_ago":30,"duration_ms":223,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"GET","http.route":"/cart/get","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/empty","kind":"SERVER","minutes_ago":360,"duration_ms":420,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/empty","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/empty","kind":"SERVER","minutes_ago":330,"duration_ms":244,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/empty","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/empty","kind":"SERVER","minutes_ago":300,"duration_ms":485,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/empty","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/empty","kind":"SERVER","minutes_ago":270,"duration_ms":189,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/empty","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/empty","kind":"SERVER","minutes_ago":240,"duration_ms":134,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/empty","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/empty","kind":"SERVER","minutes_ago":210,"duration_ms":420,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/empty","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/empty","kind":"SERVER","minutes_ago":180,"duration_ms":456,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/empty","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/empty","kind":"SERVER","minutes_ago":150,"duration_ms":485,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/empty","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/empty","kind":"SERVER","minutes_ago":120,"duration_ms":80,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/empty","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/empty","kind":"SERVER","minutes_ago":90,"duration_ms":291,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/empty","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/empty","kind":"SERVER","minutes_ago":60,"duration_ms":158,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/empty","http.status_code":"200"}}
|
||||
{"name":"cartservice /cart/empty","kind":"SERVER","minutes_ago":30,"duration_ms":56,"status":"OK","resource_attributes":{"service.name":"cartservice","deployment.environment":"production","k8s.namespace.name":"signoz-cartservice"},"attributes":{"http.method":"POST","http.route":"/cart/empty","http.status_code":"200"}}
|
||||
{"name":"checkoutservice /checkout","kind":"SERVER","minutes_ago":360,"duration_ms":378,"status":"OK","resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"checkoutservice /checkout","kind":"SERVER","minutes_ago":330,"duration_ms":82,"status":"OK","resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"checkoutservice /checkout","kind":"SERVER","minutes_ago":300,"duration_ms":102,"status":"OK","resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"checkoutservice /checkout","kind":"SERVER","minutes_ago":270,"duration_ms":249,"status":"ERROR","resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"500"}}
|
||||
{"name":"checkoutservice /checkout","kind":"SERVER","minutes_ago":240,"duration_ms":215,"status":"OK","resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"checkoutservice /checkout","kind":"SERVER","minutes_ago":210,"duration_ms":234,"status":"OK","resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"checkoutservice /checkout","kind":"SERVER","minutes_ago":180,"duration_ms":301,"status":"OK","resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"checkoutservice /checkout","kind":"SERVER","minutes_ago":150,"duration_ms":284,"status":"OK","resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"checkoutservice /checkout","kind":"SERVER","minutes_ago":120,"duration_ms":290,"status":"OK","resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"checkoutservice /checkout","kind":"SERVER","minutes_ago":90,"duration_ms":212,"status":"OK","resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"checkoutservice /checkout","kind":"SERVER","minutes_ago":60,"duration_ms":400,"status":"OK","resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"checkoutservice /checkout","kind":"SERVER","minutes_ago":30,"duration_ms":339,"status":"OK","resource_attributes":{"service.name":"checkoutservice","deployment.environment":"production","k8s.namespace.name":"signoz-checkoutservice"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"currencyservice /currency/convert","kind":"SERVER","minutes_ago":360,"duration_ms":205,"status":"ERROR","resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"http.method":"POST","http.route":"/currency/convert","http.status_code":"500"}}
|
||||
{"name":"currencyservice /currency/convert","kind":"SERVER","minutes_ago":330,"duration_ms":77,"status":"OK","resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"http.method":"POST","http.route":"/currency/convert","http.status_code":"200"}}
|
||||
{"name":"currencyservice /currency/convert","kind":"SERVER","minutes_ago":300,"duration_ms":217,"status":"OK","resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"http.method":"POST","http.route":"/currency/convert","http.status_code":"200"}}
|
||||
{"name":"currencyservice /currency/convert","kind":"SERVER","minutes_ago":270,"duration_ms":348,"status":"OK","resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"http.method":"POST","http.route":"/currency/convert","http.status_code":"200"}}
|
||||
{"name":"currencyservice /currency/convert","kind":"SERVER","minutes_ago":240,"duration_ms":351,"status":"OK","resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"http.method":"POST","http.route":"/currency/convert","http.status_code":"200"}}
|
||||
{"name":"currencyservice /currency/convert","kind":"SERVER","minutes_ago":210,"duration_ms":288,"status":"OK","resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"http.method":"POST","http.route":"/currency/convert","http.status_code":"200"}}
|
||||
{"name":"currencyservice /currency/convert","kind":"SERVER","minutes_ago":180,"duration_ms":83,"status":"OK","resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"http.method":"POST","http.route":"/currency/convert","http.status_code":"200"}}
|
||||
{"name":"currencyservice /currency/convert","kind":"SERVER","minutes_ago":150,"duration_ms":264,"status":"OK","resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"http.method":"POST","http.route":"/currency/convert","http.status_code":"200"}}
|
||||
{"name":"currencyservice /currency/convert","kind":"SERVER","minutes_ago":120,"duration_ms":111,"status":"OK","resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"http.method":"POST","http.route":"/currency/convert","http.status_code":"200"}}
|
||||
{"name":"currencyservice /currency/convert","kind":"SERVER","minutes_ago":90,"duration_ms":349,"status":"OK","resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"http.method":"POST","http.route":"/currency/convert","http.status_code":"200"}}
|
||||
{"name":"currencyservice /currency/convert","kind":"SERVER","minutes_ago":60,"duration_ms":464,"status":"OK","resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"http.method":"POST","http.route":"/currency/convert","http.status_code":"200"}}
|
||||
{"name":"currencyservice /currency/convert","kind":"SERVER","minutes_ago":30,"duration_ms":201,"status":"OK","resource_attributes":{"service.name":"currencyservice","deployment.environment":"production","k8s.namespace.name":"signoz-currencyservice"},"attributes":{"http.method":"POST","http.route":"/currency/convert","http.status_code":"200"}}
|
||||
{"name":"frontend /","kind":"SERVER","minutes_ago":360,"duration_ms":333,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"GET","http.route":"/","http.status_code":"200"}}
|
||||
{"name":"frontend /","kind":"SERVER","minutes_ago":330,"duration_ms":227,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"GET","http.route":"/","http.status_code":"200"}}
|
||||
{"name":"frontend /","kind":"SERVER","minutes_ago":300,"duration_ms":487,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"GET","http.route":"/","http.status_code":"200"}}
|
||||
{"name":"frontend /","kind":"SERVER","minutes_ago":270,"duration_ms":478,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"GET","http.route":"/","http.status_code":"200"}}
|
||||
{"name":"frontend /","kind":"SERVER","minutes_ago":240,"duration_ms":137,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"GET","http.route":"/","http.status_code":"200"}}
|
||||
{"name":"frontend /","kind":"SERVER","minutes_ago":210,"duration_ms":215,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"GET","http.route":"/","http.status_code":"200"}}
|
||||
{"name":"frontend /","kind":"SERVER","minutes_ago":180,"duration_ms":445,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"GET","http.route":"/","http.status_code":"200"}}
|
||||
{"name":"frontend /","kind":"SERVER","minutes_ago":150,"duration_ms":59,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"GET","http.route":"/","http.status_code":"200"}}
|
||||
{"name":"frontend /","kind":"SERVER","minutes_ago":120,"duration_ms":254,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"GET","http.route":"/","http.status_code":"200"}}
|
||||
{"name":"frontend /","kind":"SERVER","minutes_ago":90,"duration_ms":197,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"GET","http.route":"/","http.status_code":"200"}}
|
||||
{"name":"frontend /","kind":"SERVER","minutes_ago":60,"duration_ms":52,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"GET","http.route":"/","http.status_code":"200"}}
|
||||
{"name":"frontend /","kind":"SERVER","minutes_ago":30,"duration_ms":221,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"GET","http.route":"/","http.status_code":"200"}}
|
||||
{"name":"frontend /product","kind":"SERVER","minutes_ago":360,"duration_ms":72,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/product","http.status_code":"200"}}
|
||||
{"name":"frontend /product","kind":"SERVER","minutes_ago":330,"duration_ms":335,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/product","http.status_code":"200"}}
|
||||
{"name":"frontend /product","kind":"SERVER","minutes_ago":300,"duration_ms":292,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/product","http.status_code":"200"}}
|
||||
{"name":"frontend /product","kind":"SERVER","minutes_ago":270,"duration_ms":286,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/product","http.status_code":"200"}}
|
||||
{"name":"frontend /product","kind":"SERVER","minutes_ago":240,"duration_ms":444,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/product","http.status_code":"200"}}
|
||||
{"name":"frontend /product","kind":"SERVER","minutes_ago":210,"duration_ms":183,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/product","http.status_code":"200"}}
|
||||
{"name":"frontend /product","kind":"SERVER","minutes_ago":180,"duration_ms":123,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/product","http.status_code":"200"}}
|
||||
{"name":"frontend /product","kind":"SERVER","minutes_ago":150,"duration_ms":337,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/product","http.status_code":"200"}}
|
||||
{"name":"frontend /product","kind":"SERVER","minutes_ago":120,"duration_ms":373,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/product","http.status_code":"200"}}
|
||||
{"name":"frontend /product","kind":"SERVER","minutes_ago":90,"duration_ms":248,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/product","http.status_code":"200"}}
|
||||
{"name":"frontend /product","kind":"SERVER","minutes_ago":60,"duration_ms":459,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/product","http.status_code":"200"}}
|
||||
{"name":"frontend /product","kind":"SERVER","minutes_ago":30,"duration_ms":90,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/product","http.status_code":"200"}}
|
||||
{"name":"frontend /checkout","kind":"SERVER","minutes_ago":360,"duration_ms":304,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"frontend /checkout","kind":"SERVER","minutes_ago":330,"duration_ms":427,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"frontend /checkout","kind":"SERVER","minutes_ago":300,"duration_ms":130,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"frontend /checkout","kind":"SERVER","minutes_ago":270,"duration_ms":152,"status":"ERROR","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"500"}}
|
||||
{"name":"frontend /checkout","kind":"SERVER","minutes_ago":240,"duration_ms":163,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"frontend /checkout","kind":"SERVER","minutes_ago":210,"duration_ms":73,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"frontend /checkout","kind":"SERVER","minutes_ago":180,"duration_ms":177,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"frontend /checkout","kind":"SERVER","minutes_ago":150,"duration_ms":80,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"frontend /checkout","kind":"SERVER","minutes_ago":120,"duration_ms":440,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"frontend /checkout","kind":"SERVER","minutes_ago":90,"duration_ms":450,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"frontend /checkout","kind":"SERVER","minutes_ago":60,"duration_ms":481,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"frontend /checkout","kind":"SERVER","minutes_ago":30,"duration_ms":219,"status":"OK","resource_attributes":{"service.name":"frontend","deployment.environment":"production","k8s.namespace.name":"signoz-frontend"},"attributes":{"http.method":"POST","http.route":"/checkout","http.status_code":"200"}}
|
||||
{"name":"paymentservice /payment/charge","kind":"SERVER","minutes_ago":360,"duration_ms":381,"status":"OK","resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"http.method":"POST","http.route":"/payment/charge","http.status_code":"200"}}
|
||||
{"name":"paymentservice /payment/charge","kind":"SERVER","minutes_ago":330,"duration_ms":307,"status":"OK","resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"http.method":"POST","http.route":"/payment/charge","http.status_code":"200"}}
|
||||
{"name":"paymentservice /payment/charge","kind":"SERVER","minutes_ago":300,"duration_ms":351,"status":"OK","resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"http.method":"POST","http.route":"/payment/charge","http.status_code":"200"}}
|
||||
{"name":"paymentservice /payment/charge","kind":"SERVER","minutes_ago":270,"duration_ms":384,"status":"OK","resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"http.method":"POST","http.route":"/payment/charge","http.status_code":"200"}}
|
||||
{"name":"paymentservice /payment/charge","kind":"SERVER","minutes_ago":240,"duration_ms":273,"status":"OK","resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"http.method":"POST","http.route":"/payment/charge","http.status_code":"200"}}
|
||||
{"name":"paymentservice /payment/charge","kind":"SERVER","minutes_ago":210,"duration_ms":499,"status":"OK","resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"http.method":"POST","http.route":"/payment/charge","http.status_code":"200"}}
|
||||
{"name":"paymentservice /payment/charge","kind":"SERVER","minutes_ago":180,"duration_ms":80,"status":"ERROR","resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"http.method":"POST","http.route":"/payment/charge","http.status_code":"500"}}
|
||||
{"name":"paymentservice /payment/charge","kind":"SERVER","minutes_ago":150,"duration_ms":186,"status":"OK","resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"http.method":"POST","http.route":"/payment/charge","http.status_code":"200"}}
|
||||
{"name":"paymentservice /payment/charge","kind":"SERVER","minutes_ago":120,"duration_ms":423,"status":"OK","resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"http.method":"POST","http.route":"/payment/charge","http.status_code":"200"}}
|
||||
{"name":"paymentservice /payment/charge","kind":"SERVER","minutes_ago":90,"duration_ms":121,"status":"OK","resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"http.method":"POST","http.route":"/payment/charge","http.status_code":"200"}}
|
||||
{"name":"paymentservice /payment/charge","kind":"SERVER","minutes_ago":60,"duration_ms":451,"status":"OK","resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"http.method":"POST","http.route":"/payment/charge","http.status_code":"200"}}
|
||||
{"name":"paymentservice /payment/charge","kind":"SERVER","minutes_ago":30,"duration_ms":402,"status":"OK","resource_attributes":{"service.name":"paymentservice","deployment.environment":"production","k8s.namespace.name":"signoz-paymentservice"},"attributes":{"http.method":"POST","http.route":"/payment/charge","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/list","kind":"SERVER","minutes_ago":360,"duration_ms":189,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"POST","http.route":"/products/list","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/list","kind":"SERVER","minutes_ago":330,"duration_ms":229,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"POST","http.route":"/products/list","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/list","kind":"SERVER","minutes_ago":300,"duration_ms":59,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"POST","http.route":"/products/list","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/list","kind":"SERVER","minutes_ago":270,"duration_ms":300,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"POST","http.route":"/products/list","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/list","kind":"SERVER","minutes_ago":240,"duration_ms":439,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"POST","http.route":"/products/list","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/list","kind":"SERVER","minutes_ago":210,"duration_ms":478,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"POST","http.route":"/products/list","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/list","kind":"SERVER","minutes_ago":180,"duration_ms":104,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"POST","http.route":"/products/list","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/list","kind":"SERVER","minutes_ago":150,"duration_ms":336,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"POST","http.route":"/products/list","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/list","kind":"SERVER","minutes_ago":120,"duration_ms":335,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"POST","http.route":"/products/list","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/list","kind":"SERVER","minutes_ago":90,"duration_ms":430,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"POST","http.route":"/products/list","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/list","kind":"SERVER","minutes_ago":60,"duration_ms":116,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"POST","http.route":"/products/list","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/list","kind":"SERVER","minutes_ago":30,"duration_ms":75,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"POST","http.route":"/products/list","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/get","kind":"SERVER","minutes_ago":360,"duration_ms":314,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"GET","http.route":"/products/get","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/get","kind":"SERVER","minutes_ago":330,"duration_ms":303,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"GET","http.route":"/products/get","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/get","kind":"SERVER","minutes_ago":300,"duration_ms":174,"status":"ERROR","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"GET","http.route":"/products/get","http.status_code":"500"}}
|
||||
{"name":"productcatalogservice /products/get","kind":"SERVER","minutes_ago":270,"duration_ms":238,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"GET","http.route":"/products/get","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/get","kind":"SERVER","minutes_ago":240,"duration_ms":494,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"GET","http.route":"/products/get","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/get","kind":"SERVER","minutes_ago":210,"duration_ms":394,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"GET","http.route":"/products/get","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/get","kind":"SERVER","minutes_ago":180,"duration_ms":71,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"GET","http.route":"/products/get","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/get","kind":"SERVER","minutes_ago":150,"duration_ms":222,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"GET","http.route":"/products/get","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/get","kind":"SERVER","minutes_ago":120,"duration_ms":386,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"GET","http.route":"/products/get","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/get","kind":"SERVER","minutes_ago":90,"duration_ms":227,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"GET","http.route":"/products/get","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/get","kind":"SERVER","minutes_ago":60,"duration_ms":54,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"GET","http.route":"/products/get","http.status_code":"200"}}
|
||||
{"name":"productcatalogservice /products/get","kind":"SERVER","minutes_ago":30,"duration_ms":456,"status":"OK","resource_attributes":{"service.name":"productcatalogservice","deployment.environment":"production","k8s.namespace.name":"signoz-productcatalogservice"},"attributes":{"http.method":"GET","http.route":"/products/get","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/quote","kind":"SERVER","minutes_ago":360,"duration_ms":317,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/quote","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/quote","kind":"SERVER","minutes_ago":330,"duration_ms":111,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/quote","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/quote","kind":"SERVER","minutes_ago":300,"duration_ms":478,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/quote","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/quote","kind":"SERVER","minutes_ago":270,"duration_ms":75,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/quote","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/quote","kind":"SERVER","minutes_ago":240,"duration_ms":413,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/quote","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/quote","kind":"SERVER","minutes_ago":210,"duration_ms":217,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/quote","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/quote","kind":"SERVER","minutes_ago":180,"duration_ms":160,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/quote","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/quote","kind":"SERVER","minutes_ago":150,"duration_ms":170,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/quote","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/quote","kind":"SERVER","minutes_ago":120,"duration_ms":415,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/quote","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/quote","kind":"SERVER","minutes_ago":90,"duration_ms":448,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/quote","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/quote","kind":"SERVER","minutes_ago":60,"duration_ms":340,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/quote","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/quote","kind":"SERVER","minutes_ago":30,"duration_ms":390,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/quote","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/ship","kind":"SERVER","minutes_ago":360,"duration_ms":96,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/ship","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/ship","kind":"SERVER","minutes_ago":330,"duration_ms":414,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/ship","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/ship","kind":"SERVER","minutes_ago":300,"duration_ms":182,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/ship","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/ship","kind":"SERVER","minutes_ago":270,"duration_ms":116,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/ship","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/ship","kind":"SERVER","minutes_ago":240,"duration_ms":489,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/ship","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/ship","kind":"SERVER","minutes_ago":210,"duration_ms":130,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/ship","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/ship","kind":"SERVER","minutes_ago":180,"duration_ms":394,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/ship","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/ship","kind":"SERVER","minutes_ago":150,"duration_ms":59,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/ship","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/ship","kind":"SERVER","minutes_ago":120,"duration_ms":159,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/ship","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/ship","kind":"SERVER","minutes_ago":90,"duration_ms":432,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/ship","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/ship","kind":"SERVER","minutes_ago":60,"duration_ms":87,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/ship","http.status_code":"200"}}
|
||||
{"name":"shippingservice /shipping/ship","kind":"SERVER","minutes_ago":30,"duration_ms":59,"status":"OK","resource_attributes":{"service.name":"shippingservice","deployment.environment":"production","k8s.namespace.name":"signoz-shippingservice"},"attributes":{"http.method":"POST","http.route":"/shipping/ship","http.status_code":"200"}}
|
||||
389
tests/fixtures/seed_golden_dataset.py
vendored
Normal file
389
tests/fixtures/seed_golden_dataset.py
vendored
Normal file
@@ -0,0 +1,389 @@
|
||||
"""Golden dataset fixture — seeds OTel-demo-shaped metrics, traces, and
|
||||
logs into ClickHouse via the seeder on every test_setup invocation.
|
||||
|
||||
Timestamps are rebased to `now` so panels with default time windows
|
||||
always find data. To refresh the dataset shape on disk, run
|
||||
`uv run python -m fixtures.seed_golden_dataset regenerate`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_GOLDEN_DIR = Path(__file__).resolve().parent / "golden"
|
||||
METRICS_PATH = _GOLDEN_DIR / "otel-demo-metrics-golden.jsonl"
|
||||
TRACES_PATH = _GOLDEN_DIR / "otel-demo-traces-golden.jsonl"
|
||||
LOGS_PATH = _GOLDEN_DIR / "otel-demo-logs-golden.jsonl"
|
||||
|
||||
|
||||
# ─── Generator ───────────────────────────────────────────────────────────
|
||||
|
||||
_SERVICES = [
|
||||
"adservice",
|
||||
"cartservice",
|
||||
"checkoutservice",
|
||||
"currencyservice",
|
||||
"frontend",
|
||||
"paymentservice",
|
||||
"productcatalogservice",
|
||||
"shippingservice",
|
||||
]
|
||||
_OPERATIONS = {
|
||||
"adservice": ["/ads/get", "/ads/list"],
|
||||
"cartservice": ["/cart/add", "/cart/get", "/cart/empty"],
|
||||
"checkoutservice": ["/checkout"],
|
||||
"currencyservice": ["/currency/convert"],
|
||||
"frontend": ["/", "/product", "/checkout"],
|
||||
"paymentservice": ["/payment/charge"],
|
||||
"productcatalogservice": ["/products/list", "/products/get"],
|
||||
"shippingservice": ["/shipping/quote", "/shipping/ship"],
|
||||
}
|
||||
_DB_SERVICES = {"cartservice", "productcatalogservice"}
|
||||
_ENV = "production"
|
||||
_BUCKET_MINUTES = 5
|
||||
_WINDOW_HOURS = 6
|
||||
|
||||
|
||||
def _generate_metrics() -> list[dict]:
|
||||
rng = random.Random(20260511)
|
||||
samples: list[dict] = []
|
||||
n_buckets = (_WINDOW_HOURS * 60) // _BUCKET_MINUTES
|
||||
base_counter = 1000
|
||||
|
||||
for service in _SERVICES:
|
||||
for operation in _OPERATIONS[service]:
|
||||
for status in ("STATUS_CODE_OK", "STATUS_CODE_ERROR"):
|
||||
weight = 9 if status == "STATUS_CODE_OK" else 1
|
||||
counter = base_counter
|
||||
latency_sum = 0
|
||||
for i in range(n_buckets):
|
||||
minutes_ago = (_WINDOW_HOURS * 60) - (i + 1) * _BUCKET_MINUTES
|
||||
bucket_calls = int(
|
||||
weight
|
||||
* (50 + 20 * (1 + i % 12 / 12.0) + rng.randint(0, 10))
|
||||
)
|
||||
counter += bucket_calls
|
||||
latency_sum += bucket_calls * rng.randint(100_000, 500_000)
|
||||
resource_attrs = {
|
||||
"service.name": service,
|
||||
"deployment.environment": _ENV,
|
||||
"k8s.namespace.name": f"signoz-{service}",
|
||||
}
|
||||
point_attrs = {
|
||||
"operation": operation,
|
||||
"status_code": status,
|
||||
"span_kind": "SPAN_KIND_SERVER",
|
||||
}
|
||||
for name, value in (
|
||||
("signoz_calls_total", counter),
|
||||
("signoz_latency_count", counter),
|
||||
("signoz_latency_sum", latency_sum),
|
||||
):
|
||||
samples.append(
|
||||
{
|
||||
"metric_name": name,
|
||||
"minutes_ago": minutes_ago,
|
||||
"value": value,
|
||||
"resource_attributes": resource_attrs,
|
||||
"attributes": point_attrs,
|
||||
"is_monotonic": True,
|
||||
}
|
||||
)
|
||||
if service in _DB_SERVICES:
|
||||
db_counter = 0
|
||||
for i in range(n_buckets):
|
||||
minutes_ago = (_WINDOW_HOURS * 60) - (i + 1) * _BUCKET_MINUTES
|
||||
db_counter += 20 + rng.randint(0, 15)
|
||||
samples.append(
|
||||
{
|
||||
"metric_name": "signoz_db_latency_count",
|
||||
"minutes_ago": minutes_ago,
|
||||
"value": db_counter,
|
||||
"resource_attributes": {
|
||||
"service.name": service,
|
||||
"deployment.environment": _ENV,
|
||||
"k8s.namespace.name": f"signoz-{service}",
|
||||
},
|
||||
"attributes": {
|
||||
"db.system": "postgresql"
|
||||
if service == "cartservice"
|
||||
else "mongodb",
|
||||
},
|
||||
"is_monotonic": True,
|
||||
}
|
||||
)
|
||||
return samples
|
||||
|
||||
|
||||
def _generate_traces() -> list[dict]:
|
||||
rng = random.Random(20260512)
|
||||
samples: list[dict] = []
|
||||
n_buckets = 12
|
||||
for service in _SERVICES:
|
||||
for operation in _OPERATIONS[service]:
|
||||
for i in range(n_buckets):
|
||||
minutes_ago = int(
|
||||
(_WINDOW_HOURS * 60) - i * (_WINDOW_HOURS * 60 / n_buckets)
|
||||
)
|
||||
http_status = "500" if rng.random() < 0.05 else "200"
|
||||
samples.append(
|
||||
{
|
||||
"name": f"{service} {operation}",
|
||||
"kind": "SERVER",
|
||||
"minutes_ago": minutes_ago,
|
||||
"duration_ms": rng.randint(50, 500),
|
||||
"status": "ERROR" if http_status == "500" else "OK",
|
||||
"resource_attributes": {
|
||||
"service.name": service,
|
||||
"deployment.environment": _ENV,
|
||||
"k8s.namespace.name": f"signoz-{service}",
|
||||
},
|
||||
"attributes": {
|
||||
"http.method": "GET"
|
||||
if "get" in operation.lower() or operation == "/"
|
||||
else "POST",
|
||||
"http.route": operation,
|
||||
"http.status_code": http_status,
|
||||
},
|
||||
}
|
||||
)
|
||||
return samples
|
||||
|
||||
|
||||
_LOG_SEVERITIES = [("INFO", 0.85), ("WARN", 0.10), ("ERROR", 0.05)]
|
||||
_LOG_BODIES = {
|
||||
"INFO": ["Handled request", "Cache hit", "Connection established"],
|
||||
"WARN": ["Slow response detected", "Cache miss", "Retrying upstream call"],
|
||||
"ERROR": ["Upstream call failed", "Database query timed out", "Auth failed"],
|
||||
}
|
||||
|
||||
|
||||
def _generate_logs() -> list[dict]:
|
||||
rng = random.Random(20260512)
|
||||
samples: list[dict] = []
|
||||
n_buckets = 24
|
||||
for service in _SERVICES:
|
||||
for i in range(n_buckets):
|
||||
minutes_ago = int(
|
||||
(_WINDOW_HOURS * 60) - i * (_WINDOW_HOURS * 60 / n_buckets)
|
||||
)
|
||||
r = rng.random()
|
||||
cumulative = 0.0
|
||||
severity = "INFO"
|
||||
for name, weight in _LOG_SEVERITIES:
|
||||
cumulative += weight
|
||||
if r < cumulative:
|
||||
severity = name
|
||||
break
|
||||
samples.append(
|
||||
{
|
||||
"body": f"[{service}] {rng.choice(_LOG_BODIES[severity])}",
|
||||
"severity": severity,
|
||||
"minutes_ago": minutes_ago,
|
||||
"resource_attributes": {
|
||||
"service.name": service,
|
||||
"deployment.environment": _ENV,
|
||||
"k8s.namespace.name": f"signoz-{service}",
|
||||
},
|
||||
"attributes": {"logger.name": f"{service}.app"},
|
||||
}
|
||||
)
|
||||
return samples
|
||||
|
||||
|
||||
def _write_jsonl(path: Path, samples: list[dict]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w") as f:
|
||||
for s in samples:
|
||||
f.write(json.dumps(s, separators=(",", ":")))
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def regenerate() -> dict[str, int]:
|
||||
metrics = _generate_metrics()
|
||||
traces = _generate_traces()
|
||||
logs = _generate_logs()
|
||||
_write_jsonl(METRICS_PATH, metrics)
|
||||
_write_jsonl(TRACES_PATH, traces)
|
||||
_write_jsonl(LOGS_PATH, logs)
|
||||
return {"metrics": len(metrics), "traces": len(traces), "logs": len(logs)}
|
||||
|
||||
|
||||
# ─── Loader ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_KIND_TO_INT = {
|
||||
"UNSPECIFIED": 0,
|
||||
"INTERNAL": 1,
|
||||
"SERVER": 2,
|
||||
"CLIENT": 3,
|
||||
"PRODUCER": 4,
|
||||
"CONSUMER": 5,
|
||||
}
|
||||
_STATUS_TO_INT = {"UNSET": 0, "OK": 1, "ERROR": 2}
|
||||
|
||||
|
||||
def _read_jsonl(path: Path) -> Iterator[dict]:
|
||||
with path.open() as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
yield json.loads(line)
|
||||
|
||||
|
||||
def _iso_minus_minutes(now: datetime.datetime, minutes: float) -> str:
|
||||
ts = now - datetime.timedelta(minutes=minutes)
|
||||
return (
|
||||
ts.replace(tzinfo=datetime.timezone.utc)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
def _rebased_metric(sample: dict, now: datetime.datetime) -> dict:
|
||||
out = {k: v for k, v in sample.items() if k != "minutes_ago"}
|
||||
out["timestamp"] = _iso_minus_minutes(now, sample["minutes_ago"])
|
||||
return out
|
||||
|
||||
|
||||
def _rebased_trace(sample: dict, now: datetime.datetime) -> dict:
|
||||
return {
|
||||
"timestamp": _iso_minus_minutes(now, sample["minutes_ago"]),
|
||||
"duration": f"PT{sample['duration_ms'] / 1000:.3f}S",
|
||||
"trace_id": sample.get("trace_id") or os.urandom(16).hex(),
|
||||
"span_id": sample.get("span_id") or os.urandom(8).hex(),
|
||||
"name": sample["name"],
|
||||
"kind": _KIND_TO_INT.get(str(sample.get("kind", "SERVER")).upper(), 2),
|
||||
"status_code": _STATUS_TO_INT.get(
|
||||
str(sample.get("status", "UNSET")).upper(), 0
|
||||
),
|
||||
"resources": sample.get("resource_attributes", {}),
|
||||
"attributes": sample.get("attributes", {}),
|
||||
}
|
||||
|
||||
|
||||
def _rebased_log(sample: dict, now: datetime.datetime) -> dict:
|
||||
return {
|
||||
"timestamp": _iso_minus_minutes(now, sample["minutes_ago"]),
|
||||
"body": sample["body"],
|
||||
"severity_text": str(sample.get("severity", "INFO")).upper(),
|
||||
"resources": sample.get("resource_attributes", {}),
|
||||
"attributes": sample.get("attributes", {}),
|
||||
}
|
||||
|
||||
|
||||
def _post_batches(
|
||||
url: str, rows: Iterator[dict], batch_size: int, timeout: int
|
||||
) -> int:
|
||||
batch: list[dict] = []
|
||||
total = 0
|
||||
for row in rows:
|
||||
batch.append(row)
|
||||
if len(batch) >= batch_size:
|
||||
response = requests.post(url, json=batch, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
total += len(batch)
|
||||
batch = []
|
||||
if batch:
|
||||
response = requests.post(url, json=batch, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
total += len(batch)
|
||||
return total
|
||||
|
||||
|
||||
def seed(
|
||||
seeder_base_url: str,
|
||||
*,
|
||||
batch_size: int = 500,
|
||||
timeout: int = 60,
|
||||
clear_first: bool = True,
|
||||
) -> dict[str, int]:
|
||||
"""Wipe each signal table (via DELETE /telemetry/<signal>) and replay
|
||||
the golden dataset with timestamps rebased to `now`. Each call leaves
|
||||
the stack in the exact state the JSONL files describe — chart-data
|
||||
assertions are reproducible across sessions regardless of how many
|
||||
earlier sessions seeded."""
|
||||
for path in (METRICS_PATH, TRACES_PATH, LOGS_PATH):
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"golden dataset missing at {path} — run "
|
||||
"`uv run python -m fixtures.seed_golden_dataset regenerate`"
|
||||
)
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc).replace(
|
||||
microsecond=0, tzinfo=None
|
||||
)
|
||||
base = seeder_base_url.rstrip("/")
|
||||
if clear_first:
|
||||
for signal in ("metrics", "traces", "logs"):
|
||||
requests.delete(f"{base}/telemetry/{signal}", timeout=timeout).raise_for_status()
|
||||
counts = {
|
||||
"metrics": _post_batches(
|
||||
base + "/telemetry/metrics",
|
||||
(_rebased_metric(s, now) for s in _read_jsonl(METRICS_PATH)),
|
||||
batch_size,
|
||||
timeout,
|
||||
),
|
||||
"traces": _post_batches(
|
||||
base + "/telemetry/traces",
|
||||
(_rebased_trace(s, now) for s in _read_jsonl(TRACES_PATH)),
|
||||
batch_size,
|
||||
timeout,
|
||||
),
|
||||
"logs": _post_batches(
|
||||
base + "/telemetry/logs",
|
||||
(_rebased_log(s, now) for s in _read_jsonl(LOGS_PATH)),
|
||||
batch_size,
|
||||
timeout,
|
||||
),
|
||||
}
|
||||
logger.info("seeded through %s: %s", base, counts)
|
||||
return counts
|
||||
|
||||
|
||||
# ─── Fixture ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(name="golden_dataset", scope="package")
|
||||
def golden_dataset(seeder: types.TestContainerDocker) -> dict[str, int]:
|
||||
"""Seed metrics + traces + logs into the running stack via the
|
||||
seeder. Runs unconditionally on every test_setup invocation so the
|
||||
rebased timestamps always anchor against `now`."""
|
||||
return seed(seeder.host_configs["8080"].base())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
if len(sys.argv) < 2:
|
||||
sys.stderr.write(
|
||||
"usage: seed_golden_dataset.py seed <seeder-base-url> | regenerate\n"
|
||||
)
|
||||
sys.exit(2)
|
||||
cmd = sys.argv[1]
|
||||
if cmd == "regenerate":
|
||||
print(f"wrote {regenerate()}")
|
||||
elif cmd == "seed":
|
||||
if len(sys.argv) != 3:
|
||||
sys.stderr.write(
|
||||
"usage: seed_golden_dataset.py seed <seeder-base-url>\n"
|
||||
)
|
||||
sys.exit(2)
|
||||
print(f"seeded {seed(sys.argv[2])}")
|
||||
else:
|
||||
sys.stderr.write(f"unknown command: {cmd}\n")
|
||||
sys.exit(2)
|
||||
@@ -1,5 +1,12 @@
|
||||
"""HTTP seeder — wraps fixtures.{traces,logs,metrics} so Playwright specs
|
||||
can POST per-test telemetry (tagged `seeder=true`) and DELETE to clear."""
|
||||
"""HTTP seeder — single entrypoint for e2e/integration telemetry.
|
||||
|
||||
POST /telemetry/{metrics,logs,traces} insert into ClickHouse via
|
||||
fixtures.{metrics,logs,traces}. DELETE truncates the signal tables.
|
||||
|
||||
Parallel-safe: every seeded row is tagged `seeder=true`. Tests share
|
||||
the seeded baseline; per-test mutations live in their own dashboards.
|
||||
Only test_teardown should call DELETE — workers must finish first.
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
@@ -10,11 +17,7 @@ import clickhouse_connect
|
||||
from fastapi import FastAPI, HTTPException, Response, status
|
||||
|
||||
from fixtures.logger import setup_logger
|
||||
from fixtures.logs import (
|
||||
Logs,
|
||||
insert_logs_to_clickhouse,
|
||||
truncate_logs_tables,
|
||||
)
|
||||
from fixtures.logs import Logs, insert_logs_to_clickhouse, truncate_logs_tables
|
||||
from fixtures.metrics import (
|
||||
Metrics,
|
||||
insert_metrics_to_clickhouse,
|
||||
@@ -39,7 +42,9 @@ SEEDER_MARKER = {"seeder": "true"}
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
conn = clickhouse_connect.get_client(host=CH_HOST, port=CH_PORT, username=CH_USER, password=CH_PASSWORD)
|
||||
conn = clickhouse_connect.get_client(
|
||||
host=CH_HOST, port=CH_PORT, username=CH_USER, password=CH_PASSWORD
|
||||
)
|
||||
app.state.ch = conn
|
||||
try:
|
||||
yield
|
||||
@@ -64,12 +69,19 @@ def _tag(item: dict[str, Any]) -> dict[str, Any]:
|
||||
return {**item, "resources": resources}
|
||||
|
||||
|
||||
# Metrics payload carries label dicts at the top level, not a `resources`
|
||||
# key — tagging goes on the `resource_attrs` wrapper that Metrics.from_dict
|
||||
# unpacks. Same effect, different key.
|
||||
def _tag_metrics(item: dict[str, Any]) -> dict[str, Any]:
|
||||
resource_attrs = {**(item.get("resource_attrs") or {}), **SEEDER_MARKER}
|
||||
return {**item, "resource_attrs": resource_attrs}
|
||||
# Accept OTLP-style `resource_attributes` / `attributes` or legacy
|
||||
# `resource_attrs` / `labels` interchangeably.
|
||||
resource_attrs = {
|
||||
**(item.get("resource_attrs") or {}),
|
||||
**(item.get("resource_attributes") or {}),
|
||||
**SEEDER_MARKER,
|
||||
}
|
||||
labels = {**(item.get("labels") or {}), **(item.get("attributes") or {})}
|
||||
out = {**item, "resource_attrs": resource_attrs, "labels": labels}
|
||||
out.pop("resource_attributes", None)
|
||||
out.pop("attributes", None)
|
||||
return out
|
||||
|
||||
|
||||
@app.post("/telemetry/traces", status_code=status.HTTP_201_CREATED)
|
||||
@@ -145,3 +157,17 @@ def delete_metrics() -> Response:
|
||||
except Exception as e:
|
||||
logger.exception("truncate failed")
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.post("/seed/golden", status_code=status.HTTP_200_OK)
|
||||
def seed_golden() -> dict[str, int]:
|
||||
"""Re-seed the golden dataset with timestamps rebased to `now`.
|
||||
Called by Playwright globalSetup before every test session so chart
|
||||
assertions land within default panel time windows."""
|
||||
from fixtures import seed_golden_dataset # local import: fast cold-start
|
||||
|
||||
try:
|
||||
return seed_golden_dataset.seed("http://localhost:8080")
|
||||
except Exception as e:
|
||||
logger.exception("golden seed failed")
|
||||
raise HTTPException(500, str(e)) from e
|
||||
|
||||
Reference in New Issue
Block a user