mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-13 21:50:31 +01:00
Compare commits
26 Commits
traceop-re
...
alertmanag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f78bfa1d57 | ||
|
|
20c08b30b3 | ||
|
|
46d69f6ddb | ||
|
|
c8caa657e0 | ||
|
|
31fee32601 | ||
|
|
2362a1d6ec | ||
|
|
023e916446 | ||
|
|
270b05f94d | ||
|
|
e4e40103d5 | ||
|
|
35a498010d | ||
|
|
ed21a51671 | ||
|
|
742b0b86b4 | ||
|
|
13e1b254b5 | ||
|
|
dc579c1001 | ||
|
|
95b7186086 | ||
|
|
096ded8b0e | ||
|
|
14082ee5c7 | ||
|
|
5f36ca2992 | ||
|
|
f0cedec7a8 | ||
|
|
007fedf379 | ||
|
|
4831bea47e | ||
|
|
217ac56cef | ||
|
|
15a1d31844 | ||
|
|
02ab513081 | ||
|
|
7be19bd773 | ||
|
|
084168dad7 |
@@ -4893,10 +4893,6 @@ components:
|
||||
properties:
|
||||
duration:
|
||||
type: string
|
||||
endTime:
|
||||
format: date-time
|
||||
nullable: true
|
||||
type: string
|
||||
repeatOn:
|
||||
items:
|
||||
$ref: '#/components/schemas/RuletypesRepeatOn'
|
||||
@@ -4904,11 +4900,7 @@ components:
|
||||
type: array
|
||||
repeatType:
|
||||
$ref: '#/components/schemas/RuletypesRepeatType'
|
||||
startTime:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- startTime
|
||||
- duration
|
||||
- repeatType
|
||||
type: object
|
||||
@@ -5072,6 +5064,7 @@ components:
|
||||
type: string
|
||||
required:
|
||||
- timezone
|
||||
- startTime
|
||||
type: object
|
||||
RuletypesScheduleType:
|
||||
enum:
|
||||
|
||||
@@ -13,7 +13,6 @@ 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) {
|
||||
@@ -49,7 +48,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, opts.MaintenanceStore, opts.OrgID)
|
||||
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
|
||||
|
||||
} else if opts.Rule.RuleType == ruletypes.RuleTypeProm {
|
||||
|
||||
@@ -73,7 +72,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, opts.MaintenanceStore, opts.OrgID)
|
||||
task = newTask(baserules.TaskTypeProm, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
|
||||
|
||||
} else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly {
|
||||
// create anomaly rule
|
||||
@@ -96,7 +95,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, opts.MaintenanceStore, opts.OrgID)
|
||||
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
|
||||
|
||||
} else {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
|
||||
@@ -210,9 +209,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, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) baserules.Task {
|
||||
func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc) baserules.Task {
|
||||
if taskType == baserules.TaskTypeCh {
|
||||
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify, maintenanceStore, orgID)
|
||||
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify)
|
||||
}
|
||||
return baserules.NewPromRuleTask(name, "", frequency, rules, opts, notify, maintenanceStore, orgID)
|
||||
return baserules.NewPromRuleTask(name, "", frequency, rules, opts, notify)
|
||||
}
|
||||
|
||||
@@ -29,18 +29,6 @@ if (!HTMLElement.prototype.scrollIntoView) {
|
||||
HTMLElement.prototype.scrollIntoView = function (): void {};
|
||||
}
|
||||
|
||||
if (typeof window.IntersectionObserver === 'undefined') {
|
||||
class IntersectionObserverMock {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
(window as any).IntersectionObserver = IntersectionObserverMock;
|
||||
}
|
||||
|
||||
// Patch getComputedStyle to handle CSS parsing errors from @signozhq/* packages.
|
||||
// These packages inject CSS at import time via style-inject / vite-plugin-css-injected-by-js.
|
||||
// jsdom's nwsapi cannot parse some of the injected selectors (e.g. Tailwind's :animate-in),
|
||||
|
||||
@@ -7142,23 +7142,12 @@ 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 {
|
||||
@@ -7340,7 +7329,7 @@ export interface RuletypesScheduleDTO {
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
startTime?: Date;
|
||||
startTime: Date;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
|
||||
@@ -40,7 +40,6 @@ const getTraceV3 = async (
|
||||
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
|
||||
...span,
|
||||
'service.name': span.resource?.['service.name'] || '',
|
||||
timestamp: span.time_unix,
|
||||
}));
|
||||
|
||||
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
|
||||
|
||||
@@ -44,11 +44,7 @@ function HttpStatusBadge({
|
||||
|
||||
const color = getStatusCodeColor(numericStatusCode);
|
||||
|
||||
return (
|
||||
<Badge color={color} variant="outline">
|
||||
{statusCode}
|
||||
</Badge>
|
||||
);
|
||||
return <Badge color={color}>{statusCode}</Badge>;
|
||||
}
|
||||
|
||||
export default HttpStatusBadge;
|
||||
|
||||
@@ -38,7 +38,6 @@ export enum LOCALSTORAGE {
|
||||
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
|
||||
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
|
||||
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
|
||||
TRACE_DETAILS_PREFER_OLD_VIEW = 'TRACE_DETAILS_PREFER_OLD_VIEW',
|
||||
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
|
||||
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
|
||||
}
|
||||
|
||||
@@ -3,7 +3,5 @@ export const USER_PREFERENCES = {
|
||||
NAV_SHORTCUTS: 'nav_shortcuts',
|
||||
LAST_SEEN_CHANGELOG_VERSION: 'last_seen_changelog_version',
|
||||
SPAN_DETAILS_PINNED_ATTRIBUTES: 'span_details_pinned_attributes',
|
||||
SPAN_DETAILS_PREVIEW_ATTRIBUTES: 'span_details_preview_attributes',
|
||||
SPAN_DETAILS_COLOR_BY_ATTRIBUTE: 'span_details_color_by_attribute',
|
||||
SPAN_PERCENTILE_RESOURCE_ATTRIBUTES: 'span_percentile_resource_attributes',
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ 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';
|
||||
|
||||
@@ -24,7 +25,7 @@ import { PlannedDowntimeDeleteModal } from './PlannedDowntimeDeleteModal';
|
||||
import { PlannedDowntimeForm } from './PlannedDowntimeForm';
|
||||
import { PlannedDowntimeList } from './PlannedDowntimeList';
|
||||
import {
|
||||
defautlInitialValues,
|
||||
defaultInitialValues,
|
||||
deleteDowntimeHandler,
|
||||
} from './PlannedDowntimeutils';
|
||||
|
||||
@@ -48,8 +49,8 @@ export function PlannedDowntime(): JSX.Element {
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const [initialValues, setInitialValues] =
|
||||
useState<Partial<RuletypesPlannedMaintenanceDTO & { editMode: boolean }>>(
|
||||
defautlInitialValues,
|
||||
useState<DeepPartial<RuletypesPlannedMaintenanceDTO & { editMode: boolean }>>(
|
||||
defaultInitialValues,
|
||||
);
|
||||
|
||||
const downtimeSchedules = useListDowntimeSchedules();
|
||||
@@ -148,7 +149,7 @@ export function PlannedDowntime(): JSX.Element {
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={(): void => {
|
||||
setInitialValues({ ...defautlInitialValues, editMode: false });
|
||||
setInitialValues({ ...defaultInitialValues, editMode: false });
|
||||
setIsOpen(true);
|
||||
setEditMode(false);
|
||||
form.resetFields();
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} 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,
|
||||
@@ -39,6 +40,7 @@ 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';
|
||||
|
||||
@@ -70,14 +72,13 @@ interface PlannedDowntimeFormData {
|
||||
endTime: dayjs.Dayjs | string;
|
||||
recurrence?: RuletypesRecurrenceDTO | null;
|
||||
alertRules: DefaultOptionType[];
|
||||
recurrenceSelect?: RuletypesRecurrenceDTO;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
const customFormat = DATE_TIME_FORMATS.ORDINAL_DATETIME;
|
||||
|
||||
interface PlannedDowntimeFormProps {
|
||||
initialValues: Partial<
|
||||
initialValues: DeepPartial<
|
||||
RuletypesPlannedMaintenanceDTO & {
|
||||
editMode: boolean;
|
||||
}
|
||||
@@ -89,7 +90,7 @@ interface PlannedDowntimeFormProps {
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
refetchAllSchedules: () => void;
|
||||
isEditMode: boolean;
|
||||
form: FormInstance<any>;
|
||||
form: FormInstance;
|
||||
}
|
||||
|
||||
export function PlannedDowntimeForm(
|
||||
@@ -133,14 +134,13 @@ export function PlannedDowntimeForm(
|
||||
const { notifications } = useNotifications();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const datePickerFooter = (mode: any): any =>
|
||||
const datePickerFooter: DatePickerProps['renderExtraFooter'] = (mode) =>
|
||||
mode === 'time' ? (
|
||||
<span style={{ color: 'gray' }}>Please select the time</span>
|
||||
) : null;
|
||||
|
||||
const saveHanlder = useCallback(
|
||||
const saveHandler = useCallback(
|
||||
async (values: PlannedDowntimeFormData) => {
|
||||
const shouldKeepLocalTime = !isEditMode;
|
||||
const data: RuletypesPostablePlannedMaintenanceDTO = {
|
||||
alertIds: values.alertRules
|
||||
.map((alert) => alert.value)
|
||||
@@ -152,7 +152,6 @@ export function PlannedDowntimeForm(
|
||||
values.startTime,
|
||||
timezoneInitialValue,
|
||||
values.timezone,
|
||||
shouldKeepLocalTime,
|
||||
),
|
||||
),
|
||||
timezone: values.timezone as string,
|
||||
@@ -162,7 +161,6 @@ export function PlannedDowntimeForm(
|
||||
values.endTime,
|
||||
timezoneInitialValue,
|
||||
values.timezone,
|
||||
shouldKeepLocalTime,
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
@@ -203,38 +201,24 @@ export function PlannedDowntimeForm(
|
||||
],
|
||||
);
|
||||
const onFinish = async (values: PlannedDowntimeFormData): Promise<void> => {
|
||||
const { recurrence } = values;
|
||||
const recurrenceData =
|
||||
values?.recurrence?.repeatType === recurrenceOptions.doesNotRepeat.value
|
||||
!recurrence ||
|
||||
recurrence.repeatType === recurrenceOptions.doesNotRepeat.value
|
||||
? undefined
|
||||
: {
|
||||
duration: values.recurrence?.duration
|
||||
? `${values.recurrence?.duration}${durationUnit}`
|
||||
duration: recurrence.duration
|
||||
? `${recurrence.duration}${durationUnit}`
|
||||
: undefined,
|
||||
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,
|
||||
repeatOn: recurrence.repeatOn?.length ? recurrence.repeatOn : undefined,
|
||||
repeatType: recurrence.repeatType,
|
||||
};
|
||||
|
||||
const payloadValues = {
|
||||
...values,
|
||||
recurrence: recurrenceData as RuletypesRecurrenceDTO | undefined,
|
||||
};
|
||||
await saveHanlder(payloadValues);
|
||||
await saveHandler(payloadValues);
|
||||
};
|
||||
|
||||
const formValidationRules = [
|
||||
@@ -287,7 +271,7 @@ export function PlannedDowntimeForm(
|
||||
: '',
|
||||
recurrence: {
|
||||
...initialValues.schedule?.recurrence,
|
||||
repeatType: (!isScheduleRecurring(initialValues?.schedule)
|
||||
repeatType: (!isScheduleRecurring(initialValues.schedule)
|
||||
? recurrenceOptions.doesNotRepeat.value
|
||||
: initialValues.schedule?.recurrence
|
||||
?.repeatType) as RuletypesRecurrenceDTO['repeatType'],
|
||||
@@ -317,7 +301,6 @@ export function PlannedDowntimeForm(
|
||||
const getTimezoneFormattedTime = (
|
||||
time: string | dayjs.Dayjs,
|
||||
timeZone?: string,
|
||||
isEditMode?: boolean,
|
||||
format?: string,
|
||||
): string => {
|
||||
if (!time) {
|
||||
@@ -326,20 +309,11 @@ export function PlannedDowntimeForm(
|
||||
if (!timeZone) {
|
||||
return dayjs(time).format(format);
|
||||
}
|
||||
return dayjs(time).tz(timeZone, isEditMode).format(format);
|
||||
return dayjs(time).tz(timeZone).format(format);
|
||||
};
|
||||
|
||||
const startTimeText = useMemo((): string => {
|
||||
let startTime = formData?.startTime;
|
||||
if (recurrenceType !== recurrenceOptions.doesNotRepeat.value) {
|
||||
startTime =
|
||||
(formData?.recurrence?.startTime
|
||||
? dayjs(formData.recurrence.startTime).toISOString()
|
||||
: '') ||
|
||||
formData?.startTime ||
|
||||
'';
|
||||
}
|
||||
|
||||
let startTime = formData.startTime;
|
||||
if (!startTime) {
|
||||
return '';
|
||||
}
|
||||
@@ -349,7 +323,6 @@ export function PlannedDowntimeForm(
|
||||
startTime,
|
||||
timezoneInitialValue,
|
||||
formData?.timezone,
|
||||
!isEditMode,
|
||||
);
|
||||
}
|
||||
const daysOfWeek = formData?.recurrence?.repeatOn;
|
||||
@@ -357,21 +330,16 @@ 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,
|
||||
);
|
||||
|
||||
@@ -388,21 +356,10 @@ export function PlannedDowntimeForm(
|
||||
default:
|
||||
return `Scheduled for ${formattedStartDate} starting at ${formattedStartTime}.`;
|
||||
}
|
||||
}, [formData, recurrenceType, isEditMode, timezoneInitialValue]);
|
||||
}, [formData, recurrenceType, 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 '';
|
||||
}
|
||||
@@ -412,25 +369,21 @@ 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, recurrenceType, isEditMode, timezoneInitialValue]);
|
||||
}, [formData, timezoneInitialValue]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -465,7 +418,7 @@ export function PlannedDowntimeForm(
|
||||
name="startTime"
|
||||
rules={formValidationRules}
|
||||
className={!isEmpty(startTimeText) ? 'formItemWithBullet' : ''}
|
||||
getValueProps={(value): any => ({
|
||||
getValueProps={(value) => ({
|
||||
value: value ? dayjs(value).tz(timezoneInitialValue) : undefined,
|
||||
})}
|
||||
>
|
||||
@@ -546,7 +499,7 @@ export function PlannedDowntimeForm(
|
||||
},
|
||||
]}
|
||||
className={!isEmpty(endTimeText) ? 'formItemWithBullet' : ''}
|
||||
getValueProps={(value): any => ({
|
||||
getValueProps={(value) => ({
|
||||
value: value ? dayjs(value).tz(timezoneInitialValue) : undefined,
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import React, { 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,8 +18,9 @@ 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,
|
||||
@@ -27,7 +28,6 @@ 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 | null, string | undefined | null];
|
||||
timeframe: [string | undefined, string | undefined];
|
||||
repeats?: RuletypesRecurrenceDTO | null;
|
||||
updated_at?: string;
|
||||
updated_by_name?: string;
|
||||
@@ -192,7 +192,12 @@ export function CollapseListContent({
|
||||
),
|
||||
)}
|
||||
{renderItems('Timezone', <Typography>{timezone || '-'}</Typography>)}
|
||||
{renderItems('Repeats', <Typography>{recurrenceInfo(repeats)}</Typography>)}
|
||||
{renderItems(
|
||||
'Repeats',
|
||||
<Typography>
|
||||
{recurrenceInfo(timeframe[0], timeframe[1], repeats)}
|
||||
</Typography>,
|
||||
)}
|
||||
{renderItems(
|
||||
'Alerts silenced',
|
||||
alertOptions?.length ? (
|
||||
@@ -212,7 +217,7 @@ export function CollapseListContent({
|
||||
export function CustomCollapseList(
|
||||
props: DowntimeSchedulesTableData & {
|
||||
setInitialValues: React.Dispatch<
|
||||
React.SetStateAction<Partial<RuletypesPlannedMaintenanceDTO>>
|
||||
React.SetStateAction<DeepPartial<RuletypesPlannedMaintenanceDTO>>
|
||||
>;
|
||||
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handleDeleteDowntime: (id: string, name: string) => void;
|
||||
@@ -283,9 +288,7 @@ export function CustomCollapseList(
|
||||
schedule?.startTime?.toString(),
|
||||
typeof endTime === 'string' ? endTime : endTime?.toString(),
|
||||
]}
|
||||
repeats={
|
||||
schedule?.recurrence as RuletypesRecurrenceDTO | null | undefined
|
||||
}
|
||||
repeats={schedule?.recurrence}
|
||||
updated_at={updatedAt ? dayjs(updatedAt).toISOString() : ''}
|
||||
updated_by_name={defaultTo(updatedBy, '')}
|
||||
alertOptions={alertOptions}
|
||||
@@ -320,7 +323,7 @@ export function PlannedDowntimeList({
|
||||
>;
|
||||
alertOptions: DefaultOptionType[];
|
||||
setInitialValues: React.Dispatch<
|
||||
React.SetStateAction<Partial<RuletypesPlannedMaintenanceDTO>>
|
||||
React.SetStateAction<DeepPartial<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 type {
|
||||
import {
|
||||
DeleteDowntimeScheduleByIDPathParameters,
|
||||
RenderErrorResponseDTO,
|
||||
RuletypesPlannedMaintenanceDTO,
|
||||
@@ -14,6 +14,7 @@ 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;
|
||||
|
||||
@@ -60,13 +61,15 @@ export const getAlertOptionsFromIds = (
|
||||
);
|
||||
|
||||
export const recurrenceInfo = (
|
||||
startTime?: string,
|
||||
endTime?: string,
|
||||
recurrence?: RuletypesRecurrenceDTO | null,
|
||||
): string => {
|
||||
if (!recurrence) {
|
||||
return 'No';
|
||||
}
|
||||
|
||||
const { startTime, duration, repeatOn, repeatType, endTime } = recurrence;
|
||||
const { duration, repeatOn, repeatType } = recurrence;
|
||||
|
||||
const formattedStartTime = startTime
|
||||
? formatDateTime(dayjs(startTime).toISOString())
|
||||
@@ -80,7 +83,7 @@ export const recurrenceInfo = (
|
||||
return `Repeats - ${repeatType} ${weeklyRepeatString} from ${formattedStartTime} ${formattedEndTime} ${durationString}`;
|
||||
};
|
||||
|
||||
export const defautlInitialValues: Partial<
|
||||
export const defaultInitialValues: DeepPartial<
|
||||
RuletypesPlannedMaintenanceDTO & { editMode: boolean }
|
||||
> = {
|
||||
name: '',
|
||||
@@ -210,39 +213,17 @@ 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,
|
||||
}: Partial<
|
||||
}: DeepPartial<
|
||||
RuletypesPlannedMaintenanceDTO & {
|
||||
editMode: boolean;
|
||||
}
|
||||
>): string | dayjs.Dayjs => {
|
||||
if (kind === 'fixed') {
|
||||
return schedule?.endTime ? dayjs(schedule.endTime).toISOString() : '';
|
||||
}
|
||||
|
||||
return schedule?.recurrence?.endTime
|
||||
? dayjs(schedule.recurrence.endTime).toISOString()
|
||||
: '';
|
||||
};
|
||||
>): string | dayjs.Dayjs =>
|
||||
schedule?.endTime ? dayjs(schedule.endTime).toISOString() : '';
|
||||
|
||||
export const isScheduleRecurring = (
|
||||
schedule?: RuletypesPlannedMaintenanceDTO['schedule'] | null,
|
||||
schedule?: DeepPartial<RuletypesPlannedMaintenanceDTO['schedule']> | null,
|
||||
): boolean => (schedule ? !isEmpty(schedule?.recurrence) : false);
|
||||
|
||||
function convertUtcOffsetToTimezoneOffset(offsetMinutes: number): string {
|
||||
@@ -272,7 +253,6 @@ 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);
|
||||
@@ -280,5 +260,5 @@ export function handleTimeConversion(
|
||||
const formattedTime = formatWithTimezone(initialTime, timezone);
|
||||
return timezoneChanged
|
||||
? formattedTime
|
||||
: dayjs(dateValue).tz(timezone, shouldKeepLocalTime).format();
|
||||
: dayjs(dateValue).tz(timezone).format();
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
export const buildSchedule = (
|
||||
schedule: Partial<RuletypesScheduleDTO>,
|
||||
schedule: RuletypesScheduleDTO,
|
||||
): RuletypesScheduleDTO => ({
|
||||
timezone: schedule?.timezone ?? '',
|
||||
startTime: schedule?.startTime,
|
||||
@@ -17,16 +17,13 @@ export const buildSchedule = (
|
||||
});
|
||||
|
||||
export const createMockDowntime = (
|
||||
overrides: Partial<RuletypesPlannedMaintenanceDTO>,
|
||||
overrides: Partial<RuletypesPlannedMaintenanceDTO> &
|
||||
Pick<RuletypesPlannedMaintenanceDTO, 'schedule'>,
|
||||
): RuletypesPlannedMaintenanceDTO => ({
|
||||
id: overrides.id ?? '0',
|
||||
name: overrides.name ?? '',
|
||||
description: overrides.description ?? '',
|
||||
schedule: buildSchedule({
|
||||
timezone: 'UTC',
|
||||
startTime: new Date('2024-01-01'),
|
||||
...overrides.schedule,
|
||||
}),
|
||||
schedule: overrides.schedule,
|
||||
alertIds: overrides.alertIds ?? [],
|
||||
createdAt: overrides.createdAt,
|
||||
createdBy: overrides.createdBy ?? '',
|
||||
|
||||
@@ -218,7 +218,7 @@ function SpanLogs({
|
||||
<Virtuoso
|
||||
className="span-logs-virtuoso"
|
||||
key="span-logs-virtuoso"
|
||||
style={{ height: '100%' }}
|
||||
style={logs.length <= 35 ? { height: `calc(${logs.length} * 22px)` } : {}}
|
||||
data={logs}
|
||||
totalCount={logs.length}
|
||||
itemContent={getItemContent}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
.span-logs {
|
||||
margin-inline: 16px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100% - 64px - 55px - 56px);
|
||||
|
||||
&-virtuoso {
|
||||
background: color-mix(in srgb, var(--bg-robin-200) 4%, transparent);
|
||||
}
|
||||
&-list-container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
|
||||
.logs-loading-skeleton {
|
||||
height: 100%;
|
||||
|
||||
@@ -68,10 +68,6 @@
|
||||
border-left: unset;
|
||||
border-radius: 0px 4px 4px 0px;
|
||||
}
|
||||
|
||||
.new-view-btn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.second-row {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation, useRouteMatch } from 'react-router-dom';
|
||||
import { Skeleton, Tooltip } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Button, Skeleton, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import removeLocalStorageKey from 'api/browser/localstorage/remove';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import dayjs from 'dayjs';
|
||||
import history from 'lib/history';
|
||||
@@ -64,51 +60,18 @@ function TraceMetadata(props: ITraceMetadataProps): JSX.Element {
|
||||
}
|
||||
};
|
||||
|
||||
const isOnOldRoute = !!useRouteMatch({
|
||||
path: ROUTES.TRACE_DETAIL_OLD,
|
||||
exact: true,
|
||||
});
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
const handleSwitchToNewView = (): void => {
|
||||
removeLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW);
|
||||
history.replace({
|
||||
pathname: `/trace/${traceID}`,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
state: location.state,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="trace-metadata">
|
||||
<section className="metadata-info">
|
||||
<div className="first-row">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className="previous-btn"
|
||||
prefix={<ArrowLeft size={14} />}
|
||||
onClick={handlePreviousBtnClick}
|
||||
/>
|
||||
<Button className="previous-btn" onClick={handlePreviousBtnClick}>
|
||||
<ArrowLeft size={14} />
|
||||
</Button>
|
||||
<div className="trace-name">
|
||||
<DraftingCompass size={14} className="drafting" />
|
||||
<Typography.Text className="trace-id">Trace ID</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text className="trace-id-value">{traceID}</Typography.Text>
|
||||
{isOnOldRoute && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="md"
|
||||
className="new-view-btn"
|
||||
onClick={handleSwitchToNewView}
|
||||
>
|
||||
New view
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDataLoading && (
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
import { AxiosError } from 'axios';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { UserPreference } from 'types/api/preferences/preference';
|
||||
import { showErrorNotification } from 'utils/error';
|
||||
|
||||
import { useLocalStorage } from '../useLocalStorage';
|
||||
|
||||
@@ -10,25 +19,59 @@ interface UsePinnedAttributesReturn {
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 trace-details pinned-attributes hook. localStorage-only.
|
||||
* Hook for managing pinned span attributes with backend persistence.
|
||||
* Falls back to localStorage during initial load and handles migration.
|
||||
*
|
||||
* NOTE: V2 used to also persist to the user-pref API
|
||||
* (`span_details_pinned_attributes`) but V3 now owns that key with a different
|
||||
* (nested-path) format. V2 is isolated to localStorage so it doesn't fight V3
|
||||
* over the same backend value. See `useMigratePinnedAttributes` in V3.
|
||||
* @param availableAttributes - Object keys of the current span's flattened attributes
|
||||
* @returns Object with pinned state, toggle function, and check function
|
||||
*/
|
||||
export function usePinnedAttributes(
|
||||
availableAttributes: string[],
|
||||
): UsePinnedAttributesReturn {
|
||||
const [pinnedKeys, setPinnedKeys] = useLocalStorage<string[]>(
|
||||
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
||||
const { notifications } = useNotifications();
|
||||
// API mutation for updating preferences
|
||||
const { mutate: updateUserPreferenceMutation } = useMutation(
|
||||
updateUserPreference,
|
||||
{
|
||||
onError: (error) => {
|
||||
showErrorNotification(notifications, error as AxiosError);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Local state for optimistic updates
|
||||
const [pinnedKeys, setPinnedKeys] = useState<string[]>([]);
|
||||
|
||||
// Get localStorage fallback for initial load
|
||||
const [localStoragePinnedKeys] = useLocalStorage<string[]>(
|
||||
LOCALSTORAGE.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
[],
|
||||
);
|
||||
|
||||
// Initialize from user preferences when loaded
|
||||
useEffect(() => {
|
||||
if (userPreferences !== null) {
|
||||
const preference = userPreferences.find(
|
||||
(pref) => pref.name === USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
);
|
||||
|
||||
if (preference?.value) {
|
||||
// use backend data
|
||||
setPinnedKeys(preference.value as string[]);
|
||||
} else if (localStoragePinnedKeys.length > 0) {
|
||||
// use local storage data
|
||||
setPinnedKeys(localStoragePinnedKeys);
|
||||
}
|
||||
}
|
||||
}, [userPreferences, localStoragePinnedKeys]);
|
||||
|
||||
// Create pinned attributes state from stored keys, filtering by available attributes
|
||||
const pinnedAttributes = useMemo(
|
||||
(): Record<string, boolean> =>
|
||||
pinnedKeys.reduce(
|
||||
(acc, key) => {
|
||||
// Only include if the attribute exists in the current span
|
||||
if (availableAttributes.includes(key)) {
|
||||
acc[key] = true;
|
||||
}
|
||||
@@ -39,17 +82,38 @@ export function usePinnedAttributes(
|
||||
[pinnedKeys, availableAttributes],
|
||||
);
|
||||
|
||||
// Toggle pin state for an attribute
|
||||
const togglePin = useCallback(
|
||||
(attributeKey: string): void => {
|
||||
setPinnedKeys((prev) =>
|
||||
prev.includes(attributeKey)
|
||||
? prev.filter((k) => k !== attributeKey)
|
||||
: [...prev, attributeKey],
|
||||
const currentlyPinned = pinnedKeys.includes(attributeKey);
|
||||
const newPinnedKeys = currentlyPinned
|
||||
? pinnedKeys.filter((key) => key !== attributeKey)
|
||||
: [...pinnedKeys, attributeKey];
|
||||
|
||||
// Optimistically update local state for instant UI feedback
|
||||
setPinnedKeys(newPinnedKeys);
|
||||
|
||||
updateUserPreferenceInContext({
|
||||
name: USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
value: newPinnedKeys,
|
||||
} as UserPreference);
|
||||
|
||||
// Save to localStorage immediately for offline resilience
|
||||
setLocalStorageApi(
|
||||
LOCALSTORAGE.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
JSON.stringify(newPinnedKeys),
|
||||
);
|
||||
|
||||
// Make the API call in the background
|
||||
updateUserPreferenceMutation({
|
||||
name: USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
value: newPinnedKeys,
|
||||
});
|
||||
},
|
||||
[setPinnedKeys],
|
||||
[pinnedKeys, updateUserPreferenceInContext, updateUserPreferenceMutation],
|
||||
);
|
||||
|
||||
// Check if an attribute is pinned
|
||||
const isPinned = useCallback(
|
||||
(attributeKey: string): boolean => pinnedAttributes[attributeKey] === true,
|
||||
[pinnedAttributes],
|
||||
|
||||
@@ -12,11 +12,8 @@ const useGetTraceFlamegraph = (
|
||||
): UseLicense =>
|
||||
useQuery({
|
||||
queryFn: () => getTraceFlamegraph(props),
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH,
|
||||
props.traceId,
|
||||
props.selectFields,
|
||||
],
|
||||
// if any of the props changes then we need to trigger an API call as the older data will be obsolete
|
||||
queryKey: [REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH, props.traceId],
|
||||
enabled: !!props.traceId,
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: false,
|
||||
|
||||
@@ -15,7 +15,6 @@ const useGetTraceV3 = (props: GetTraceV3PayloadProps): UseTraceV3 =>
|
||||
props.traceId,
|
||||
props.selectedSpanId,
|
||||
props.isSelectedSpanIDUnCollapsed,
|
||||
props.aggregations,
|
||||
],
|
||||
enabled: !!props.traceId,
|
||||
keepPreviousData: true,
|
||||
|
||||
@@ -1,25 +1,5 @@
|
||||
import { Redirect, useParams } from 'react-router-dom';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import TraceDetailsV3 from '../TraceDetailsV3';
|
||||
|
||||
export default function TraceDetailV3Page(): JSX.Element {
|
||||
const { id } = useParams<TraceDetailV2URLProps>();
|
||||
const preferOld =
|
||||
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW) === 'true';
|
||||
|
||||
if (preferOld) {
|
||||
return (
|
||||
<Redirect
|
||||
to={{
|
||||
pathname: `/trace-old/${id}`,
|
||||
search: window.location.search,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <TraceDetailsV3 />;
|
||||
}
|
||||
|
||||
@@ -10,14 +10,16 @@ import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
|
||||
import { useTraceContext } from '../../contexts/TraceContext';
|
||||
import { AGGREGATIONS } from '../../utils/aggregations';
|
||||
|
||||
import './AnalyticsPanel.styles.scss';
|
||||
|
||||
interface AnalyticsPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
serviceExecTime?: Record<string, number>;
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
// TODO: Re-enable when backend provides per-service span counts
|
||||
// spans?: Span[];
|
||||
}
|
||||
|
||||
const PANEL_WIDTH = 350;
|
||||
@@ -28,46 +30,41 @@ const PANEL_MARGIN_BOTTOM = 50;
|
||||
function AnalyticsPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
serviceExecTime = {},
|
||||
traceStartTime = 0,
|
||||
traceEndTime = 0,
|
||||
}: AnalyticsPanelProps): JSX.Element | null {
|
||||
const { getAggregationMap } = useTraceContext();
|
||||
|
||||
const execTimePct = useMemo(
|
||||
() => getAggregationMap(AGGREGATIONS.EXEC_TIME_PCT),
|
||||
[getAggregationMap],
|
||||
);
|
||||
|
||||
const spanCounts = useMemo(
|
||||
() => getAggregationMap(AGGREGATIONS.SPAN_COUNT),
|
||||
[getAggregationMap],
|
||||
);
|
||||
const spread = traceEndTime - traceStartTime;
|
||||
|
||||
const execTimeRows = useMemo(() => {
|
||||
if (!execTimePct) {
|
||||
if (spread <= 0) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(execTimePct)
|
||||
.map(([group, percentage]) => ({
|
||||
group,
|
||||
percentage,
|
||||
color: generateColor(group, themeColors.traceDetailColorsV3),
|
||||
return Object.entries(serviceExecTime)
|
||||
.map(([service, duration]) => ({
|
||||
service,
|
||||
percentage: (duration * 100) / spread,
|
||||
color: generateColor(service, themeColors.traceDetailColorsV3),
|
||||
}))
|
||||
.sort((a, b) => b.percentage - a.percentage);
|
||||
}, [execTimePct]);
|
||||
}, [serviceExecTime, spread]);
|
||||
|
||||
const spanCountRows = useMemo(() => {
|
||||
if (!spanCounts) {
|
||||
return [];
|
||||
}
|
||||
const max = Math.max(...Object.values(spanCounts), 1);
|
||||
return Object.entries(spanCounts)
|
||||
.map(([group, count]) => ({
|
||||
group,
|
||||
count,
|
||||
max,
|
||||
color: generateColor(group, themeColors.traceDetailColorsV3),
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}, [spanCounts]);
|
||||
// const spanCountRows = useMemo(() => {
|
||||
// const counts: Record<string, number> = {};
|
||||
// for (const span of spans) {
|
||||
// const name = span.serviceName || 'unknown';
|
||||
// counts[name] = (counts[name] || 0) + 1;
|
||||
// }
|
||||
// return Object.entries(counts)
|
||||
// .map(([service, count]) => ({
|
||||
// service,
|
||||
// count,
|
||||
// color: generateColor(service, themeColors.traceDetailColorsV3),
|
||||
// }))
|
||||
// .sort((a, b) => b.count - a.count);
|
||||
// }, [spans]);
|
||||
|
||||
// const maxSpanCount = spanCountRows[0]?.count || 1;
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
@@ -106,9 +103,11 @@ function AnalyticsPanel({
|
||||
<TabsTrigger value="exec-time" variant="secondary">
|
||||
% exec time
|
||||
</TabsTrigger>
|
||||
{/* TODO: Enable when backend provides per-service span counts
|
||||
<TabsTrigger value="spans" variant="secondary">
|
||||
Spans
|
||||
</TabsTrigger>
|
||||
*/}
|
||||
</TabsList>
|
||||
|
||||
<div className="analytics-panel__tabs-scroll">
|
||||
@@ -117,17 +116,17 @@ function AnalyticsPanel({
|
||||
{execTimeRows.map((row) => (
|
||||
<>
|
||||
<div
|
||||
key={`${row.group}-dot`}
|
||||
key={`${row.service}-dot`}
|
||||
className="analytics-panel__dot"
|
||||
style={{ backgroundColor: row.color }}
|
||||
/>
|
||||
<span
|
||||
key={`${row.group}-name`}
|
||||
key={`${row.service}-name`}
|
||||
className="analytics-panel__service-name"
|
||||
>
|
||||
{row.group}
|
||||
{row.service}
|
||||
</span>
|
||||
<div key={`${row.group}-bar`} className="analytics-panel__bar-cell">
|
||||
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
|
||||
<div className="analytics-panel__bar">
|
||||
<div
|
||||
className="analytics-panel__bar-fill"
|
||||
@@ -146,27 +145,28 @@ function AnalyticsPanel({
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* TODO: Enable when backend provides per-service span counts
|
||||
<TabsContent value="spans">
|
||||
<div className="analytics-panel__list">
|
||||
{spanCountRows.map((row) => (
|
||||
<>
|
||||
<div
|
||||
key={`${row.group}-dot`}
|
||||
key={`${row.service}-dot`}
|
||||
className="analytics-panel__dot"
|
||||
style={{ backgroundColor: row.color }}
|
||||
/>
|
||||
<span
|
||||
key={`${row.group}-name`}
|
||||
key={`${row.service}-name`}
|
||||
className="analytics-panel__service-name"
|
||||
>
|
||||
{row.group}
|
||||
{row.service}
|
||||
</span>
|
||||
<div key={`${row.group}-bar`} className="analytics-panel__bar-cell">
|
||||
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
|
||||
<div className="analytics-panel__bar">
|
||||
<div
|
||||
className="analytics-panel__bar-fill"
|
||||
style={{
|
||||
width: `${(row.count / row.max) * 100}%`,
|
||||
width: `${(row.count / maxSpanCount) * 100}%`,
|
||||
backgroundColor: row.color,
|
||||
}}
|
||||
/>
|
||||
@@ -179,6 +179,7 @@ function AnalyticsPanel({
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
*/}
|
||||
</div>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
|
||||
@@ -64,13 +64,9 @@
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
[role='tabpanel'] {
|
||||
padding: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,8 +112,7 @@
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-forest);
|
||||
flex-shrink: 0;
|
||||
background: var(--success-500);
|
||||
}
|
||||
|
||||
&__trace-id {
|
||||
@@ -128,17 +123,6 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__trace-id-copy {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&__key-attributes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -41,12 +41,7 @@ import Events from 'container/SpanDetailsDrawer/Events/Events';
|
||||
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
|
||||
import dayjs from 'dayjs';
|
||||
import { useMigratePinnedAttributes } from 'pages/TraceDetailsV3/hooks/useMigratePinnedAttributes';
|
||||
import {
|
||||
getSpanAttribute,
|
||||
getSpanDisplayData,
|
||||
hasInfraMetadata,
|
||||
} from 'pages/TraceDetailsV3/utils';
|
||||
import { getSpanAttribute, hasInfraMetadata } from 'pages/TraceDetailsV3/utils';
|
||||
import { DataViewer } from 'periscope/components/DataViewer';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
@@ -64,7 +59,6 @@ import {
|
||||
VISIBLE_ACTIONS,
|
||||
} from './constants';
|
||||
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
|
||||
import { useTracePinnedFields } from './hooks/useTracePinnedFields';
|
||||
import {
|
||||
LinkedSpansPanel,
|
||||
LinkedSpansToggle,
|
||||
@@ -83,6 +77,7 @@ interface SpanDetailsPanelProps {
|
||||
onVariantChange?: (variant: SpanDetailVariant) => void;
|
||||
traceStartTime?: number;
|
||||
traceEndTime?: number;
|
||||
serviceExecTime?: Record<string, number>;
|
||||
}
|
||||
|
||||
function SpanDetailsContent({
|
||||
@@ -99,17 +94,6 @@ function SpanDetailsContent({
|
||||
const percentile = useSpanPercentile(selectedSpan);
|
||||
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
|
||||
|
||||
// One-time conversion of any V2-format value still living in the
|
||||
// `span_details_pinned_attributes` user pref into V3 nested-path format.
|
||||
useMigratePinnedAttributes(selectedSpan);
|
||||
const { value: pinnedFieldsValue, onChange: onPinnedFieldsChange } =
|
||||
useTracePinnedFields();
|
||||
|
||||
const spanDisplayData = useMemo(
|
||||
() => getSpanDisplayData(selectedSpan),
|
||||
[selectedSpan],
|
||||
);
|
||||
|
||||
// Map span attribute actions to PrettyView actions format.
|
||||
// Use the last key in fieldKeyPath (the actual attribute key), not the full display path.
|
||||
const prettyViewCustomActions = useMemo(
|
||||
@@ -407,14 +391,12 @@ function SpanDetailsContent({
|
||||
<div className="span-details-panel__tabs-scroll">
|
||||
<TabsContent value="overview">
|
||||
<DataViewer
|
||||
data={spanDisplayData}
|
||||
data={selectedSpan}
|
||||
drawerKey="trace-details"
|
||||
prettyViewProps={{
|
||||
showPinned: true,
|
||||
actions: prettyViewCustomActions,
|
||||
visibleActions: VISIBLE_ACTIONS,
|
||||
pinnedFieldsValue,
|
||||
onPinnedFieldsChange,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
@@ -469,6 +451,7 @@ function SpanDetailsPanel({
|
||||
onVariantChange,
|
||||
traceStartTime,
|
||||
traceEndTime,
|
||||
serviceExecTime,
|
||||
}: SpanDetailsPanelProps): JSX.Element {
|
||||
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
|
||||
|
||||
@@ -575,6 +558,9 @@ function SpanDetailsPanel({
|
||||
<AnalyticsPanel
|
||||
isOpen={isAnalyticsOpen}
|
||||
onClose={(): void => setIsAnalyticsOpen(false)}
|
||||
serviceExecTime={serviceExecTime}
|
||||
traceStartTime={traceStartTime}
|
||||
traceEndTime={traceEndTime}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -608,7 +594,6 @@ function SpanDetailsPanel({
|
||||
isOpen={panelState.isOpen}
|
||||
className="span-details-panel"
|
||||
width={PANEL_WIDTH}
|
||||
minWidth={480}
|
||||
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
|
||||
defaultPosition={{
|
||||
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN_RIGHT,
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Link, useRouteMatch } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
interface TraceIdFieldProps {
|
||||
span: SpanV3;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a span's trace id. When the user is already on the trace detail
|
||||
* page for this trace, clicking the id copies it to the clipboard (the
|
||||
* "navigate" affordance would be a no-op). Otherwise, falls back to the
|
||||
* existing link to the trace detail page.
|
||||
*/
|
||||
export function TraceIdField({ span }: TraceIdFieldProps): JSX.Element {
|
||||
const match = useRouteMatch<{ id: string }>({
|
||||
path: ROUTES.TRACE_DETAIL,
|
||||
exact: true,
|
||||
});
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
|
||||
const isCurrentTrace = match?.params.id === span.trace_id;
|
||||
|
||||
if (isCurrentTrace) {
|
||||
const handleCopy = (): void => {
|
||||
setCopy(span.trace_id);
|
||||
toast.success('Trace ID copied to clipboard', {
|
||||
position: 'top-right',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
color="secondary"
|
||||
className="span-details-panel__trace-id-copy"
|
||||
onClick={handleCopy}
|
||||
title="Click to copy trace ID"
|
||||
>
|
||||
{span.trace_id}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={{
|
||||
pathname: `/trace/${span.trace_id}`,
|
||||
search: window.location.search,
|
||||
}}
|
||||
className="span-details-panel__trace-id"
|
||||
>
|
||||
{span.trace_id}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceIdField } from './TraceIdField';
|
||||
|
||||
interface HighlightedOption {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -34,7 +33,17 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
|
||||
key: 'traceId',
|
||||
label: 'TRACE ID',
|
||||
render: (span): ReactNode | null =>
|
||||
span.trace_id ? <TraceIdField span={span} /> : null,
|
||||
span.trace_id ? (
|
||||
<Link
|
||||
to={{
|
||||
pathname: `/trace/${span.trace_id}`,
|
||||
search: window.location.search,
|
||||
}}
|
||||
className="span-details-panel__trace-id"
|
||||
>
|
||||
{span.trace_id}
|
||||
</Link>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
key: 'spanKind',
|
||||
@@ -42,12 +51,4 @@ export const HIGHLIGHTED_OPTIONS: HighlightedOption[] = [
|
||||
render: (span): ReactNode | null =>
|
||||
span.kind_string ? <Badge color="vanilla">{span.kind_string}</Badge> : null,
|
||||
},
|
||||
{
|
||||
key: 'statusMessage',
|
||||
label: 'STATUS MESSAGE',
|
||||
render: (span): ReactNode | null =>
|
||||
span.status_message ? (
|
||||
<Badge color="vanilla">{span.status_message}</Badge>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import { isV3PinnedAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
interface UseTracePinnedFieldsReturn {
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads/writes V3 trace-details pinned attributes from the user-preference
|
||||
* `span_details_pinned_attributes` (cross-device sync). Drops legacy V2-format
|
||||
* entries from the rendered set so PrettyView never tries to render an
|
||||
* un-parseable path while the migration is in flight.
|
||||
*
|
||||
* Migration of V2 → V3 format is handled separately by
|
||||
* `useMigratePinnedAttributes`.
|
||||
*/
|
||||
export function useTracePinnedFields(): UseTracePinnedFieldsReturn {
|
||||
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
||||
const { mutate } = useMutation(updateUserPreferenceAPI);
|
||||
|
||||
const value = useMemo<string[]>(() => {
|
||||
const pref = userPreferences?.find(
|
||||
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
);
|
||||
const arr = (pref?.value as string[] | undefined) ?? [];
|
||||
return arr.filter(isV3PinnedAttribute);
|
||||
}, [userPreferences]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(next: string[]) => {
|
||||
const existing = userPreferences?.find(
|
||||
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
);
|
||||
if (existing) {
|
||||
updateUserPreferenceInContext({ ...existing, value: next });
|
||||
}
|
||||
mutate({
|
||||
name: USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
value: next,
|
||||
});
|
||||
},
|
||||
[userPreferences, updateUserPreferenceInContext, mutate],
|
||||
);
|
||||
|
||||
return { value, onChange };
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export function EventTooltipContent({
|
||||
{eventName}
|
||||
</div>
|
||||
<div className="event-tooltip-content__time">
|
||||
{toFixed(time, 2)} {timeUnitName} since span start
|
||||
{toFixed(time, 2)} {timeUnitName} from start
|
||||
</div>
|
||||
{Object.keys(attributeMap).length > 0 && (
|
||||
<>
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
// Invisible 1 px anchor mounted inside the scrollable waterfall body. Its
|
||||
// `top` is updated by the parent to track the hovered row's Y; its `left`
|
||||
// is the sidebar/timeline boundary so the popover always opens at the same
|
||||
// X regardless of which row is hovered.
|
||||
.span-hover-card-anchor {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
pointer-events: none;
|
||||
.span-hover-card-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.span-hover-card-popover {
|
||||
// Hover card may be rendered while the SpanDetailsPanel is docked as
|
||||
// a FloatingPanel (z-index 999); bump above the default tooltip z-index.
|
||||
// Event-dot tooltip is rendered while the SpanDetailsPanel may be docked as
|
||||
// a FloatingPanel (z-index 999); bump above the default tooltip z-index 50.
|
||||
--tooltip-z-index: 1000;
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
color: var(--l1-foreground);
|
||||
|
||||
.ant-popover-inner {
|
||||
background-color: var(--l1-background);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.ant-popover-inner-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Flamegraph tooltip — rendered as a portal, uses same semantic tokens.
|
||||
|
||||
@@ -1,33 +1,16 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui';
|
||||
import { memo, ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import { Popover } from 'antd';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { useTraceContext } from 'pages/TraceDetailsV3/contexts/TraceContext';
|
||||
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
import { useMemo } from 'react';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import './SpanHoverCard.styles.scss';
|
||||
|
||||
/**
|
||||
* Span-level fields that the tooltip always shows (as the colored title or
|
||||
* one of the status/start/duration rows). Preview rows for these keys are
|
||||
* filtered out to avoid duplication.
|
||||
*/
|
||||
export const RESERVED_PREVIEW_KEYS: ReadonlySet<string> = new Set([
|
||||
'name',
|
||||
'has_error',
|
||||
'timestamp',
|
||||
'duration_nano',
|
||||
]);
|
||||
|
||||
export interface SpanPreviewRow {
|
||||
key: string;
|
||||
value: string;
|
||||
interface ITraceMetadata {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}
|
||||
|
||||
export interface SpanTooltipContentProps {
|
||||
@@ -36,7 +19,6 @@ export interface SpanTooltipContentProps {
|
||||
hasError: boolean;
|
||||
relativeStartMs: number;
|
||||
durationMs: number;
|
||||
previewRows?: SpanPreviewRow[];
|
||||
}
|
||||
|
||||
export function SpanTooltipContent({
|
||||
@@ -45,7 +27,6 @@ export function SpanTooltipContent({
|
||||
hasError,
|
||||
relativeStartMs,
|
||||
durationMs,
|
||||
previewRows,
|
||||
}: SpanTooltipContentProps): JSX.Element {
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(durationMs);
|
||||
@@ -56,118 +37,104 @@ export function SpanTooltipContent({
|
||||
{spanName}
|
||||
</div>
|
||||
<div className="span-hover-card-content__row">
|
||||
status: {hasError ? 'error' : 'ok'}
|
||||
Status: {hasError ? 'error' : 'ok'}
|
||||
</div>
|
||||
<div className="span-hover-card-content__row">
|
||||
start: {toFixed(relativeStartMs, 2)} ms
|
||||
Start: {toFixed(relativeStartMs, 2)} ms
|
||||
</div>
|
||||
<div className="span-hover-card-content__row">
|
||||
duration: {toFixed(formattedDuration, 2)} {timeUnitName}
|
||||
Duration: {toFixed(formattedDuration, 2)} {timeUnitName}
|
||||
</div>
|
||||
{previewRows && previewRows.length > 0 && (
|
||||
<div className="span-hover-card-content__preview">
|
||||
{previewRows.map((row) => (
|
||||
<div key={row.key} className="span-hover-card-content__row">
|
||||
<span className="span-hover-card-content__preview-key">{row.key}:</span>{' '}
|
||||
<span className="span-hover-card-content__preview-value">
|
||||
{row.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single hover card anchored at a fixed X (sidebar/timeline boundary). The
|
||||
* Y of the anchor is derived from the hovered span's index in the list,
|
||||
* so the card slides vertically in place rather than jumping with the cursor.
|
||||
*
|
||||
* Mount this inside the scrollable waterfall body so `anchorTop` is in
|
||||
* content coordinates — Radix portals the content layer out automatically.
|
||||
*/
|
||||
export interface SpanHoverCardProps {
|
||||
hoveredSpanId: string | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
anchorLeft: number;
|
||||
rowHeight: number;
|
||||
spans: SpanV3[];
|
||||
traceStartTime: number;
|
||||
interface SpanHoverCardProps {
|
||||
span: SpanV3;
|
||||
traceMetadata: ITraceMetadata;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SpanHoverCard({
|
||||
hoveredSpanId,
|
||||
onOpenChange,
|
||||
anchorLeft,
|
||||
rowHeight,
|
||||
spans,
|
||||
traceStartTime,
|
||||
/**
|
||||
* Lazy hover card — only mounts the expensive antd Popover when the user
|
||||
* actually hovers over the element (after a short delay). During fast scrolling,
|
||||
* rows mount and unmount without ever creating a Popover instance, avoiding
|
||||
* expensive DOM/effect overhead from antd Tooltip/Trigger internals.
|
||||
*/
|
||||
const SpanHoverCard = memo(function SpanHoverCard({
|
||||
span,
|
||||
traceMetadata,
|
||||
children,
|
||||
}: SpanHoverCardProps): JSX.Element {
|
||||
const { previewFields, resolveSpanColor } = useTraceContext();
|
||||
const [showPopover, setShowPopover] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const hoverCardData = useMemo(() => {
|
||||
if (!hoveredSpanId) {
|
||||
return null;
|
||||
}
|
||||
const idx = spans.findIndex((s) => s.span_id === hoveredSpanId);
|
||||
if (idx === -1) {
|
||||
return null;
|
||||
}
|
||||
const span = spans[idx];
|
||||
const previewRows: SpanPreviewRow[] = previewFields
|
||||
.filter((f) => !RESERVED_PREVIEW_KEYS.has(f.key))
|
||||
.map((f) => {
|
||||
const value = getSpanAttribute(span, f.key);
|
||||
return value !== undefined && value !== ''
|
||||
? { key: f.key, value: String(value) }
|
||||
: null;
|
||||
})
|
||||
.filter((r): r is SpanPreviewRow => r !== null);
|
||||
const handleMouseEnter = useCallback((): void => {
|
||||
timerRef.current = setTimeout(() => {
|
||||
setShowPopover(true);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
anchorTop: idx * rowHeight,
|
||||
tooltip: {
|
||||
spanName: span.name,
|
||||
color: resolveSpanColor(span),
|
||||
hasError: span.has_error,
|
||||
relativeStartMs: span.timestamp - traceStartTime,
|
||||
durationMs: span.duration_nano / 1e6,
|
||||
previewRows,
|
||||
},
|
||||
};
|
||||
}, [
|
||||
hoveredSpanId,
|
||||
spans,
|
||||
previewFields,
|
||||
resolveSpanColor,
|
||||
rowHeight,
|
||||
traceStartTime,
|
||||
]);
|
||||
const handleMouseLeave = useCallback((): void => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setShowPopover(false);
|
||||
}, []);
|
||||
|
||||
if (!showPopover) {
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/mouse-events-have-key-events
|
||||
<span
|
||||
className="span-hover-card-wrapper"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const durationMs = span.duration_nano / 1e6;
|
||||
const relativeStartMs = span.timestamp - traceMetadata.startTime;
|
||||
|
||||
let color = generateColor(
|
||||
span['service.name'],
|
||||
themeColors.traceDetailColorsV3,
|
||||
);
|
||||
if (span.has_error) {
|
||||
color = 'var(--bg-cherry-500)';
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={hoverCardData !== null} onOpenChange={onOpenChange}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="span-hover-card-anchor"
|
||||
style={{
|
||||
top: hoverCardData?.anchorTop ?? 0,
|
||||
left: anchorLeft,
|
||||
height: rowHeight,
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="span-hover-card-popover"
|
||||
>
|
||||
{hoverCardData && <SpanTooltipContent {...hoverCardData.tooltip} />}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Popover
|
||||
open
|
||||
content={
|
||||
<SpanTooltipContent
|
||||
spanName={span.name}
|
||||
color={color}
|
||||
hasError={span.has_error}
|
||||
relativeStartMs={relativeStartMs}
|
||||
durationMs={durationMs}
|
||||
/>
|
||||
}
|
||||
trigger="hover"
|
||||
rootClassName="span-hover-card-popover"
|
||||
autoAdjustOverflow
|
||||
arrow={false}
|
||||
onOpenChange={(open): void => {
|
||||
if (!open) {
|
||||
setShowPopover(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events */}
|
||||
<span className="span-hover-card-wrapper" onMouseLeave={handleMouseLeave}>
|
||||
{children}
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default SpanHoverCard;
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
.trace-details-header-wrapper {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trace-details-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -21,53 +16,13 @@
|
||||
&.trace-v3-filter-row {
|
||||
padding: 0;
|
||||
}
|
||||
max-width: 850px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
&:not(&--expanded) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&--expanded {
|
||||
max-width: none;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__old-view-btn {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__sub-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 4px 16px 8px;
|
||||
font-size: 13px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&__sub-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__separator {
|
||||
color: var(--l2-foreground);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&__entry-point-badge {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&__skeleton .ant-skeleton-input {
|
||||
width: 160px !important;
|
||||
min-height: 20px !important;
|
||||
height: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Skeleton } from 'antd';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import history from 'lib/history';
|
||||
import { ArrowLeft, CalendarClock, Server, Timer } from '@signozhq/icons';
|
||||
import { FloatingPanel } from 'periscope/components/FloatingPanel';
|
||||
import { ArrowLeft } from '@signozhq/icons';
|
||||
import KeyValueLabel from 'periscope/components/KeyValueLabel';
|
||||
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import FieldsSettings from '../components/FieldsSettings/FieldsSettings';
|
||||
import { useTraceContext } from '../contexts/TraceContext';
|
||||
import Filters from '../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters';
|
||||
import TraceOptionsMenu from './TraceOptionsMenu';
|
||||
|
||||
import './TraceDetailsHeader.styles.scss';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
|
||||
interface FilterMetadata {
|
||||
startTime: number;
|
||||
@@ -29,53 +17,20 @@ interface FilterMetadata {
|
||||
traceId: string;
|
||||
}
|
||||
|
||||
export interface TraceMetadataForHeader {
|
||||
startTimestampMillis: number;
|
||||
endTimestampMillis: number;
|
||||
rootServiceName: string;
|
||||
rootServiceEntryPoint: string;
|
||||
rootSpanStatusCode: string;
|
||||
}
|
||||
|
||||
interface TraceDetailsHeaderProps {
|
||||
filterMetadata: FilterMetadata;
|
||||
onFilteredSpansChange: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
isDataLoaded?: boolean;
|
||||
traceMetadata?: TraceMetadataForHeader;
|
||||
}
|
||||
|
||||
const SKELETON_COUNT = 3;
|
||||
|
||||
function DetailsLoader(): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: SKELETON_COUNT }).map((_, i) => (
|
||||
<Skeleton.Input
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={i}
|
||||
active
|
||||
size="small"
|
||||
className="trace-details-header__skeleton"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
noData?: boolean;
|
||||
}
|
||||
|
||||
function TraceDetailsHeader({
|
||||
filterMetadata,
|
||||
onFilteredSpansChange,
|
||||
isDataLoaded,
|
||||
traceMetadata,
|
||||
noData,
|
||||
}: TraceDetailsHeaderProps): JSX.Element {
|
||||
const { id: traceID } = useParams<TraceDetailV2URLProps>();
|
||||
const [showTraceDetails, setShowTraceDetails] = useState(true);
|
||||
const [isFilterExpanded, setIsFilterExpanded] = useState(false);
|
||||
const [isPreviewFieldsOpen, setIsPreviewFieldsOpen] = useState(false);
|
||||
const { previewFields, setPreviewFields } = useTraceContext();
|
||||
|
||||
const handleSwitchToOldView = useCallback((): void => {
|
||||
setLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW, 'true');
|
||||
const oldUrl = `/trace-old/${traceID}${window.location.search}`;
|
||||
history.replace(oldUrl);
|
||||
}, [traceID]);
|
||||
@@ -94,127 +49,42 @@ function TraceDetailsHeader({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleTraceDetails = useCallback((): void => {
|
||||
setShowTraceDetails((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const durationMs = traceMetadata
|
||||
? traceMetadata.endTimestampMillis - traceMetadata.startTimestampMillis
|
||||
: 0;
|
||||
const { time: formattedDuration, timeUnitName } =
|
||||
convertTimeToRelevantUnit(durationMs);
|
||||
|
||||
return (
|
||||
<div className="trace-details-header-wrapper">
|
||||
<div className="trace-details-header">
|
||||
{!isFilterExpanded && (
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="md"
|
||||
className="trace-details-header__back-btn"
|
||||
onClick={handlePreviousBtnClick}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
</Button>
|
||||
<KeyValueLabel
|
||||
badgeKey="Trace ID"
|
||||
badgeValue={traceID || ''}
|
||||
maxCharacters={100}
|
||||
<div className="trace-details-header">
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="trace-details-header__back-btn"
|
||||
onClick={handlePreviousBtnClick}
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
</Button>
|
||||
<KeyValueLabel
|
||||
badgeKey="Trace ID"
|
||||
badgeValue={traceID || ''}
|
||||
maxCharacters={100}
|
||||
/>
|
||||
{!noData && (
|
||||
<>
|
||||
<div className="trace-details-header__filter">
|
||||
<Filters
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
traceID={filterMetadata.traceId}
|
||||
onFilteredSpansChange={onFilteredSpansChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isDataLoaded && (
|
||||
<>
|
||||
<div
|
||||
className={`trace-details-header__filter${
|
||||
isFilterExpanded ? ' trace-details-header__filter--expanded' : ''
|
||||
}`}
|
||||
>
|
||||
<Filters
|
||||
startTime={filterMetadata.startTime}
|
||||
endTime={filterMetadata.endTime}
|
||||
traceID={filterMetadata.traceId}
|
||||
onFilteredSpansChange={onFilteredSpansChange}
|
||||
isExpanded={isFilterExpanded}
|
||||
onExpand={(): void => setIsFilterExpanded(true)}
|
||||
onCollapse={(): void => setIsFilterExpanded(false)}
|
||||
/>
|
||||
</div>
|
||||
{!isFilterExpanded && (
|
||||
<>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="trace-details-header__old-view-btn"
|
||||
onClick={handleSwitchToOldView}
|
||||
>
|
||||
Old View
|
||||
</Button>
|
||||
<TraceOptionsMenu
|
||||
showTraceDetails={showTraceDetails}
|
||||
onToggleTraceDetails={handleToggleTraceDetails}
|
||||
onOpenPreviewFields={(): void => setIsPreviewFieldsOpen(true)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showTraceDetails && (
|
||||
<div className="trace-details-header__sub-header">
|
||||
{traceMetadata ? (
|
||||
<>
|
||||
<span className="trace-details-header__sub-item">
|
||||
<Server size={13} />
|
||||
{traceMetadata.rootServiceName}
|
||||
<span className="trace-details-header__separator">—</span>
|
||||
<span className="trace-details-header__entry-point-badge">
|
||||
{traceMetadata.rootServiceEntryPoint}
|
||||
</span>
|
||||
</span>
|
||||
<span className="trace-details-header__sub-item">
|
||||
<Timer size={13} />
|
||||
{parseFloat(formattedDuration.toFixed(2))} {timeUnitName}
|
||||
</span>
|
||||
<span className="trace-details-header__sub-item">
|
||||
<CalendarClock size={13} />
|
||||
{dayjs(traceMetadata.startTimestampMillis).format(
|
||||
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
|
||||
)}
|
||||
</span>
|
||||
{traceMetadata.rootSpanStatusCode && (
|
||||
<HttpStatusBadge statusCode={traceMetadata.rootSpanStatusCode} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<DetailsLoader />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPreviewFieldsOpen && (
|
||||
<FloatingPanel
|
||||
isOpen
|
||||
width={350}
|
||||
height={window.innerHeight - 100}
|
||||
defaultPosition={{
|
||||
x: window.innerWidth - 350 - 100,
|
||||
y: 50,
|
||||
}}
|
||||
enableResizing={false}
|
||||
>
|
||||
<FieldsSettings
|
||||
title="Preview fields"
|
||||
fields={previewFields}
|
||||
onFieldsChange={setPreviewFields}
|
||||
onClose={(): void => setIsPreviewFieldsOpen(false)}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>
|
||||
</FloatingPanel>
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
className="trace-details-header__old-view-btn"
|
||||
onClick={handleSwitchToOldView}
|
||||
>
|
||||
Old View
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { MenuItem } from '@signozhq/ui';
|
||||
import { Button } from '@signozhq/ui';
|
||||
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
|
||||
import { Ellipsis } from '@signozhq/icons';
|
||||
|
||||
import { useTraceContext } from '../contexts/TraceContext';
|
||||
|
||||
interface TraceOptionsMenuProps {
|
||||
showTraceDetails: boolean;
|
||||
onToggleTraceDetails: () => void;
|
||||
onOpenPreviewFields: () => void;
|
||||
}
|
||||
|
||||
function TraceOptionsMenu({
|
||||
showTraceDetails,
|
||||
onToggleTraceDetails,
|
||||
onOpenPreviewFields,
|
||||
}: TraceOptionsMenuProps): JSX.Element {
|
||||
const { colorByField, setColorByField, availableColorByOptions } =
|
||||
useTraceContext();
|
||||
|
||||
const menuItems: MenuItem[] = useMemo(() => {
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
key: 'toggle-trace-details',
|
||||
label: showTraceDetails ? 'Hide trace details' : 'Show trace details',
|
||||
onClick: onToggleTraceDetails,
|
||||
},
|
||||
{
|
||||
key: 'preview-fields',
|
||||
label: 'Preview fields',
|
||||
onClick: onOpenPreviewFields,
|
||||
},
|
||||
];
|
||||
|
||||
// Only show the "Colour by" submenu if there's an actual choice to make.
|
||||
if (availableColorByOptions.length > 1) {
|
||||
items.push({
|
||||
key: 'colour-by',
|
||||
label: 'Colour by',
|
||||
children: [
|
||||
{
|
||||
type: 'group',
|
||||
label: 'COLOUR BY',
|
||||
children: [
|
||||
{
|
||||
type: 'radio-group',
|
||||
value: colorByField.name,
|
||||
onChange: (name: string): void => {
|
||||
const next = availableColorByOptions.find(
|
||||
(o) => o.field.name === name,
|
||||
);
|
||||
if (next) {
|
||||
setColorByField(next.field);
|
||||
}
|
||||
},
|
||||
children: availableColorByOptions.map((opt) => ({
|
||||
type: 'radio',
|
||||
key: opt.field.name,
|
||||
label: opt.label,
|
||||
value: opt.field.name,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [
|
||||
showTraceDetails,
|
||||
onToggleTraceDetails,
|
||||
onOpenPreviewFields,
|
||||
colorByField.name,
|
||||
setColorByField,
|
||||
availableColorByOptions,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items: menuItems }}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
prefix={<Ellipsis size={14} />}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
export default TraceOptionsMenu;
|
||||
@@ -48,10 +48,6 @@
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
&__collapse-count-errors {
|
||||
color: var(--destructive);
|
||||
}
|
||||
|
||||
&__flame-collapse {
|
||||
flex-shrink: 0;
|
||||
|
||||
|
||||
@@ -214,7 +214,6 @@ function FlamegraphCanvas(props: FlamegraphCanvasProps): JSX.Element {
|
||||
hasError={tooltipContent.status === 'error'}
|
||||
relativeStartMs={tooltipContent.startMs}
|
||||
durationMs={tooltipContent.durationMs}
|
||||
previewRows={tooltipContent.previewRows}
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
|
||||
@@ -5,13 +5,7 @@ import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import { COLOR_BY_FIELDS } from '../constants';
|
||||
import { useTraceContext } from '../contexts/TraceContext';
|
||||
import Error from '../TraceWaterfall/TraceWaterfallStates/Error/Error';
|
||||
import {
|
||||
mergeTelemetryFieldKeys,
|
||||
toTelemetryFieldKey,
|
||||
} from '../utils/previewFields';
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from './constants';
|
||||
import FlamegraphCanvas from './FlamegraphCanvas';
|
||||
import { useVisualLayoutWorker } from './hooks/useVisualLayoutWorker';
|
||||
@@ -50,19 +44,6 @@ function TraceFlamegraph({
|
||||
[history, search],
|
||||
);
|
||||
|
||||
const { previewFields } = useTraceContext();
|
||||
|
||||
// Color-by fields baseline + user-picked preview fields. De-duped by `name`,
|
||||
// color-by entries first so their canonical metadata wins on collision.
|
||||
const flamegraphSelectFields = useMemo(
|
||||
() =>
|
||||
mergeTelemetryFieldKeys(
|
||||
COLOR_BY_FIELDS,
|
||||
previewFields.map(toTelemetryFieldKey),
|
||||
),
|
||||
[previewFields],
|
||||
);
|
||||
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
@@ -71,7 +52,6 @@ function TraceFlamegraph({
|
||||
traceId,
|
||||
// selectedSpanId: firstSpanAtFetchLevel,
|
||||
limit: FLAMEGRAPH_SPAN_LIMIT,
|
||||
selectFields: flamegraphSelectFields,
|
||||
});
|
||||
|
||||
const spans = useMemo(
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
import React from 'react';
|
||||
import type React from 'react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { AllTheProviders } from 'tests/test-utils';
|
||||
|
||||
import { TraceProvider } from '../../contexts/TraceContext';
|
||||
import { useFlamegraphHover } from '../hooks/useFlamegraphHover';
|
||||
import type { SpanRect } from '../types';
|
||||
import { MOCK_SPAN, MOCK_TRACE_METADATA } from './testUtils';
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
return (
|
||||
<AllTheProviders>
|
||||
<TraceProvider aggregations={undefined}>{children}</TraceProvider>
|
||||
</AllTheProviders>
|
||||
);
|
||||
}
|
||||
|
||||
function createMockCanvas(): HTMLCanvasElement {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 800;
|
||||
canvas.height = 400;
|
||||
jest.spyOn(canvas, 'getBoundingClientRect').mockImplementation(
|
||||
canvas.getBoundingClientRect = jest.fn(
|
||||
(): DOMRect =>
|
||||
({
|
||||
left: 0,
|
||||
@@ -69,9 +59,7 @@ describe('useFlamegraphHover', () => {
|
||||
});
|
||||
|
||||
it('sets hoveredSpanId and tooltipContent when hovering on span', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
|
||||
wrapper,
|
||||
});
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
@@ -88,9 +76,7 @@ describe('useFlamegraphHover', () => {
|
||||
});
|
||||
|
||||
it('clears hover when moving to empty area', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
|
||||
wrapper,
|
||||
});
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
@@ -113,9 +99,7 @@ describe('useFlamegraphHover', () => {
|
||||
});
|
||||
|
||||
it('clears hover on mouse leave', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
|
||||
wrapper,
|
||||
});
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
@@ -133,9 +117,7 @@ describe('useFlamegraphHover', () => {
|
||||
});
|
||||
|
||||
it('suppresses click when drag distance exceeds threshold', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
|
||||
wrapper,
|
||||
});
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleMouseDownForClick({
|
||||
@@ -155,9 +137,7 @@ describe('useFlamegraphHover', () => {
|
||||
});
|
||||
|
||||
it('calls onSpanClick when clicking on span', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
|
||||
wrapper,
|
||||
});
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleClick({
|
||||
@@ -170,9 +150,7 @@ describe('useFlamegraphHover', () => {
|
||||
});
|
||||
|
||||
it('uses clientX/clientY for tooltip positioning', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
|
||||
wrapper,
|
||||
});
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
|
||||
act(() => {
|
||||
result.current.handleHoverMouseMove({
|
||||
@@ -186,9 +164,7 @@ describe('useFlamegraphHover', () => {
|
||||
});
|
||||
|
||||
it('does not update hover during drag', () => {
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs), {
|
||||
wrapper,
|
||||
});
|
||||
const { result } = renderHook(() => useFlamegraphHover(defaultArgs));
|
||||
defaultArgs.isDraggingRef.current = true;
|
||||
|
||||
act(() => {
|
||||
@@ -1,6 +1,4 @@
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { getFlamegraphSpanGroupValue, getSpanColor } from '../utils';
|
||||
import { getSpanColor } from '../utils';
|
||||
import { MOCK_SPAN } from './testUtils';
|
||||
|
||||
const mockGenerateColor = jest.fn();
|
||||
@@ -10,17 +8,6 @@ jest.mock('lib/uPlotLib/utils/generateColor', () => ({
|
||||
mockGenerateColor(key, colorMap),
|
||||
}));
|
||||
|
||||
const SERVICE_FIELD: TelemetryFieldKey = {
|
||||
name: 'service.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
};
|
||||
const HOST_FIELD: TelemetryFieldKey = {
|
||||
name: 'host.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
};
|
||||
|
||||
describe('Presentation / Styling Utils', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -28,17 +15,16 @@ describe('Presentation / Styling Utils', () => {
|
||||
});
|
||||
|
||||
describe('getSpanColor', () => {
|
||||
it('uses generated colour from groupValue for normal span', () => {
|
||||
it('uses generated service color for normal span', () => {
|
||||
mockGenerateColor.mockReturnValue('#1890ff');
|
||||
|
||||
const color = getSpanColor({
|
||||
span: { ...MOCK_SPAN, hasError: false },
|
||||
isDarkMode: false,
|
||||
groupValue: 'my-bucket',
|
||||
});
|
||||
|
||||
expect(mockGenerateColor).toHaveBeenCalledWith(
|
||||
'my-bucket',
|
||||
MOCK_SPAN.serviceName,
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(color).toBe('#1890ff');
|
||||
@@ -50,7 +36,6 @@ describe('Presentation / Styling Utils', () => {
|
||||
const color = getSpanColor({
|
||||
span: { ...MOCK_SPAN, hasError: true },
|
||||
isDarkMode: false,
|
||||
groupValue: 'my-bucket',
|
||||
});
|
||||
|
||||
expect(color).toBe('rgb(220, 38, 38)');
|
||||
@@ -62,50 +47,21 @@ describe('Presentation / Styling Utils', () => {
|
||||
const color = getSpanColor({
|
||||
span: { ...MOCK_SPAN, hasError: true },
|
||||
isDarkMode: true,
|
||||
groupValue: 'my-bucket',
|
||||
});
|
||||
|
||||
expect(color).toBe('rgb(239, 68, 68)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFlamegraphSpanGroupValue', () => {
|
||||
it('returns resource[field.name] when present', () => {
|
||||
const value = getFlamegraphSpanGroupValue(
|
||||
{
|
||||
serviceName: 'legacy',
|
||||
resource: { 'service.name': 'svc-from-resource' },
|
||||
},
|
||||
SERVICE_FIELD,
|
||||
);
|
||||
expect(value).toBe('svc-from-resource');
|
||||
});
|
||||
it('passes serviceName to generateColor', () => {
|
||||
getSpanColor({
|
||||
span: { ...MOCK_SPAN, serviceName: 'my-service' },
|
||||
isDarkMode: false,
|
||||
});
|
||||
|
||||
it('falls back to top-level serviceName for service.name when resource is empty', () => {
|
||||
const value = getFlamegraphSpanGroupValue(
|
||||
{ serviceName: 'svc-legacy', resource: {} },
|
||||
SERVICE_FIELD,
|
||||
expect(mockGenerateColor).toHaveBeenCalledWith(
|
||||
'my-service',
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(value).toBe('svc-legacy');
|
||||
});
|
||||
|
||||
it('returns "unknown" for non-service fields when resource is missing', () => {
|
||||
const value = getFlamegraphSpanGroupValue(
|
||||
{ serviceName: 'svc', resource: {} },
|
||||
HOST_FIELD,
|
||||
);
|
||||
expect(value).toBe('unknown');
|
||||
});
|
||||
|
||||
it('reads host.name from resource when present', () => {
|
||||
const value = getFlamegraphSpanGroupValue(
|
||||
{
|
||||
serviceName: 'svc',
|
||||
resource: { 'host.name': 'host-1' },
|
||||
},
|
||||
HOST_FIELD,
|
||||
);
|
||||
expect(value).toBe('host-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,6 @@ export interface ConnectorLine {
|
||||
childRow: number;
|
||||
timestampMs: number;
|
||||
serviceName: string;
|
||||
// Snapshot of the child span's resource so draw-time can resolve the
|
||||
// `colorByField` group value without crossing the worker boundary.
|
||||
resource?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface VisualLayout {
|
||||
@@ -360,7 +357,6 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
|
||||
childRow,
|
||||
timestampMs: child.timestamp,
|
||||
serviceName: child.serviceName,
|
||||
resource: child.resource,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import React, { RefObject, useCallback, useMemo, useRef } from 'react';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { useTraceContext } from 'pages/TraceDetailsV3/contexts/TraceContext';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import { ConnectorLine } from '../computeVisualLayout';
|
||||
import { EventRect, SpanRect } from '../types';
|
||||
@@ -12,7 +10,6 @@ import {
|
||||
drawSpanBar,
|
||||
FlamegraphRowMetrics,
|
||||
getFlamegraphRowMetrics,
|
||||
getFlamegraphSpanGroupValue,
|
||||
getSpanColor,
|
||||
} from '../utils';
|
||||
|
||||
@@ -54,7 +51,6 @@ interface DrawLevelArgs {
|
||||
selectedSpanId: string | undefined;
|
||||
hoveredSpanId: string;
|
||||
isDarkMode: boolean;
|
||||
colorByField: TelemetryFieldKey;
|
||||
spanRectsArray: SpanRect[];
|
||||
eventRectsArray: EventRect[];
|
||||
metrics: FlamegraphRowMetrics;
|
||||
@@ -75,7 +71,6 @@ function drawLevel(args: DrawLevelArgs): void {
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
isDarkMode,
|
||||
colorByField,
|
||||
spanRectsArray,
|
||||
eventRectsArray,
|
||||
metrics,
|
||||
@@ -117,8 +112,7 @@ function drawLevel(args: DrawLevelArgs): void {
|
||||
// Minimum 1px width so tiny spans remain visible
|
||||
width = clamp(width, 1, Infinity);
|
||||
|
||||
const groupValue = getFlamegraphSpanGroupValue(span, colorByField);
|
||||
const color = getSpanColor({ span, isDarkMode, groupValue });
|
||||
const color = getSpanColor({ span, isDarkMode });
|
||||
|
||||
const isDimmedByFilter =
|
||||
!!isFilterActiveInLevel &&
|
||||
@@ -154,7 +148,6 @@ interface DrawConnectorLinesArgs {
|
||||
cssWidth: number;
|
||||
viewportHeight: number;
|
||||
metrics: FlamegraphRowMetrics;
|
||||
colorByField: TelemetryFieldKey;
|
||||
}
|
||||
|
||||
function drawConnectorLines(args: DrawConnectorLinesArgs): void {
|
||||
@@ -167,7 +160,6 @@ function drawConnectorLines(args: DrawConnectorLinesArgs): void {
|
||||
cssWidth,
|
||||
viewportHeight,
|
||||
metrics,
|
||||
colorByField,
|
||||
} = args;
|
||||
|
||||
ctx.save();
|
||||
@@ -193,11 +185,10 @@ function drawConnectorLines(args: DrawConnectorLinesArgs): void {
|
||||
continue;
|
||||
}
|
||||
|
||||
const groupValue = getFlamegraphSpanGroupValue(
|
||||
{ serviceName: conn.serviceName, resource: conn.resource },
|
||||
colorByField,
|
||||
const color = generateColor(
|
||||
conn.serviceName,
|
||||
themeColors.traceDetailColorsV3,
|
||||
);
|
||||
const color = generateColor(groupValue, themeColors.traceDetailColorsV3);
|
||||
ctx.strokeStyle = color;
|
||||
|
||||
const x = clamp(xFrac * cssWidth, 0, cssWidth);
|
||||
@@ -237,10 +228,11 @@ export function useFlamegraphDraw(
|
||||
const eventRectsRefInternal = useRef<EventRect[]>([]);
|
||||
const eventRectsRef = eventRectsRefProp ?? eventRectsRefInternal;
|
||||
|
||||
const { colorByField } = useTraceContext();
|
||||
|
||||
const filteredSpanIdsSet = useMemo(
|
||||
() => (isFilterActive && filteredSpanIds ? new Set(filteredSpanIds) : null),
|
||||
() =>
|
||||
isFilterActive && filteredSpanIds && filteredSpanIds.length > 0
|
||||
? new Set(filteredSpanIds)
|
||||
: null,
|
||||
[filteredSpanIds, isFilterActive],
|
||||
);
|
||||
|
||||
@@ -293,7 +285,6 @@ export function useFlamegraphDraw(
|
||||
cssWidth,
|
||||
viewportHeight,
|
||||
metrics,
|
||||
colorByField,
|
||||
});
|
||||
|
||||
const spanRectsArray: SpanRect[] = [];
|
||||
@@ -318,7 +309,6 @@ export function useFlamegraphDraw(
|
||||
selectedSpanId,
|
||||
hoveredSpanId,
|
||||
isDarkMode,
|
||||
colorByField,
|
||||
spanRectsArray,
|
||||
eventRectsArray,
|
||||
metrics,
|
||||
@@ -345,7 +335,6 @@ export function useFlamegraphDraw(
|
||||
hoveredSpanId,
|
||||
hoveredEventKey,
|
||||
isDarkMode,
|
||||
colorByField,
|
||||
filteredSpanIdsSet,
|
||||
isFilterActive,
|
||||
]);
|
||||
|
||||
@@ -8,18 +8,11 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTraceContext } from 'pages/TraceDetailsV3/contexts/TraceContext';
|
||||
import { RESERVED_PREVIEW_KEYS } from 'pages/TraceDetailsV3/SpanHoverCard/SpanHoverCard';
|
||||
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
|
||||
import { EventRect, SpanRect } from '../types';
|
||||
import { ITraceMetadata } from '../types';
|
||||
import {
|
||||
getFlamegraphServiceName,
|
||||
getFlamegraphSpanGroupValue,
|
||||
getSpanColor,
|
||||
} from '../utils';
|
||||
import { getSpanColor } from '../utils';
|
||||
|
||||
function getCanvasPointer(
|
||||
canvas: HTMLCanvasElement,
|
||||
@@ -76,11 +69,6 @@ export interface EventTooltipData {
|
||||
attributeMap: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SpanPreviewRowData {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface TooltipContent {
|
||||
serviceName: string;
|
||||
spanName: string;
|
||||
@@ -90,7 +78,6 @@ export interface TooltipContent {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
spanColor: string;
|
||||
previewRows?: SpanPreviewRowData[];
|
||||
event?: EventTooltipData;
|
||||
}
|
||||
|
||||
@@ -138,25 +125,6 @@ export function useFlamegraphHover(
|
||||
null,
|
||||
);
|
||||
|
||||
const { colorByField, previewFields } = useTraceContext();
|
||||
|
||||
const buildPreviewRows = useCallback(
|
||||
(span: FlamegraphSpan): SpanPreviewRowData[] =>
|
||||
previewFields
|
||||
.filter((field) => !RESERVED_PREVIEW_KEYS.has(field.key))
|
||||
.map((field) => {
|
||||
const value = getSpanAttribute(
|
||||
{ resource: span.resource, attributes: span.attributes },
|
||||
field.key,
|
||||
);
|
||||
return value !== undefined && value !== ''
|
||||
? { key: field.key, value: String(value) }
|
||||
: null;
|
||||
})
|
||||
.filter((r): r is SpanPreviewRowData => r !== null),
|
||||
[previewFields],
|
||||
);
|
||||
|
||||
const isZoomed =
|
||||
viewStartTs !== traceMetadata.startTime ||
|
||||
viewEndTs !== traceMetadata.endTime;
|
||||
@@ -203,18 +171,14 @@ export function useFlamegraphHover(
|
||||
setHoveredEventKey(`${span.spanId}-${event.name}-${event.timeUnixNano}`);
|
||||
setHoveredSpanId(span.spanId);
|
||||
setTooltipContent({
|
||||
serviceName: getFlamegraphServiceName(span),
|
||||
serviceName: span.serviceName || '',
|
||||
spanName: span.name || 'unknown',
|
||||
status: span.hasError ? 'error' : 'ok',
|
||||
startMs: span.timestamp - traceMetadata.startTime,
|
||||
durationMs: span.durationNano / 1e6,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
spanColor: getSpanColor({
|
||||
span,
|
||||
isDarkMode,
|
||||
groupValue: getFlamegraphSpanGroupValue(span, colorByField),
|
||||
}),
|
||||
spanColor: getSpanColor({ span, isDarkMode }),
|
||||
event: {
|
||||
name: event.name,
|
||||
timeOffsetMs: eventTimeMs - span.timestamp,
|
||||
@@ -236,19 +200,14 @@ export function useFlamegraphHover(
|
||||
setHoveredEventKey(null);
|
||||
setHoveredSpanId(span.spanId);
|
||||
setTooltipContent({
|
||||
serviceName: getFlamegraphServiceName(span),
|
||||
serviceName: span.serviceName || '',
|
||||
spanName: span.name || 'unknown',
|
||||
status: span.hasError ? 'error' : 'ok',
|
||||
startMs: span.timestamp - traceMetadata.startTime,
|
||||
durationMs: span.durationNano / 1e6,
|
||||
clientX: e.clientX,
|
||||
clientY: e.clientY,
|
||||
spanColor: getSpanColor({
|
||||
span,
|
||||
isDarkMode,
|
||||
groupValue: getFlamegraphSpanGroupValue(span, colorByField),
|
||||
}),
|
||||
previewRows: buildPreviewRows(span),
|
||||
spanColor: getSpanColor({ span, isDarkMode }),
|
||||
});
|
||||
updateCursor(canvas, span);
|
||||
} else {
|
||||
@@ -266,8 +225,6 @@ export function useFlamegraphHover(
|
||||
isDraggingRef,
|
||||
updateCursor,
|
||||
isDarkMode,
|
||||
colorByField,
|
||||
buildPreviewRows,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
import {
|
||||
DASHED_BORDER_LINE_DASH,
|
||||
@@ -68,47 +66,14 @@ export function getFlamegraphRowMetrics(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the displayed service.name for a flamegraph span. Used by tooltips
|
||||
* (service identity, independent of the active colour-by field). Prefers
|
||||
* `resource['service.name']` with legacy top-level `serviceName` fallback.
|
||||
*/
|
||||
export function getFlamegraphServiceName(
|
||||
span: Pick<FlamegraphSpan, 'serviceName' | 'resource' | 'attributes'>,
|
||||
): string {
|
||||
return getSpanAttribute(span, 'service.name') || span.serviceName || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the value used to bucket a flamegraph span by colour for the given
|
||||
* field. Prefers `resource[field.name]` (new contract from `selectFields`).
|
||||
* For `service.name`, falls back to the legacy top-level `serviceName` when
|
||||
* resource is empty (backward-compat with backends that haven't shipped
|
||||
* `selectFields` yet). For other fields, falls back to `'unknown'`.
|
||||
*/
|
||||
export function getFlamegraphSpanGroupValue(
|
||||
span: Pick<FlamegraphSpan, 'serviceName' | 'resource' | 'attributes'>,
|
||||
field: TelemetryFieldKey,
|
||||
): string {
|
||||
const fromAttribute = getSpanAttribute(span, field.name);
|
||||
if (fromAttribute) {
|
||||
return fromAttribute;
|
||||
}
|
||||
if (field.name === 'service.name') {
|
||||
return span.serviceName || 'unknown';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
interface GetSpanColorArgs {
|
||||
span: FlamegraphSpan;
|
||||
isDarkMode: boolean;
|
||||
groupValue: string;
|
||||
}
|
||||
|
||||
export function getSpanColor(args: GetSpanColorArgs): string {
|
||||
const { span, isDarkMode, groupValue } = args;
|
||||
let color = generateColor(groupValue, themeColors.traceDetailColorsV3);
|
||||
const { span, isDarkMode } = args;
|
||||
let color = generateColor(span.serviceName, themeColors.traceDetailColorsV3);
|
||||
|
||||
if (span.hasError) {
|
||||
color = isDarkMode ? 'rgb(239, 68, 68)' : 'rgb(220, 38, 38)';
|
||||
@@ -246,7 +211,7 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
|
||||
// Alpha is applied to bar + events only; label is drawn after restoring alpha to 1
|
||||
// so text stays readable against the faded bar.
|
||||
if (shouldDim) {
|
||||
ctx.globalAlpha = 0.15;
|
||||
ctx.globalAlpha = 0.4;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
|
||||
@@ -11,10 +11,14 @@ import NoData from './TraceWaterfallStates/NoData/NoData';
|
||||
import Success from './TraceWaterfallStates/Success/Success';
|
||||
import { getVisibleSpans } from './utils';
|
||||
|
||||
import { IInterestedSpan } from './types';
|
||||
|
||||
import './TraceWaterfall.styles.scss';
|
||||
|
||||
export interface IInterestedSpan {
|
||||
spanId: string;
|
||||
isUncollapsed: boolean;
|
||||
scrollToSpan?: boolean;
|
||||
}
|
||||
|
||||
interface ITraceWaterfallProps {
|
||||
traceId: string;
|
||||
uncollapsedNodes: string[];
|
||||
|
||||
@@ -3,114 +3,18 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
&.expanded {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filter-search-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.query-builder-search-v2 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// --- Collapsed pill ---
|
||||
.filter-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
max-width: 220px;
|
||||
min-width: 120px;
|
||||
height: 32px;
|
||||
background: var(--l1-background);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--l3-border);
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-background);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Collapsed pill popover ---
|
||||
.filter-pill-popover {
|
||||
max-width: 400px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__expression {
|
||||
font-family: 'Geist Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
word-break: break-all;
|
||||
padding: 6px 8px;
|
||||
background: var(--l2-background);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// --- ToggleGroup override: size to content, don't stretch items ---
|
||||
[class*='toggle-group'] {
|
||||
flex-shrink: 0;
|
||||
|
||||
[class*='toggle-group-item'] {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Collapse button ---
|
||||
.filter-collapse-btn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
// --- Highlight errors toggle ---
|
||||
.highlight-errors-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// --- Prev/next navigation ---
|
||||
.pre-next-toggle {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 12px;
|
||||
|
||||
&__count {
|
||||
.ant-typography {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: auto;
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
@@ -126,20 +30,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.filter-status {
|
||||
.no-results {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
color: var(--l2-foreground);
|
||||
font-family: 'Geist Mono';
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
|
||||
&--error {
|
||||
color: var(--destructive);
|
||||
cursor: help;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,19 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Info,
|
||||
Loader,
|
||||
Search,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Switch, ToggleGroup, ToggleGroupItem, toast } from '@signozhq/ui';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@signozhq/ui/tooltip';
|
||||
import { Button, Spin, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { AxiosError } from 'axios';
|
||||
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
|
||||
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
|
||||
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { uniqBy } from 'lodash-es';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
DataSource,
|
||||
TracesAggregatorOperator,
|
||||
} from 'types/common/queryBuilder';
|
||||
import { TracesAggregatorOperator } from 'types/common/queryBuilder';
|
||||
import { LoaderCircle, Info, ChevronDown, ChevronUp } from '@signozhq/icons';
|
||||
|
||||
import { BASE_FILTER_QUERY } from './constants';
|
||||
import { useHighlightErrors } from './hooks/useHighlightErrors';
|
||||
import {
|
||||
SpanCategory,
|
||||
useSpanCategoryFilter,
|
||||
} from './hooks/useSpanCategoryFilter';
|
||||
|
||||
import './Filters.styles.scss';
|
||||
|
||||
@@ -69,7 +44,6 @@ function prepareQuery(filters: TagFilter, traceID: string): Query {
|
||||
},
|
||||
],
|
||||
},
|
||||
selectColumns: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -81,87 +55,31 @@ function Filters({
|
||||
endTime,
|
||||
traceID,
|
||||
onFilteredSpansChange = (): void => {},
|
||||
isExpanded,
|
||||
onExpand,
|
||||
onCollapse,
|
||||
}: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
traceID: string;
|
||||
onFilteredSpansChange?: (spanIds: string[], isFilterActive: boolean) => void;
|
||||
isExpanded: boolean;
|
||||
onExpand: () => void;
|
||||
onCollapse: () => void;
|
||||
}): JSX.Element {
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
const [filters, setFilters] = useState<TagFilter>(
|
||||
BASE_FILTER_QUERY.filters || { items: [], op: 'AND' },
|
||||
);
|
||||
const [expression, setExpression] = useState<string>('');
|
||||
const [noData, setNoData] = useState<boolean>(false);
|
||||
const [filteredSpanIds, setFilteredSpanIds] = useState<string[]>([]);
|
||||
const [currentSearchedIndex, setCurrentSearchedIndex] = useState<number>(0);
|
||||
const expressionRef = useRef<string>('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const runQuery = useCallback(
|
||||
(value: string): void => {
|
||||
const items = convertExpressionToFilters(value);
|
||||
setFilters({ items, op: 'AND' });
|
||||
// Clear results when expression produces no filters
|
||||
if (items.length === 0) {
|
||||
const handleFilterChange = useCallback(
|
||||
(value: TagFilter): void => {
|
||||
if (value.items.length === 0) {
|
||||
setFilteredSpanIds([]);
|
||||
onFilteredSpansChange?.([], false);
|
||||
setCurrentSearchedIndex(0);
|
||||
setNoData(false);
|
||||
}
|
||||
setFilters(value);
|
||||
},
|
||||
[onFilteredSpansChange],
|
||||
);
|
||||
|
||||
// onChange fires on every keystroke — only store the expression, don't trigger API
|
||||
const handleExpressionChange = useCallback(
|
||||
(value: string): void => {
|
||||
setExpression(value);
|
||||
expressionRef.current = value;
|
||||
// Clear results when expression is emptied
|
||||
if (!value.trim()) {
|
||||
setFilters({ items: [], op: 'AND' });
|
||||
setFilteredSpanIds([]);
|
||||
onFilteredSpansChange?.([], false);
|
||||
setCurrentSearchedIndex(0);
|
||||
setNoData(false);
|
||||
}
|
||||
},
|
||||
[onFilteredSpansChange],
|
||||
);
|
||||
|
||||
// onRun fires on Ctrl+Enter
|
||||
const handleRunQuery = useCallback(
|
||||
(value: string): void => {
|
||||
runQuery(value);
|
||||
},
|
||||
[runQuery],
|
||||
);
|
||||
|
||||
// Run query on blur (click outside the filter input)
|
||||
const handleBlur = useCallback((): void => {
|
||||
runQuery(expressionRef.current);
|
||||
}, [runQuery]);
|
||||
|
||||
// Expression-based filter hooks
|
||||
const filterProps = {
|
||||
expression,
|
||||
filters,
|
||||
setExpression,
|
||||
expressionRef,
|
||||
runQuery,
|
||||
};
|
||||
const { isHighlightErrors, handleToggle: handleToggleHighlightErrors } =
|
||||
useHighlightErrors(filterProps);
|
||||
const { selectedCategory, categories, handleCategoryChange } =
|
||||
useSpanCategoryFilter(filterProps);
|
||||
|
||||
const { search } = useLocation();
|
||||
const history = useHistory();
|
||||
|
||||
@@ -192,14 +110,14 @@ function Filters({
|
||||
tableParams: {
|
||||
pagination: {
|
||||
offset: 0,
|
||||
limit: 10000,
|
||||
limit: 200,
|
||||
},
|
||||
selectColumns: [
|
||||
{
|
||||
key: 'spanID',
|
||||
key: 'name',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
id: 'spanId--string--tag--true',
|
||||
id: 'name--string--tag--true',
|
||||
isIndexed: false,
|
||||
},
|
||||
],
|
||||
@@ -229,187 +147,58 @@ function Filters({
|
||||
setCurrentSearchedIndex(0);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
const isFilterActive = filters.items.length > 0;
|
||||
setNoData(false);
|
||||
setFilteredSpanIds([]);
|
||||
onFilteredSpansChange?.([], isFilterActive);
|
||||
setCurrentSearchedIndex(0);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const highlightErrorsToggle = (
|
||||
<div className="highlight-errors-toggle">
|
||||
<Typography.Text>Highlight errors</Typography.Text>
|
||||
<Switch
|
||||
color="cherry"
|
||||
value={isHighlightErrors}
|
||||
onChange={handleToggleHighlightErrors}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const statusIndicators = (
|
||||
<>
|
||||
{isFetching && <Loader className="animate-spin" />}
|
||||
{error && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="filter-status filter-status--error">
|
||||
<Info />
|
||||
API error
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(error as AxiosError)?.message || 'Something went wrong'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!error && noData && (
|
||||
<Typography.Text className="filter-status">
|
||||
No results found
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// --- COLLAPSED VIEW ---
|
||||
if (!isExpanded) {
|
||||
const pill = (
|
||||
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
|
||||
<div className="filter-pill" onClick={onExpand}>
|
||||
<Search size={12} />
|
||||
<span className="filter-pill__text">{expression || 'Search...'}</span>
|
||||
{expression && <span className="filter-pill__indicator" />}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="trace-v3-filter-row collapsed">
|
||||
{expression ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{pill}</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start">
|
||||
<div className="filter-pill-popover">
|
||||
<div className="filter-pill-popover__header">
|
||||
<Typography.Text>Search query</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
onClick={(): void => {
|
||||
setCopy(expression);
|
||||
toast.success('Copied to clipboard', {
|
||||
richColors: false,
|
||||
position: 'top-right',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Copy size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="filter-pill-popover__expression">{expression}</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
pill
|
||||
)}
|
||||
{highlightErrorsToggle}
|
||||
{statusIndicators}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// --- EXPANDED VIEW ---
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="trace-v3-filter-row expanded">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={selectedCategory}
|
||||
onChange={(value): void => {
|
||||
if (value) {
|
||||
handleCategoryChange(value as SpanCategory);
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
{categories.map((category) => (
|
||||
<ToggleGroupItem key={category} value={category}>
|
||||
{category}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
className="filter-search-container"
|
||||
ref={containerRef}
|
||||
onBlur={(e): void => {
|
||||
if (!containerRef.current?.contains(e.relatedTarget as Node)) {
|
||||
handleBlur();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<QuerySearch
|
||||
queryData={{
|
||||
...BASE_FILTER_QUERY,
|
||||
filters,
|
||||
filter: { expression },
|
||||
<div className="trace-v3-filter-row">
|
||||
<QueryBuilderSearchV2
|
||||
query={{
|
||||
...BASE_FILTER_QUERY,
|
||||
filters,
|
||||
}}
|
||||
onChange={handleFilterChange}
|
||||
hideSpanScopeSelector={false}
|
||||
skipQueryBuilderRedirect
|
||||
selectProps={{ listHeight: 125 }}
|
||||
/>
|
||||
{filteredSpanIds.length > 0 && (
|
||||
<div className="pre-next-toggle">
|
||||
<Typography.Text>
|
||||
{currentSearchedIndex + 1} / {filteredSpanIds.length}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
icon={<ChevronUp size={14} />}
|
||||
disabled={currentSearchedIndex === 0}
|
||||
type="text"
|
||||
onClick={(): void => {
|
||||
handlePrevNext(currentSearchedIndex - 1);
|
||||
setCurrentSearchedIndex((prev) => prev - 1);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<ChevronDown size={14} />}
|
||||
type="text"
|
||||
disabled={currentSearchedIndex === filteredSpanIds.length - 1}
|
||||
onClick={(): void => {
|
||||
handlePrevNext(currentSearchedIndex + 1);
|
||||
setCurrentSearchedIndex((prev) => prev + 1);
|
||||
}}
|
||||
onChange={handleExpressionChange}
|
||||
onRun={handleRunQuery}
|
||||
dataSource={DataSource.TRACES}
|
||||
placeholder="Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')"
|
||||
/>
|
||||
</div>
|
||||
{filteredSpanIds.length > 0 && (
|
||||
<div className="pre-next-toggle">
|
||||
<Typography.Text className="pre-next-toggle__count">
|
||||
{currentSearchedIndex + 1} / {filteredSpanIds.length}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
disabled={currentSearchedIndex === 0}
|
||||
onClick={(): void => {
|
||||
handlePrevNext(currentSearchedIndex - 1);
|
||||
setCurrentSearchedIndex((prev) => prev - 1);
|
||||
}}
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
disabled={currentSearchedIndex === filteredSpanIds.length - 1}
|
||||
onClick={(): void => {
|
||||
handlePrevNext(currentSearchedIndex + 1);
|
||||
setCurrentSearchedIndex((prev) => prev + 1);
|
||||
}}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className="filter-collapse-btn"
|
||||
onClick={onCollapse}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
{highlightErrorsToggle}
|
||||
{statusIndicators}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{isFetching && (
|
||||
<Spin indicator={<LoaderCircle className="animate-spin" />} size="small" />
|
||||
)}
|
||||
{error && (
|
||||
<Tooltip title={(error as AxiosError)?.message || 'Something went wrong'}>
|
||||
<Info size={14} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{noData && (
|
||||
<Typography.Text className="no-results">No results found</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { MutableRefObject } from 'react';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
/**
|
||||
* Shared props for expression-based filter hooks.
|
||||
* Each hook reads the current expression + derived filters,
|
||||
* and manipulates the expression via remove/add pattern.
|
||||
*/
|
||||
export interface ExpressionFilterProps {
|
||||
expression: string;
|
||||
filters: TagFilter;
|
||||
setExpression: (expr: string) => void;
|
||||
expressionRef: MutableRefObject<string>;
|
||||
runQuery: (expr: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: update expression state, ref, and trigger query.
|
||||
*/
|
||||
export function applyExpression(
|
||||
newExpression: string,
|
||||
props: Pick<
|
||||
ExpressionFilterProps,
|
||||
'setExpression' | 'expressionRef' | 'runQuery'
|
||||
>,
|
||||
): void {
|
||||
props.setExpression(newExpression);
|
||||
props.expressionRef.current = newExpression;
|
||||
props.runQuery(newExpression);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
|
||||
import { applyExpression, ExpressionFilterProps } from './types';
|
||||
|
||||
interface UseHighlightErrorsReturn {
|
||||
isHighlightErrors: boolean;
|
||||
handleToggle: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
const ERROR_KEY = 'has_error';
|
||||
|
||||
export function useHighlightErrors(
|
||||
props: ExpressionFilterProps,
|
||||
): UseHighlightErrorsReturn {
|
||||
const { expression, filters, setExpression, expressionRef, runQuery } = props;
|
||||
|
||||
// Derive from filters (only updates after runQuery, not on every keystroke)
|
||||
const isHighlightErrors = useMemo(
|
||||
() =>
|
||||
filters.items.some(
|
||||
(item) =>
|
||||
item.key?.key === ERROR_KEY &&
|
||||
(item.value === true || item.value === 'true'),
|
||||
),
|
||||
[filters],
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(checked: boolean): void => {
|
||||
// Always remove existing has_error first (whatever its value)
|
||||
let newExpr = removeKeysFromExpression(expression, [ERROR_KEY]);
|
||||
// Add back if turning ON
|
||||
if (checked) {
|
||||
newExpr = newExpr.trim()
|
||||
? `${newExpr.trim()} AND has_error = true`
|
||||
: `has_error = true`;
|
||||
}
|
||||
applyExpression(newExpr, { setExpression, expressionRef, runQuery });
|
||||
},
|
||||
[expression, setExpression, expressionRef, runQuery],
|
||||
);
|
||||
|
||||
return { isHighlightErrors, handleToggle };
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
|
||||
|
||||
import { applyExpression, ExpressionFilterProps } from './types';
|
||||
|
||||
export type SpanCategory =
|
||||
| 'All'
|
||||
| 'Database'
|
||||
| 'Functions'
|
||||
| 'HTTP'
|
||||
| 'Jobs'
|
||||
| 'LLM';
|
||||
|
||||
export const SPAN_CATEGORIES: readonly SpanCategory[] = [
|
||||
'All',
|
||||
'Database',
|
||||
'Functions',
|
||||
'HTTP',
|
||||
'Jobs',
|
||||
'LLM',
|
||||
];
|
||||
|
||||
// Map each category to the attribute key it filters on
|
||||
const CATEGORY_KEYS: Record<Exclude<SpanCategory, 'All'>, string> = {
|
||||
Database: 'db.system',
|
||||
HTTP: 'http.method',
|
||||
Functions: 'kind_string',
|
||||
Jobs: 'messaging.system',
|
||||
LLM: 'gen_ai.request.model',
|
||||
};
|
||||
|
||||
// All category keys — used for bulk removal when switching categories
|
||||
const ALL_CATEGORY_KEYS = Object.values(CATEGORY_KEYS);
|
||||
|
||||
// The expression clause to add for each category
|
||||
const CATEGORY_EXPRESSIONS: Record<Exclude<SpanCategory, 'All'>, string> = {
|
||||
Database: 'db.system exists',
|
||||
HTTP: 'http.method exists',
|
||||
Functions: "kind_string = 'Internal'",
|
||||
Jobs: 'messaging.system exists',
|
||||
LLM: 'gen_ai.request.model exists',
|
||||
};
|
||||
|
||||
interface UseSpanCategoryFilterReturn {
|
||||
selectedCategory: SpanCategory;
|
||||
categories: readonly SpanCategory[];
|
||||
handleCategoryChange: (category: SpanCategory) => void;
|
||||
}
|
||||
|
||||
export function useSpanCategoryFilter(
|
||||
props: ExpressionFilterProps,
|
||||
): UseSpanCategoryFilterReturn {
|
||||
const { expression, filters, setExpression, expressionRef, runQuery } = props;
|
||||
|
||||
// Derive active category from filters (only updates after runQuery)
|
||||
const selectedCategory = useMemo((): SpanCategory => {
|
||||
for (const [category, key] of Object.entries(CATEGORY_KEYS)) {
|
||||
if (filters.items.some((item) => item.key?.key === key)) {
|
||||
return category as SpanCategory;
|
||||
}
|
||||
}
|
||||
return 'All';
|
||||
}, [filters]);
|
||||
|
||||
const handleCategoryChange = useCallback(
|
||||
(category: SpanCategory): void => {
|
||||
// Remove ALL category keys first
|
||||
let newExpr = removeKeysFromExpression(expression, ALL_CATEGORY_KEYS);
|
||||
// Add the selected category clause (unless "All")
|
||||
if (category !== 'All') {
|
||||
const clause = CATEGORY_EXPRESSIONS[category];
|
||||
newExpr = newExpr.trim() ? `${newExpr.trim()} AND ${clause}` : clause;
|
||||
}
|
||||
applyExpression(newExpr, { setExpression, expressionRef, runQuery });
|
||||
},
|
||||
[expression, setExpression, expressionRef, runQuery],
|
||||
);
|
||||
|
||||
return {
|
||||
selectedCategory,
|
||||
categories: SPAN_CATEGORIES,
|
||||
handleCategoryChange,
|
||||
};
|
||||
}
|
||||
@@ -131,24 +131,6 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Invisible IntersectionObserver targets pinned at the top and bottom of
|
||||
// the virtualized content. See `useBoundaryPagination`.
|
||||
.waterfall-load-more-sentinel {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
pointer-events: none;
|
||||
|
||||
&--top {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&--bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.waterfall-sidebar {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
@@ -261,7 +243,7 @@
|
||||
}
|
||||
|
||||
&.dimmed-span {
|
||||
opacity: 0.15;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,7 +315,7 @@
|
||||
|
||||
.tree-connector {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
width: 11px;
|
||||
height: 50%;
|
||||
border-left: 1px solid var(--l2-border);
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
@@ -341,16 +323,6 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Reserved horizontal space for the chevron — present on every row,
|
||||
// filled only when the span has children. Keeps sibling icons aligned.
|
||||
.tree-arrow-slot {
|
||||
width: 18px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tree-arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -367,18 +339,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Reserved horizontal space for the subtree-count badge — same reason.
|
||||
// Right-aligns the badge inside so single-digit counts don't push the
|
||||
// icon left of where multi-digit counts would put it.
|
||||
.subtree-count-slot {
|
||||
min-width: 20px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.subtree-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -579,7 +539,7 @@
|
||||
}
|
||||
|
||||
.dimmed-span {
|
||||
opacity: 0.15;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.highlighted-span {
|
||||
opacity: 1;
|
||||
|
||||
@@ -27,11 +27,12 @@ import { useVirtualizer, Virtualizer } from '@tanstack/react-virtual';
|
||||
import cx from 'classnames';
|
||||
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
|
||||
import TimelineV3 from 'components/TimelineV3/TimelineV3';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
|
||||
import { useCopySpanLink } from 'hooks/trace/useCopySpanLink';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { colorToRgb } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { colorToRgb, generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
@@ -40,17 +41,15 @@ import {
|
||||
Link,
|
||||
ListPlus,
|
||||
} from '@signozhq/icons';
|
||||
import { useTraceContext } from 'pages/TraceDetailsV3/contexts/TraceContext';
|
||||
import { useBoundaryPagination } from 'pages/TraceDetailsV3/TraceWaterfall/hooks/useBoundaryPagination';
|
||||
import { useCrosshair } from 'pages/TraceDetailsV3/hooks/useCrosshair';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import { EventV3, SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
import { toFixed } from 'utils/toFixed';
|
||||
|
||||
import { EventTooltipContent } from '../../../SpanHoverCard/EventTooltipContent';
|
||||
import { SpanHoverCard } from '../../../SpanHoverCard/SpanHoverCard';
|
||||
import SpanHoverCard from '../../../SpanHoverCard/SpanHoverCard';
|
||||
import AddSpanToFunnelModal from '../../AddSpanToFunnelModal/AddSpanToFunnelModal';
|
||||
import { IInterestedSpan } from '../../types';
|
||||
import { IInterestedSpan } from '../../TraceWaterfall';
|
||||
|
||||
import './Success.styles.scss';
|
||||
|
||||
@@ -77,7 +76,7 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleMouseEnter = useCallback((): void => {
|
||||
timerRef.current = setTimeout(() => setShowPopover(true), 200);
|
||||
timerRef.current = setTimeout(() => setShowPopover(true), 150);
|
||||
}, []);
|
||||
|
||||
const handleMouseLeave = useCallback((): void => {
|
||||
@@ -134,7 +133,7 @@ const LazyEventDotPopover = memo(function LazyEventDotPopover({
|
||||
});
|
||||
|
||||
// css config
|
||||
const CONNECTOR_WIDTH = 30;
|
||||
const CONNECTOR_WIDTH = 20;
|
||||
const VERTICAL_CONNECTOR_WIDTH = 1;
|
||||
|
||||
interface SpanStateClasses {
|
||||
@@ -195,9 +194,8 @@ const SpanOverview = memo(function SpanOverview({
|
||||
selectedSpan,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
traceMetadata,
|
||||
onAddSpanToFunnel,
|
||||
onHoverEnter,
|
||||
onHoverLeave,
|
||||
}: {
|
||||
span: SpanV3;
|
||||
isSpanCollapsed: boolean;
|
||||
@@ -206,15 +204,19 @@ const SpanOverview = memo(function SpanOverview({
|
||||
handleSpanClick: (span: SpanV3) => void;
|
||||
filteredSpanIds: string[];
|
||||
isFilterActive: boolean;
|
||||
traceMetadata: ITraceMetadata;
|
||||
onAddSpanToFunnel: (span: SpanV3) => void;
|
||||
onHoverEnter: (spanId: string) => void;
|
||||
onHoverLeave: () => void;
|
||||
}): JSX.Element {
|
||||
const isRootSpan = span.level === 0;
|
||||
const { onSpanCopy } = useCopySpanLink(span);
|
||||
const { resolveSpanColor } = useTraceContext();
|
||||
|
||||
const color = resolveSpanColor(span);
|
||||
let color = generateColor(
|
||||
span['service.name'],
|
||||
themeColors.traceDetailColorsV3,
|
||||
);
|
||||
if (span.has_error) {
|
||||
color = `var(--bg-cherry-500)`;
|
||||
}
|
||||
|
||||
// Smart highlighting logic
|
||||
const {
|
||||
@@ -230,9 +232,6 @@ const SpanOverview = memo(function SpanOverview({
|
||||
isFilterActive,
|
||||
);
|
||||
|
||||
// All siblings at the same level share the same indent so the "same X =
|
||||
// same level" visual rule holds. Parent/child distinction is conveyed by
|
||||
// the chevron and the L-connector, not by an icon-X offset.
|
||||
const indentWidth = isRootSpan ? 0 : span.level * CONNECTOR_WIDTH;
|
||||
|
||||
const handleFunnelClick = (e: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
@@ -241,128 +240,126 @@ const SpanOverview = memo(function SpanOverview({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx('span-overview', {
|
||||
'interested-span': isSelected && (!isFilterActive || isMatching),
|
||||
'highlighted-span': isHighlighted,
|
||||
'selected-non-matching-span': isSelectedNonMatching,
|
||||
'dimmed-span': isDimmed,
|
||||
})}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
onMouseEnter={(): void => onHoverEnter(span.span_id)}
|
||||
onMouseLeave={(): void => onHoverLeave()}
|
||||
>
|
||||
{/* Tree connector lines — always draw vertical lines at all ancestor levels + L-connector */}
|
||||
{!isRootSpan &&
|
||||
Array.from({ length: span.level }, (_, i) => {
|
||||
const lvl = i + 1;
|
||||
const xPos = (lvl - 1) * CONNECTOR_WIDTH + 9;
|
||||
if (lvl < span.level) {
|
||||
// Stop the line at 50% for the last child's parent level
|
||||
const isLastChildParentLine = !span.has_sibling && lvl === span.level - 1;
|
||||
return (
|
||||
<div
|
||||
key={lvl}
|
||||
className="tree-line"
|
||||
style={{
|
||||
left: xPos,
|
||||
top: 0,
|
||||
width: 1,
|
||||
height: isLastChildParentLine ? '50%' : '100%',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={lvl}>
|
||||
<div
|
||||
className="tree-line"
|
||||
style={{ left: xPos, top: 0, width: 1, height: '50%' }}
|
||||
/>
|
||||
<div className="tree-connector" style={{ left: xPos, top: 0 }} />
|
||||
</div>
|
||||
);
|
||||
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
|
||||
<div
|
||||
className={cx('span-overview', {
|
||||
'interested-span': isSelected && (!isFilterActive || isMatching),
|
||||
'highlighted-span': isHighlighted,
|
||||
'selected-non-matching-span': isSelectedNonMatching,
|
||||
'dimmed-span': isDimmed,
|
||||
})}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
>
|
||||
{/* Tree connector lines — always draw vertical lines at all ancestor levels + L-connector */}
|
||||
{!isRootSpan &&
|
||||
Array.from({ length: span.level }, (_, i) => {
|
||||
const lvl = i + 1;
|
||||
const xPos = (lvl - 1) * CONNECTOR_WIDTH + 9;
|
||||
if (lvl < span.level) {
|
||||
// Stop the line at 50% for the last child's parent level
|
||||
const isLastChildParentLine =
|
||||
!span.has_sibling && lvl === span.level - 1;
|
||||
return (
|
||||
<div
|
||||
key={lvl}
|
||||
className="tree-line"
|
||||
style={{
|
||||
left: xPos,
|
||||
top: 0,
|
||||
width: 1,
|
||||
height: isLastChildParentLine ? '50%' : '100%',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={lvl}>
|
||||
<div
|
||||
className="tree-line"
|
||||
style={{ left: xPos, top: 0, width: 1, height: '50%' }}
|
||||
/>
|
||||
<div className="tree-connector" style={{ left: xPos, top: 0 }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Indent spacer */}
|
||||
<span className="tree-indent" style={{ width: `${indentWidth}px` }} />
|
||||
{/* Indent spacer */}
|
||||
<span className="tree-indent" style={{ width: `${indentWidth}px` }} />
|
||||
|
||||
{/* Expand/collapse arrow + child count slots — always render the
|
||||
slots, fill them only when the span has children. Reserving the
|
||||
horizontal space on leaf rows aligns sibling icons regardless
|
||||
of whether each sibling is a parent or a leaf. */}
|
||||
<span className="tree-arrow-slot">
|
||||
{/* Expand/collapse arrow + child count (only for spans with children) */}
|
||||
{span.has_children && (
|
||||
<span
|
||||
className={cx('tree-arrow', { expanded: !isSpanCollapsed })}
|
||||
onClick={(event): void => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handleCollapseUncollapse(span.span_id, !isSpanCollapsed);
|
||||
}}
|
||||
>
|
||||
{isSpanCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
</span>
|
||||
<>
|
||||
<span
|
||||
className={cx('tree-arrow', { expanded: !isSpanCollapsed })}
|
||||
onClick={(event): void => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handleCollapseUncollapse(span.span_id, !isSpanCollapsed);
|
||||
}}
|
||||
>
|
||||
{isSpanCollapsed ? (
|
||||
<ChevronRight size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
)}
|
||||
</span>
|
||||
<span className="subtree-count">
|
||||
<Badge color="vanilla">{span.sub_tree_node_count}</Badge>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className="subtree-count-slot">
|
||||
{span.has_children && (
|
||||
<span className="subtree-count">
|
||||
<Badge color="vanilla">{span.sub_tree_node_count}</Badge>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* Colored service dot */}
|
||||
<span
|
||||
className={cx('tree-icon', { 'is-error': span.has_error })}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{/* Colored service dot */}
|
||||
<span
|
||||
className={cx('tree-icon', { 'is-error': span.has_error })}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
|
||||
{/* Span name + service name */}
|
||||
<span className="tree-label">
|
||||
{span.name}
|
||||
<span className="tree-service-name">{span['service.name']}</span>
|
||||
</span>
|
||||
{/* Span name + service name */}
|
||||
<span className="tree-label">
|
||||
{span.name}
|
||||
<span className="tree-service-name">{span['service.name']}</span>
|
||||
</span>
|
||||
|
||||
{/* Action buttons — shown on hover via CSS, right-aligned */}
|
||||
<span className="span-row-actions">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className="span-action-btn"
|
||||
onClick={onSpanCopy}
|
||||
>
|
||||
<Link size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="span-action-tooltip">
|
||||
Copy Span Link
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className="span-action-btn"
|
||||
onClick={handleFunnelClick}
|
||||
>
|
||||
<ListPlus size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="span-action-tooltip">
|
||||
Add to Trace Funnel
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
</div>
|
||||
{/* Action buttons — shown on hover via CSS, right-aligned */}
|
||||
<span className="span-row-actions">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className="span-action-btn"
|
||||
onClick={onSpanCopy}
|
||||
>
|
||||
<Link size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="span-action-tooltip">
|
||||
Copy Span Link
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
color="secondary"
|
||||
className="span-action-btn"
|
||||
onClick={handleFunnelClick}
|
||||
>
|
||||
<ListPlus size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="span-action-tooltip">
|
||||
Add to Trace Funnel
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</span>
|
||||
</div>
|
||||
</SpanHoverCard>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -389,10 +386,16 @@ export const SpanDuration = memo(function SpanDuration({
|
||||
const leftOffset = ((span.timestamp - traceMetadata.startTime) * 1e2) / spread;
|
||||
const width = (span.duration_nano * 1e2) / (spread * 1e6);
|
||||
|
||||
const { resolveSpanColor } = useTraceContext();
|
||||
const color = resolveSpanColor(span);
|
||||
// `resolveSpanColor` returns a CSS variable for errors; `colorToRgb` can't parse it.
|
||||
const rgbColor = span.has_error ? '239, 68, 68' : colorToRgb(color);
|
||||
let color = generateColor(
|
||||
span['service.name'],
|
||||
themeColors.traceDetailColorsV3,
|
||||
);
|
||||
let rgbColor = colorToRgb(color);
|
||||
|
||||
if (span.has_error) {
|
||||
color = `var(--bg-cherry-500)`;
|
||||
rgbColor = '239, 68, 68';
|
||||
}
|
||||
|
||||
const {
|
||||
isSelected,
|
||||
@@ -417,25 +420,27 @@ export const SpanDuration = memo(function SpanDuration({
|
||||
})}
|
||||
onClick={(): void => handleSpanClick(span)}
|
||||
>
|
||||
<div
|
||||
className="span-bar"
|
||||
style={
|
||||
{
|
||||
left: `${leftOffset}%`,
|
||||
width: `${width}%`,
|
||||
'--span-color': color,
|
||||
'--span-color-rgb': rgbColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="span-info">
|
||||
<span className="span-name">{span.name}</span>
|
||||
<span className="span-duration-text">{`${toFixed(
|
||||
time,
|
||||
2,
|
||||
)} ${timeUnitName}`}</span>
|
||||
</span>
|
||||
</div>
|
||||
<SpanHoverCard span={span} traceMetadata={traceMetadata}>
|
||||
<div
|
||||
className="span-bar"
|
||||
style={
|
||||
{
|
||||
left: `${leftOffset}%`,
|
||||
width: `${width}%`,
|
||||
'--span-color': color,
|
||||
'--span-color-rgb': rgbColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="span-info">
|
||||
<span className="span-name">{span.name}</span>
|
||||
<span className="span-duration-text">{`${toFixed(
|
||||
time,
|
||||
2,
|
||||
)} ${timeUnitName}`}</span>
|
||||
</span>
|
||||
</div>
|
||||
</SpanHoverCard>
|
||||
{span.events?.map((event) => {
|
||||
const eventTimeMs = event.timeUnixNano / 1e6;
|
||||
const spanDurationMs = span.duration_nano / 1e6;
|
||||
@@ -501,17 +506,6 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
const prevHoveredSpanIdRef = useRef<string | null>(null);
|
||||
const autoScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const {
|
||||
topSentinelRef: loadMoreTopSentinelRef,
|
||||
bottomSentinelRef: loadMoreBottomSentinelRef,
|
||||
} = useBoundaryPagination({
|
||||
scrollContainerRef,
|
||||
spans,
|
||||
isFetching,
|
||||
isFullDataLoaded,
|
||||
setInterestedSpanId,
|
||||
});
|
||||
|
||||
const {
|
||||
cursorXPercent,
|
||||
cursorX,
|
||||
@@ -537,32 +531,15 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
prevHoveredSpanIdRef.current = spanId;
|
||||
}, []);
|
||||
|
||||
// Hover-card state — single popover anchored at the sidebar/timeline
|
||||
// boundary, Y tracks the hovered row. Set after a 500 ms debounce so fast
|
||||
// scrolls/cursor sweeps don't fire the card.
|
||||
const [hoveredSpanId, setHoveredSpanId] = useState<string | null>(null);
|
||||
const hoverDelayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleRowMouseEnter = useCallback(
|
||||
(spanId: string): void => {
|
||||
applyHoverClass(spanId);
|
||||
if (hoverDelayTimerRef.current) {
|
||||
clearTimeout(hoverDelayTimerRef.current);
|
||||
}
|
||||
hoverDelayTimerRef.current = setTimeout(() => {
|
||||
setHoveredSpanId(spanId);
|
||||
}, 500);
|
||||
},
|
||||
[applyHoverClass],
|
||||
);
|
||||
|
||||
const handleRowMouseLeave = useCallback((): void => {
|
||||
applyHoverClass(null);
|
||||
if (hoverDelayTimerRef.current) {
|
||||
clearTimeout(hoverDelayTimerRef.current);
|
||||
hoverDelayTimerRef.current = null;
|
||||
}
|
||||
setHoveredSpanId(null);
|
||||
}, [applyHoverClass]);
|
||||
|
||||
const handleCollapseUncollapse = useCallback(
|
||||
@@ -578,15 +555,14 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
// Backend mode: trigger API call (current behavior)
|
||||
setInterestedSpanId({
|
||||
spanId,
|
||||
isUncollapsed: !collapse,
|
||||
scrollToSpan: false,
|
||||
});
|
||||
}
|
||||
// Backend mode: trigger API call (current behavior)
|
||||
// keeping this for both mode to support scroll to view to function well.
|
||||
// interestedspan would not make api call in frontend mode so it is safe to use for both mode.
|
||||
setInterestedSpanId({
|
||||
spanId,
|
||||
isUncollapsed: !collapse,
|
||||
scrollToSpan: false,
|
||||
});
|
||||
},
|
||||
[isFullDataLoaded, setLocalUncollapsedNodes, setInterestedSpanId],
|
||||
);
|
||||
@@ -639,8 +615,32 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
}
|
||||
}, 20);
|
||||
}
|
||||
|
||||
// In frontend mode all data is already loaded, no need to fetch more.
|
||||
// In backend mode, skip auto-fetch when under 500 spans (nothing more to paginate).
|
||||
if (isFullDataLoaded || spans.length < 500) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (range?.startIndex === 0 && instance.isScrolling) {
|
||||
// do not trigger for trace root as nothing to fetch above
|
||||
if (spans[0].level !== 0) {
|
||||
setInterestedSpanId({
|
||||
spanId: spans[0].span_id,
|
||||
isUncollapsed: false,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (range?.endIndex === spans.length - 1 && instance.isScrolling) {
|
||||
setInterestedSpanId({
|
||||
spanId: spans[spans.length - 1].span_id,
|
||||
isUncollapsed: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
[spans],
|
||||
[spans, setInterestedSpanId],
|
||||
);
|
||||
|
||||
const [isAddSpanToFunnelModalOpen, setIsAddSpanToFunnelModalOpen] =
|
||||
@@ -685,11 +685,10 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
}
|
||||
selectedSpan={selectedSpan}
|
||||
handleSpanClick={handleSpanClick}
|
||||
traceMetadata={traceMetadata}
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
onAddSpanToFunnel={handleAddSpanToFunnel}
|
||||
onHoverEnter={handleRowMouseEnter}
|
||||
onHoverLeave={handleRowMouseLeave}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
@@ -699,13 +698,12 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
uncollapsedNodes,
|
||||
isFullDataLoaded,
|
||||
localUncollapsedNodes,
|
||||
traceMetadata,
|
||||
selectedSpan,
|
||||
handleSpanClick,
|
||||
filteredSpanIds,
|
||||
isFilterActive,
|
||||
handleAddSpanToFunnel,
|
||||
handleRowMouseEnter,
|
||||
handleRowMouseLeave,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -782,12 +780,6 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
const leftRows = leftTable.getRowModel().rows;
|
||||
|
||||
const handleHoverCardOpenChange = useCallback((open: boolean): void => {
|
||||
if (!open) {
|
||||
setHoveredSpanId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="success-content">
|
||||
{traceMetadata.hasMissingSpans && (
|
||||
@@ -841,24 +833,6 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{/* Top / bottom sentinels: each transition into the viewport
|
||||
fires a load-more via useBoundaryPagination. */}
|
||||
<div
|
||||
ref={loadMoreTopSentinelRef}
|
||||
className="waterfall-load-more-sentinel waterfall-load-more-sentinel--top"
|
||||
/>
|
||||
<div
|
||||
ref={loadMoreBottomSentinelRef}
|
||||
className="waterfall-load-more-sentinel waterfall-load-more-sentinel--bottom"
|
||||
/>
|
||||
<SpanHoverCard
|
||||
hoveredSpanId={hoveredSpanId}
|
||||
onOpenChange={handleHoverCardOpenChange}
|
||||
anchorLeft={sidebarWidth}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
spans={spans}
|
||||
traceStartTime={traceMetadata.startTime}
|
||||
/>
|
||||
{/* Left panel - table with horizontal scroll */}
|
||||
<ResizableBox
|
||||
direction="horizontal"
|
||||
@@ -968,8 +942,8 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
height: ROW_HEIGHT,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
onMouseEnter={(): void => applyHoverClass(span.span_id)}
|
||||
onMouseLeave={(): void => applyHoverClass(null)}
|
||||
onMouseEnter={(): void => handleRowMouseEnter(span.span_id)}
|
||||
onMouseLeave={handleRowMouseLeave}
|
||||
>
|
||||
<SpanDuration
|
||||
span={span}
|
||||
|
||||
@@ -2,12 +2,8 @@ import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceProvider } from '../../../../contexts/TraceContext';
|
||||
import { SpanDuration } from '../Success';
|
||||
|
||||
const renderWithTraceProvider: typeof render = (ui, options) =>
|
||||
render(<TraceProvider aggregations={undefined}>{ui}</TraceProvider>, options);
|
||||
|
||||
// Constants to avoid string duplication
|
||||
const SPAN_DURATION_TEXT = '1.16 ms';
|
||||
const SPAN_DURATION_CLASS = '.span-duration';
|
||||
@@ -23,9 +19,6 @@ jest.mock('components/TimelineV3/TimelineV3', () => ({
|
||||
|
||||
// Mock the hooks
|
||||
jest.mock('hooks/useUrlQuery');
|
||||
jest.mock('@signozhq/ui', () => ({
|
||||
Badge: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockSpan: SpanV3 = {
|
||||
span_id: 'test-span-id',
|
||||
@@ -95,7 +88,7 @@ describe('SpanDuration', () => {
|
||||
it('calls handleSpanClick when clicked', () => {
|
||||
const mockHandleSpanClick = jest.fn();
|
||||
|
||||
renderWithTraceProvider(
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
@@ -115,7 +108,7 @@ describe('SpanDuration', () => {
|
||||
});
|
||||
|
||||
it('shows action buttons on hover', () => {
|
||||
renderWithTraceProvider(
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
@@ -136,7 +129,7 @@ describe('SpanDuration', () => {
|
||||
});
|
||||
|
||||
it('applies interested-span class when span is selected', () => {
|
||||
renderWithTraceProvider(
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
@@ -154,7 +147,7 @@ describe('SpanDuration', () => {
|
||||
});
|
||||
|
||||
it('applies highlighted-span class when span matches filter', () => {
|
||||
renderWithTraceProvider(
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
@@ -173,7 +166,7 @@ describe('SpanDuration', () => {
|
||||
});
|
||||
|
||||
it('applies dimmed-span class when span does not match filter', () => {
|
||||
renderWithTraceProvider(
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
@@ -192,7 +185,7 @@ describe('SpanDuration', () => {
|
||||
});
|
||||
|
||||
it('prioritizes interested-span over highlighted-span when span is selected and matches filter', () => {
|
||||
renderWithTraceProvider(
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
@@ -212,7 +205,7 @@ describe('SpanDuration', () => {
|
||||
});
|
||||
|
||||
it('applies selected-non-matching-span class when span is selected but does not match filter', () => {
|
||||
renderWithTraceProvider(
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
@@ -233,7 +226,7 @@ describe('SpanDuration', () => {
|
||||
});
|
||||
|
||||
it('applies interested-span class when span is selected and no filter is active', () => {
|
||||
renderWithTraceProvider(
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
@@ -254,7 +247,7 @@ describe('SpanDuration', () => {
|
||||
});
|
||||
|
||||
it('dims span when filter is active but no matches found', () => {
|
||||
renderWithTraceProvider(
|
||||
render(
|
||||
<SpanDuration
|
||||
span={mockSpan}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
|
||||
@@ -2,16 +2,8 @@ import React from 'react';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { TraceProvider } from '../../../../contexts/TraceContext';
|
||||
import Success from '../Success';
|
||||
|
||||
const renderWithTraceProvider: typeof render = (ui, options, customOptions) =>
|
||||
render(
|
||||
<TraceProvider aggregations={undefined}>{ui}</TraceProvider>,
|
||||
options,
|
||||
customOptions,
|
||||
);
|
||||
|
||||
// Mock the required hooks with proper typing
|
||||
const mockSafeNavigate = jest.fn() as jest.MockedFunction<
|
||||
(params: { search: string }) => void
|
||||
@@ -230,7 +222,7 @@ describe('Span Click User Flows', () => {
|
||||
it('clicking span updates URL with spanId parameter', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderWithTraceProvider(
|
||||
render(
|
||||
<Success
|
||||
spans={mockSpans}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
@@ -269,7 +261,7 @@ describe('Span Click User Flows', () => {
|
||||
it('clicking span duration visually selects the span', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderWithTraceProvider(<TestComponent />, undefined, {
|
||||
render(<TestComponent />, undefined, {
|
||||
initialRoute: '/trace',
|
||||
});
|
||||
|
||||
@@ -308,7 +300,7 @@ describe('Span Click User Flows', () => {
|
||||
it('both click areas produce the same visual result', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderWithTraceProvider(<TestComponent />, undefined, {
|
||||
render(<TestComponent />, undefined, {
|
||||
initialRoute: '/trace',
|
||||
});
|
||||
|
||||
@@ -368,7 +360,7 @@ describe('Span Click User Flows', () => {
|
||||
it('clicking different spans updates selection correctly', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
renderWithTraceProvider(<TestComponent />, undefined, {
|
||||
render(<TestComponent />, undefined, {
|
||||
initialRoute: '/trace',
|
||||
});
|
||||
|
||||
@@ -412,7 +404,7 @@ describe('Span Click User Flows', () => {
|
||||
mockUrlQuery.set('existingParam', 'existingValue');
|
||||
mockUrlQuery.set('anotherParam', 'anotherValue');
|
||||
|
||||
renderWithTraceProvider(
|
||||
render(
|
||||
<Success
|
||||
spans={mockSpans}
|
||||
traceMetadata={mockTraceMetadata}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { RefObject, useEffect, useRef } from 'react';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { IInterestedSpan } from '../types';
|
||||
|
||||
const MIN_SPANS_FOR_PAGINATION = 500;
|
||||
|
||||
interface UseBoundaryPaginationProps {
|
||||
scrollContainerRef: RefObject<HTMLDivElement>;
|
||||
spans: SpanV3[];
|
||||
isFetching: boolean | undefined;
|
||||
isFullDataLoaded: boolean;
|
||||
setInterestedSpanId: (next: IInterestedSpan) => void;
|
||||
}
|
||||
|
||||
interface UseBoundaryPaginationResult {
|
||||
topSentinelRef: RefObject<HTMLDivElement>;
|
||||
bottomSentinelRef: RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives load-more on a virtualized list via two `IntersectionObserver`
|
||||
* sentinels (top + bottom of the inner content). The observer is created
|
||||
* once and reads live state through refs — recreating it would re-fire
|
||||
* IO's mandatory initial-intersection callback for sentinels still in view
|
||||
* and produce a fetch spiral on every data update.
|
||||
*
|
||||
* Returns the two refs the caller must attach to its sentinel `<div>`s.
|
||||
*/
|
||||
export function useBoundaryPagination({
|
||||
scrollContainerRef,
|
||||
spans,
|
||||
isFetching,
|
||||
isFullDataLoaded,
|
||||
setInterestedSpanId,
|
||||
}: UseBoundaryPaginationProps): UseBoundaryPaginationResult {
|
||||
const topSentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
const bottomSentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const spansRef = useRef<SpanV3[]>(spans);
|
||||
const isFetchingRef = useRef<boolean | undefined>(isFetching);
|
||||
const isFullDataLoadedRef = useRef<boolean>(isFullDataLoaded);
|
||||
|
||||
useEffect(() => {
|
||||
spansRef.current = spans;
|
||||
}, [spans]);
|
||||
useEffect(() => {
|
||||
isFetchingRef.current = isFetching;
|
||||
}, [isFetching]);
|
||||
useEffect(() => {
|
||||
isFullDataLoadedRef.current = isFullDataLoaded;
|
||||
}, [isFullDataLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = scrollContainerRef.current;
|
||||
if (!root) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const seenInitial = new WeakSet<Element>();
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!seenInitial.has(entry.target)) {
|
||||
seenInitial.add(entry.target);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!entry.isIntersecting ||
|
||||
isFetchingRef.current ||
|
||||
isFullDataLoadedRef.current ||
|
||||
spansRef.current.length < MIN_SPANS_FOR_PAGINATION
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.target === bottomSentinelRef.current) {
|
||||
const lastSpan = spansRef.current[spansRef.current.length - 1];
|
||||
if (lastSpan) {
|
||||
setInterestedSpanId({
|
||||
spanId: lastSpan.span_id,
|
||||
isUncollapsed: false,
|
||||
});
|
||||
}
|
||||
} else if (entry.target === topSentinelRef.current) {
|
||||
const firstSpan = spansRef.current[0];
|
||||
if (firstSpan && firstSpan.level !== 0) {
|
||||
setInterestedSpanId({
|
||||
spanId: firstSpan.span_id,
|
||||
isUncollapsed: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
{ root, threshold: 0 },
|
||||
);
|
||||
|
||||
if (bottomSentinelRef.current) {
|
||||
observer.observe(bottomSentinelRef.current);
|
||||
}
|
||||
if (topSentinelRef.current) {
|
||||
observer.observe(topSentinelRef.current);
|
||||
}
|
||||
|
||||
return (): void => observer.disconnect();
|
||||
}, [scrollContainerRef, setInterestedSpanId]);
|
||||
|
||||
return { topSentinelRef, bottomSentinelRef };
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface IInterestedSpan {
|
||||
spanId: string;
|
||||
isUncollapsed: boolean;
|
||||
scrollToSpan?: boolean;
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { GripVertical } from '@signozhq/icons';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
function SortableField({
|
||||
field,
|
||||
onRemove,
|
||||
allowDrag,
|
||||
}: {
|
||||
field: BaseAutocompleteData;
|
||||
onRemove: (field: BaseAutocompleteData) => void;
|
||||
allowDrag: boolean;
|
||||
}): JSX.Element {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: field.key });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`fs-field-item ${allowDrag ? 'drag-enabled' : 'drag-disabled'}`}
|
||||
>
|
||||
<div {...attributes} {...listeners} className="drag-handle">
|
||||
{allowDrag && <GripVertical size={14} />}
|
||||
<span className="fs-field-key">{field.key}</span>
|
||||
</div>
|
||||
<Button
|
||||
className="remove-field-btn periscope-btn"
|
||||
variant="outlined"
|
||||
color="destructive"
|
||||
size="sm"
|
||||
onClick={(): void => onRemove(field)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AddedFieldsProps {
|
||||
inputValue: string;
|
||||
fields: BaseAutocompleteData[];
|
||||
onFieldsChange: (fields: BaseAutocompleteData[]) => void;
|
||||
}
|
||||
|
||||
function AddedFields({
|
||||
inputValue,
|
||||
fields,
|
||||
onFieldsChange,
|
||||
}: AddedFieldsProps): JSX.Element {
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent): void => {
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = fields.findIndex((f) => f.key === active.id);
|
||||
const newIndex = fields.findIndex((f) => f.key === over.id);
|
||||
onFieldsChange(arrayMove(fields, oldIndex, newIndex));
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFields = useMemo(
|
||||
() =>
|
||||
fields.filter((f) => f.key.toLowerCase().includes(inputValue.toLowerCase())),
|
||||
[fields, inputValue],
|
||||
);
|
||||
|
||||
const handleRemove = (field: BaseAutocompleteData): void => {
|
||||
onFieldsChange(fields.filter((f) => f.key !== field.key));
|
||||
};
|
||||
|
||||
const allowDrag = inputValue.length === 0;
|
||||
|
||||
return (
|
||||
<div className="fs-section fs-added">
|
||||
<div className="fs-section-header">ADDED FIELDS</div>
|
||||
<div className="fs-added-list">
|
||||
<OverlayScrollbar>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{filteredFields.length === 0 ? (
|
||||
<div className="fs-no-values">No values found</div>
|
||||
) : (
|
||||
<SortableContext
|
||||
items={fields.map((f) => f.key)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
disabled={!allowDrag}
|
||||
>
|
||||
{filteredFields.map((field) => (
|
||||
<SortableField
|
||||
key={field.key}
|
||||
field={field}
|
||||
onRemove={handleRemove}
|
||||
allowDrag={allowDrag}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
)}
|
||||
</DndContext>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddedFields;
|
||||
@@ -1,161 +0,0 @@
|
||||
.fields-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--l1-background);
|
||||
color: var(--l1-foreground);
|
||||
overflow: hidden;
|
||||
|
||||
.fs-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.fs-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fs-close-icon {
|
||||
cursor: pointer;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
&:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fs-search {
|
||||
.fs-search-input {
|
||||
background-color: var(--l1-background);
|
||||
height: 40px;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.fs-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.fs-added {
|
||||
max-height: 40%;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
&.fs-other {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.fs-section-header {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.fs-added-list {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fs-other-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.ant-skeleton-input {
|
||||
width: 300px;
|
||||
margin: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.fs-no-values {
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
color: var(--l2-foreground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.fs-limit-hint {
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.fs-field-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
font-size: 13px;
|
||||
|
||||
.drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.fs-field-key {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.drag-enabled {
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
&.drag-disabled {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
&.other-field-item {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.remove-field-btn,
|
||||
.add-field-btn {
|
||||
padding: 4px 10px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--l2-background);
|
||||
|
||||
.remove-field-btn,
|
||||
.add-field-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fs-footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { toast } from '@signozhq/ui';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { Check, TableColumnsSplit, X } from '@signozhq/icons';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import AddedFields from './AddedFields';
|
||||
import OtherFields from './OtherFields';
|
||||
|
||||
import './FieldsSettings.styles.scss';
|
||||
|
||||
const MAX_FIELDS_DEFAULT = 10;
|
||||
|
||||
interface FieldsSettingsProps {
|
||||
title: string;
|
||||
// Picker's native shape (`BaseAutocompleteData`) is preserved end-to-end so
|
||||
// downstream consumers (flamegraph `selectFields`, hover popovers) get full
|
||||
// field metadata without a lossy conversion at add-time.
|
||||
fields: BaseAutocompleteData[];
|
||||
onFieldsChange: (fields: BaseAutocompleteData[]) => void;
|
||||
onClose: () => void;
|
||||
dataSource: DataSource;
|
||||
maxFields?: number;
|
||||
}
|
||||
|
||||
function FieldsSettings({
|
||||
title,
|
||||
fields,
|
||||
onFieldsChange,
|
||||
onClose,
|
||||
dataSource,
|
||||
maxFields = MAX_FIELDS_DEFAULT,
|
||||
}: FieldsSettingsProps): JSX.Element {
|
||||
// Local draft state — changes here don't persist until Save
|
||||
const [draftFields, setDraftFields] = useState<BaseAutocompleteData[]>(fields);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [debouncedInputValue, setDebouncedInputValue] = useState('');
|
||||
|
||||
const debouncedUpdate = useDebouncedFn((value) => {
|
||||
setDebouncedInputValue(value as string);
|
||||
}, 400);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const value = e.target.value.trim().toLowerCase();
|
||||
setInputValue(value);
|
||||
debouncedUpdate(value);
|
||||
},
|
||||
[debouncedUpdate],
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(
|
||||
(field: BaseAutocompleteData): void => {
|
||||
if (draftFields.length >= maxFields) {
|
||||
return;
|
||||
}
|
||||
if (draftFields.some((f) => f.key === field.key)) {
|
||||
return;
|
||||
}
|
||||
setDraftFields((prev) => [...prev, field]);
|
||||
},
|
||||
[draftFields, maxFields],
|
||||
);
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
onFieldsChange(draftFields);
|
||||
toast.success('Saved successfully', {
|
||||
position: 'top-right',
|
||||
});
|
||||
onClose();
|
||||
}, [draftFields, onFieldsChange, onClose]);
|
||||
|
||||
const handleDiscard = useCallback((): void => {
|
||||
setDraftFields(fields);
|
||||
}, [fields]);
|
||||
|
||||
const hasUnsavedChanges = useMemo(
|
||||
() =>
|
||||
!(
|
||||
draftFields.length === fields.length &&
|
||||
draftFields.every((f, i) => f.key === fields[i]?.key)
|
||||
),
|
||||
[draftFields, fields],
|
||||
);
|
||||
|
||||
const isAtLimit = draftFields.length >= maxFields;
|
||||
|
||||
return (
|
||||
<div className="fields-settings">
|
||||
<div className="fs-header">
|
||||
<div className="fs-title">
|
||||
<TableColumnsSplit size={16} />
|
||||
{title}
|
||||
</div>
|
||||
<X className="fs-close-icon" size={16} onClick={onClose} />
|
||||
</div>
|
||||
|
||||
<section className="fs-search">
|
||||
<Input
|
||||
className="fs-search-input"
|
||||
type="text"
|
||||
value={inputValue}
|
||||
placeholder="Search for a field..."
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<AddedFields
|
||||
inputValue={inputValue}
|
||||
fields={draftFields}
|
||||
onFieldsChange={setDraftFields}
|
||||
/>
|
||||
|
||||
<OtherFields
|
||||
dataSource={dataSource}
|
||||
debouncedInputValue={debouncedInputValue}
|
||||
addedFields={draftFields}
|
||||
onAdd={handleAdd}
|
||||
isAtLimit={isAtLimit}
|
||||
/>
|
||||
|
||||
{hasUnsavedChanges && (
|
||||
<div className="fs-footer">
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={handleDiscard}
|
||||
prefix={<X width={14} height={14} />}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={handleSave}
|
||||
prefix={<Check width={14} height={14} />}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FieldsSettings;
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Skeleton } from 'antd';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
interface OtherFieldsProps {
|
||||
dataSource: DataSource;
|
||||
debouncedInputValue: string;
|
||||
addedFields: BaseAutocompleteData[];
|
||||
onAdd: (field: BaseAutocompleteData) => void;
|
||||
isAtLimit: boolean;
|
||||
}
|
||||
|
||||
function OtherFields({
|
||||
dataSource,
|
||||
debouncedInputValue,
|
||||
addedFields,
|
||||
onAdd,
|
||||
isAtLimit,
|
||||
}: OtherFieldsProps): JSX.Element {
|
||||
// API call to get available attribute keys
|
||||
const { data, isFetching } = useGetAggregateKeys(
|
||||
{
|
||||
searchText: debouncedInputValue,
|
||||
dataSource,
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: '',
|
||||
tagType: '',
|
||||
},
|
||||
{
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_OTHER_FILTERS,
|
||||
'preview-fields',
|
||||
debouncedInputValue,
|
||||
],
|
||||
enabled: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Filter out already-added fields, match on .key from API response objects
|
||||
const otherFields = useMemo(() => {
|
||||
const attributes = data?.payload?.attributeKeys || [];
|
||||
const addedKeys = new Set(addedFields.map((f) => f.key));
|
||||
return attributes.filter((attr) => !addedKeys.has(attr.key));
|
||||
}, [data, addedFields]);
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<div className="fs-section fs-other">
|
||||
<div className="fs-section-header">OTHER FIELDS</div>
|
||||
<div className="fs-other-list">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<Skeleton.Input active size="small" key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fs-section fs-other">
|
||||
<div className="fs-section-header">OTHER FIELDS</div>
|
||||
<div className="fs-other-list">
|
||||
<OverlayScrollbar>
|
||||
<>
|
||||
{otherFields.length === 0 ? (
|
||||
<div className="fs-no-values">No values found</div>
|
||||
) : (
|
||||
otherFields.map((attr) => (
|
||||
<div key={attr.key} className="fs-field-item other-field-item">
|
||||
<span className="fs-field-key">{attr.key}</span>
|
||||
{!isAtLimit && (
|
||||
<Button
|
||||
className="add-field-btn periscope-btn"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={(): void => onAdd(attr)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{isAtLimit && <div className="fs-limit-hint">Maximum 10 fields</div>}
|
||||
</>
|
||||
</OverlayScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OtherFields;
|
||||
@@ -1,55 +0,0 @@
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
export interface ColorByOption {
|
||||
field: TelemetryFieldKey;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const COLOR_BY_OPTIONS: ColorByOption[] = [
|
||||
{
|
||||
field: {
|
||||
name: 'service.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
label: 'Service',
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'service.namespace',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
label: 'Namespace',
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'host.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
label: 'Host',
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'k8s.node.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
label: 'Node',
|
||||
},
|
||||
{
|
||||
field: {
|
||||
name: 'k8s.container.name',
|
||||
fieldContext: 'resource',
|
||||
fieldDataType: 'string',
|
||||
},
|
||||
label: 'Container',
|
||||
},
|
||||
];
|
||||
|
||||
export const COLOR_BY_FIELDS: TelemetryFieldKey[] = COLOR_BY_OPTIONS.map(
|
||||
(o) => o.field,
|
||||
);
|
||||
|
||||
export const DEFAULT_COLOR_BY_FIELD = COLOR_BY_FIELDS[0];
|
||||
@@ -1,220 +0,0 @@
|
||||
import {
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
// oxlint-disable-next-line no-restricted-imports
|
||||
useContext,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import {
|
||||
SpanV3,
|
||||
WaterfallAggregationResponse,
|
||||
WaterfallAggregationType,
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
import {
|
||||
ColorByOption,
|
||||
COLOR_BY_FIELDS,
|
||||
COLOR_BY_OPTIONS,
|
||||
DEFAULT_COLOR_BY_FIELD,
|
||||
} from '../constants';
|
||||
import { getSpanAttribute } from '../utils';
|
||||
import {
|
||||
AGGREGATIONS,
|
||||
getAggregationMap as findAggregationMap,
|
||||
} from '../utils/aggregations';
|
||||
|
||||
interface TraceContextValue {
|
||||
colorByField: TelemetryFieldKey;
|
||||
setColorByField: (field: TelemetryFieldKey) => void;
|
||||
aggregations: WaterfallAggregationResponse[] | undefined;
|
||||
getAggregationMap: (
|
||||
type: WaterfallAggregationType,
|
||||
) => Record<string, number> | undefined;
|
||||
getSpanGroupValue: (span: SpanV3) => string;
|
||||
resolveSpanColor: (span: SpanV3) => string;
|
||||
/**
|
||||
* Subset of COLOR_BY_OPTIONS whose data is populated on the current trace.
|
||||
* `service.name` is always included; host/container only when their
|
||||
* aggregation `value` map has entries.
|
||||
*/
|
||||
availableColorByOptions: ColorByOption[];
|
||||
/**
|
||||
* Per-user preview fields (selected via the floating "Preview fields"
|
||||
* panel). Stored in `span_details_preview_attributes` user pref. Will be
|
||||
* consumed by the flamegraph `selectFields` request and the waterfall +
|
||||
* flamegraph hover popovers in follow-up phases.
|
||||
*/
|
||||
previewFields: BaseAutocompleteData[];
|
||||
setPreviewFields: (next: BaseAutocompleteData[]) => void;
|
||||
}
|
||||
|
||||
const TraceContext = createContext<TraceContextValue | null>(null);
|
||||
|
||||
export function TraceProvider({
|
||||
aggregations,
|
||||
children,
|
||||
}: {
|
||||
aggregations: WaterfallAggregationResponse[] | undefined;
|
||||
children: ReactNode;
|
||||
}): JSX.Element | null {
|
||||
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
||||
const { mutate: updateUserPreferenceMutation } = useMutation(
|
||||
updateUserPreferenceAPI,
|
||||
);
|
||||
|
||||
// Source of truth: user-preferences API (loaded once on app init via
|
||||
// AppProvider). Render nothing until it resolves so we never paint the
|
||||
// default colour first and then swap to the user's persisted choice.
|
||||
// AppProvider fires the prefs query as soon as the user is logged in, so
|
||||
// this is usually already settled by the time TraceDetailsV3 mounts.
|
||||
const persistedColorByField = useMemo<TelemetryFieldKey>(() => {
|
||||
const pref = userPreferences?.find(
|
||||
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_COLOR_BY_ATTRIBUTE,
|
||||
);
|
||||
const name = (pref?.value as string) || '';
|
||||
return COLOR_BY_FIELDS.find((f) => f.name === name) ?? DEFAULT_COLOR_BY_FIELD;
|
||||
}, [userPreferences]);
|
||||
|
||||
const setColorByField = useCallback(
|
||||
(field: TelemetryFieldKey): void => {
|
||||
// Optimistically reflect the choice in the in-memory cache so the UI
|
||||
// reacts immediately (the GET /user/preferences response on app init
|
||||
// always includes the registered key, so `existing` will be defined).
|
||||
const existing = userPreferences?.find(
|
||||
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_COLOR_BY_ATTRIBUTE,
|
||||
);
|
||||
if (existing) {
|
||||
updateUserPreferenceInContext({ ...existing, value: field.name });
|
||||
}
|
||||
updateUserPreferenceMutation({
|
||||
name: USER_PREFERENCES.SPAN_DETAILS_COLOR_BY_ATTRIBUTE,
|
||||
value: field.name,
|
||||
});
|
||||
},
|
||||
[
|
||||
userPreferences,
|
||||
updateUserPreferenceInContext,
|
||||
updateUserPreferenceMutation,
|
||||
],
|
||||
);
|
||||
|
||||
const previewFields = useMemo<BaseAutocompleteData[]>(() => {
|
||||
const pref = userPreferences?.find(
|
||||
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PREVIEW_ATTRIBUTES,
|
||||
);
|
||||
const raw = (pref?.value as BaseAutocompleteData[] | undefined) ?? [];
|
||||
// Defensive: keep only entries that have a string `key`.
|
||||
return raw.filter(
|
||||
(f): f is BaseAutocompleteData =>
|
||||
typeof f === 'object' &&
|
||||
f !== null &&
|
||||
typeof (f as { key?: unknown }).key === 'string',
|
||||
);
|
||||
}, [userPreferences]);
|
||||
|
||||
const setPreviewFields = useCallback(
|
||||
(next: BaseAutocompleteData[]): void => {
|
||||
const existing = userPreferences?.find(
|
||||
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PREVIEW_ATTRIBUTES,
|
||||
);
|
||||
if (existing) {
|
||||
updateUserPreferenceInContext({ ...existing, value: next });
|
||||
}
|
||||
updateUserPreferenceMutation({
|
||||
name: USER_PREFERENCES.SPAN_DETAILS_PREVIEW_ATTRIBUTES,
|
||||
value: next,
|
||||
});
|
||||
},
|
||||
[
|
||||
userPreferences,
|
||||
updateUserPreferenceInContext,
|
||||
updateUserPreferenceMutation,
|
||||
],
|
||||
);
|
||||
|
||||
const value = useMemo<TraceContextValue>(() => {
|
||||
const isFieldAvailable = (fieldName: string): boolean => {
|
||||
if (fieldName === DEFAULT_COLOR_BY_FIELD.name) {
|
||||
return true;
|
||||
}
|
||||
// Pick any aggregation type — if execution_time_percentage is empty,
|
||||
// span_count for the same field will be too (both are derived from
|
||||
// the same set of spans).
|
||||
const map = findAggregationMap(
|
||||
aggregations,
|
||||
AGGREGATIONS.EXEC_TIME_PCT,
|
||||
fieldName,
|
||||
);
|
||||
return !!map && Object.keys(map).length > 0;
|
||||
};
|
||||
|
||||
const availableColorByOptions = COLOR_BY_OPTIONS.filter((opt) =>
|
||||
isFieldAvailable(opt.field.name),
|
||||
);
|
||||
|
||||
const colorByField =
|
||||
aggregations === undefined || isFieldAvailable(persistedColorByField.name)
|
||||
? persistedColorByField
|
||||
: DEFAULT_COLOR_BY_FIELD;
|
||||
|
||||
const getAggregationMap = (
|
||||
type: WaterfallAggregationType,
|
||||
): Record<string, number> | undefined =>
|
||||
findAggregationMap(aggregations, type, colorByField.name);
|
||||
|
||||
const getSpanGroupValue = (span: SpanV3): string =>
|
||||
getSpanAttribute(span, colorByField.name) || 'unknown';
|
||||
|
||||
const resolveSpanColor = (span: SpanV3): string => {
|
||||
if (span.has_error) {
|
||||
return 'var(--bg-cherry-500)';
|
||||
}
|
||||
return generateColor(
|
||||
getSpanGroupValue(span),
|
||||
themeColors.traceDetailColorsV3,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
colorByField,
|
||||
setColorByField,
|
||||
aggregations,
|
||||
getAggregationMap,
|
||||
getSpanGroupValue,
|
||||
resolveSpanColor,
|
||||
availableColorByOptions,
|
||||
previewFields,
|
||||
setPreviewFields,
|
||||
};
|
||||
}, [
|
||||
persistedColorByField,
|
||||
aggregations,
|
||||
setColorByField,
|
||||
previewFields,
|
||||
setPreviewFields,
|
||||
]);
|
||||
|
||||
if (!userPreferences) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <TraceContext.Provider value={value}>{children}</TraceContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTraceContext(): TraceContextValue {
|
||||
const ctx = useContext(TraceContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useTraceContext must be used inside TraceProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import { isV3PinnedAttribute } from 'pages/TraceDetailsV3/utils';
|
||||
import { serializeKeyPath } from 'periscope/components/PrettyView/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
|
||||
/**
|
||||
* V2 stored pinned attributes as flat strings (`["http.method"]`).
|
||||
* V3 stores nested key paths (`['["attributes","http.method"]']`).
|
||||
*
|
||||
* On first load with both `userPreferences` and `selectedSpan` available,
|
||||
* detect a V2-format value in the backend pref and convert it to V3 paths
|
||||
* using the loaded span's shape. Idempotent: once written in V3 format the
|
||||
* format check on subsequent loads short-circuits.
|
||||
*
|
||||
* Unmappable keys (not present on the loaded span) are dropped — we can't
|
||||
* determine whether they belong under `attributes`, `resource`, or top-level.
|
||||
*/
|
||||
export function useMigratePinnedAttributes(
|
||||
selectedSpan: SpanV3 | undefined,
|
||||
): void {
|
||||
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
||||
const { mutate } = useMutation(updateUserPreferenceAPI);
|
||||
const ranRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (ranRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!userPreferences || !selectedSpan) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pref = userPreferences.find(
|
||||
(p) => p.name === USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
);
|
||||
const value = (pref?.value as string[] | undefined) ?? [];
|
||||
if (value.length === 0) {
|
||||
ranRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Walk every entry — the array may be mixed (e.g. some legacy V2 flat
|
||||
// keys saved alongside V3 paths). V3 entries pass through unchanged;
|
||||
// V2 entries get converted via the loaded span; unmappable V2 entries
|
||||
// are dropped. We only persist when at least one V2 entry was found
|
||||
// (otherwise the input is already V3-clean).
|
||||
const next: string[] = [];
|
||||
let hadV2Entry = false;
|
||||
|
||||
for (const entry of value) {
|
||||
if (isV3PinnedAttribute(entry)) {
|
||||
next.push(entry);
|
||||
continue;
|
||||
}
|
||||
hadV2Entry = true;
|
||||
const path = v2KeyToPath(entry, selectedSpan);
|
||||
if (path) {
|
||||
next.push(serializeKeyPath(path));
|
||||
}
|
||||
// else: unmappable on the loaded span — drop
|
||||
}
|
||||
|
||||
if (!hadV2Entry) {
|
||||
ranRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (pref) {
|
||||
updateUserPreferenceInContext({ ...pref, value: next });
|
||||
}
|
||||
mutate(
|
||||
{
|
||||
name: USER_PREFERENCES.SPAN_DETAILS_PINNED_ATTRIBUTES,
|
||||
value: next,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
ranRef.current = true;
|
||||
},
|
||||
onError: () => {
|
||||
// Roll back the optimistic context update
|
||||
if (pref) {
|
||||
updateUserPreferenceInContext({ ...pref, value });
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [userPreferences, selectedSpan, updateUserPreferenceInContext, mutate]);
|
||||
}
|
||||
|
||||
function v2KeyToPath(key: string, span: SpanV3): (string | number)[] | null {
|
||||
if (span.attributes && key in span.attributes) {
|
||||
return ['attributes', key];
|
||||
}
|
||||
if (span.resource && key in span.resource) {
|
||||
return ['resource', key];
|
||||
}
|
||||
if (key in (span as unknown as Record<string, unknown>)) {
|
||||
return [key];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -12,23 +12,16 @@ import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import NoData from 'pages/TraceDetailV2/NoData/NoData';
|
||||
import { ResizableBox } from 'periscope/components/ResizableBox';
|
||||
import {
|
||||
SpanV3,
|
||||
TraceDetailV3URLProps,
|
||||
WaterfallAggregationRequest,
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
|
||||
|
||||
import { COLOR_BY_FIELDS } from './constants';
|
||||
import { TraceProvider } from './contexts/TraceContext';
|
||||
import { AGGREGATIONS } from './utils/aggregations';
|
||||
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
|
||||
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
|
||||
import type { TraceMetadataForHeader } from './TraceDetailsHeader/TraceDetailsHeader';
|
||||
import TraceDetailsHeader from './TraceDetailsHeader/TraceDetailsHeader';
|
||||
import { FLAMEGRAPH_SPAN_LIMIT } from './TraceFlamegraph/constants';
|
||||
import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
|
||||
import TraceWaterfall from './TraceWaterfall/TraceWaterfall';
|
||||
import { IInterestedSpan } from './TraceWaterfall/types';
|
||||
import TraceWaterfall, {
|
||||
IInterestedSpan,
|
||||
} from './TraceWaterfall/TraceWaterfall';
|
||||
import { getAncestorSpanIds } from './TraceWaterfall/utils';
|
||||
|
||||
import './TraceDetailsV3.styles.scss';
|
||||
@@ -86,17 +79,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
});
|
||||
}, [urlQuery]);
|
||||
|
||||
// Hardcoded for now — fetch aggregations for all 3 candidate color-by fields
|
||||
// upfront so a future color-by-field switch doesn't need to refetch.
|
||||
const waterfallAggregationsRequest = useMemo<WaterfallAggregationRequest[]>(
|
||||
() =>
|
||||
COLOR_BY_FIELDS.flatMap((field) => [
|
||||
{ field, aggregation: AGGREGATIONS.EXEC_TIME_PCT },
|
||||
{ field, aggregation: AGGREGATIONS.SPAN_COUNT },
|
||||
]),
|
||||
[],
|
||||
);
|
||||
|
||||
// Once all spans are loaded (frontend mode), freeze query params so
|
||||
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
|
||||
const fullDataLoadedRef = useRef(false);
|
||||
@@ -104,7 +86,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
aggregations: waterfallAggregationsRequest,
|
||||
});
|
||||
|
||||
const queryParams = fullDataLoadedRef.current
|
||||
@@ -113,7 +94,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
aggregations: waterfallAggregationsRequest,
|
||||
};
|
||||
|
||||
const {
|
||||
@@ -125,7 +105,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
uncollapsedSpans: queryParams.uncollapsedSpans,
|
||||
selectedSpanId: queryParams.selectedSpanId,
|
||||
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
|
||||
aggregations: queryParams.aggregations,
|
||||
});
|
||||
|
||||
const allSpans = traceData?.payload?.spans || [];
|
||||
@@ -140,7 +119,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
selectedSpanId: interestedSpanId.spanId,
|
||||
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
|
||||
uncollapsedSpans: uncollapsedNodes,
|
||||
aggregations: waterfallAggregationsRequest,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -235,23 +213,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
],
|
||||
);
|
||||
|
||||
const traceMetadataForHeader = useMemo(():
|
||||
| TraceMetadataForHeader
|
||||
| undefined => {
|
||||
const payload = traceData?.payload;
|
||||
if (!payload) {
|
||||
return undefined;
|
||||
}
|
||||
const rootSpan = payload.spans?.find((s) => s.level === 0);
|
||||
return {
|
||||
startTimestampMillis: payload.startTimestampMillis,
|
||||
endTimestampMillis: payload.endTimestampMillis,
|
||||
rootServiceName: payload.rootServiceName,
|
||||
rootServiceEntryPoint: payload.rootServiceEntryPoint,
|
||||
rootSpanStatusCode: rootSpan?.response_status_code || '',
|
||||
};
|
||||
}, [traceData?.payload]);
|
||||
|
||||
const showNoData =
|
||||
!isFetchingTraceData &&
|
||||
(!!errorFetchingTraceData || !traceData?.payload?.spans?.length);
|
||||
@@ -285,116 +246,104 @@ function TraceDetailsV3(): JSX.Element {
|
||||
);
|
||||
|
||||
return (
|
||||
<TraceProvider aggregations={traceData?.payload?.aggregations}>
|
||||
<div className="trace-details-v3">
|
||||
<TraceDetailsHeader
|
||||
filterMetadata={filterMetadata}
|
||||
onFilteredSpansChange={handleFilteredSpansChange}
|
||||
isDataLoaded={!!traceData?.payload?.spans?.length && !showNoData}
|
||||
traceMetadata={traceMetadataForHeader}
|
||||
/>
|
||||
<div className="trace-details-v3">
|
||||
<TraceDetailsHeader
|
||||
filterMetadata={filterMetadata}
|
||||
onFilteredSpansChange={handleFilteredSpansChange}
|
||||
noData={showNoData}
|
||||
/>
|
||||
|
||||
{showNoData ? (
|
||||
<NoData />
|
||||
) : (
|
||||
<>
|
||||
<div className="trace-details-v3__content">
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
className="trace-details-v3__flame-collapse"
|
||||
items={[
|
||||
{
|
||||
key: 'flame',
|
||||
label: (
|
||||
<div className="trace-details-v3__collapse-label">
|
||||
<span>Flame Graph</span>
|
||||
{traceData?.payload?.totalSpansCount ? (
|
||||
<span className="trace-details-v3__collapse-count">
|
||||
<span>Spans: {traceData.payload.totalSpansCount}</span>
|
||||
<span
|
||||
className={
|
||||
traceData.payload.totalErrorSpansCount > 0
|
||||
? 'trace-details-v3__collapse-count-errors'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Errors: {traceData.payload.totalErrorSpansCount ?? 0}
|
||||
</span>
|
||||
{traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
||||
<WarningPopover
|
||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans in flamegraph."
|
||||
placement="bottomRight"
|
||||
autoAdjustOverflow={false}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
||||
<TraceFlamegraph
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
</ResizableBox>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{showNoData ? (
|
||||
<NoData />
|
||||
) : (
|
||||
<>
|
||||
<div className="trace-details-v3__content">
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'flame')}
|
||||
onChange={(): void => handleCollapseChange('flame')}
|
||||
size="small"
|
||||
className="trace-details-v3__flame-collapse"
|
||||
items={[
|
||||
{
|
||||
key: 'flame',
|
||||
label: (
|
||||
<div className="trace-details-v3__collapse-label">
|
||||
<span>Flame Graph</span>
|
||||
{traceData?.payload?.totalSpansCount ? (
|
||||
<span className="trace-details-v3__collapse-count">
|
||||
{traceData.payload.totalSpansCount} spans
|
||||
{traceData.payload.totalSpansCount > FLAMEGRAPH_SPAN_LIMIT && (
|
||||
<WarningPopover
|
||||
message="The total span count exceeds the visualization limit. Displaying a sampled subset of spans."
|
||||
placement="bottomRight"
|
||||
autoAdjustOverflow={false}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<ResizableBox defaultHeight={300} minHeight={100} maxHeight={400}>
|
||||
<TraceFlamegraph
|
||||
filteredSpanIds={filteredSpanIds}
|
||||
isFilterActive={isFilterActive}
|
||||
/>
|
||||
</ResizableBox>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
className={`trace-details-v3__waterfall-collapse${
|
||||
isWaterfallDocked
|
||||
? ' trace-details-v3__waterfall-collapse--docked'
|
||||
: ''
|
||||
}`}
|
||||
items={[
|
||||
{
|
||||
key: 'waterfall',
|
||||
label: 'Waterfall',
|
||||
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Collapse
|
||||
// @ts-expect-error motion is passed through to rc-collapse to disable animation
|
||||
motion={false}
|
||||
activeKey={activeKeys.filter((k) => k === 'waterfall')}
|
||||
onChange={(): void => handleCollapseChange('waterfall')}
|
||||
size="small"
|
||||
className={`trace-details-v3__waterfall-collapse${
|
||||
isWaterfallDocked ? ' trace-details-v3__waterfall-collapse--docked' : ''
|
||||
}`}
|
||||
items={[
|
||||
{
|
||||
key: 'waterfall',
|
||||
label: 'Waterfall',
|
||||
children: activeKeys.includes('waterfall') ? waterfallChildren : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{panelState.isOpen && isDocked && (
|
||||
<div className="trace-details-v3__docked-span-details">
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{panelState.isOpen && !isDocked && (
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DIALOG}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
/>
|
||||
{panelState.isOpen && isDocked && (
|
||||
<div className="trace-details-v3__docked-span-details">
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DOCKED}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
serviceExecTime={traceData?.payload?.serviceNameToTotalDurationMap}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TraceProvider>
|
||||
</div>
|
||||
|
||||
{panelState.isOpen && !isDocked && (
|
||||
<SpanDetailsPanel
|
||||
panelState={panelState}
|
||||
selectedSpan={selectedSpan}
|
||||
variant={SpanDetailVariant.DIALOG}
|
||||
onVariantChange={handleVariantChange}
|
||||
traceStartTime={traceData?.payload?.startTimestampMillis}
|
||||
traceEndTime={traceData?.payload?.endTimestampMillis}
|
||||
serviceExecTime={traceData?.payload?.serviceNameToTotalDurationMap}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,10 @@ import { SpanV3 } from 'types/api/trace/getTraceV3';
|
||||
/**
|
||||
* Look up an attribute from both `resource` and `attributes` on a span.
|
||||
* Resources are checked first (service.name, k8s.* etc. live there).
|
||||
*
|
||||
* Accepts both `SpanV3` (waterfall) and `FlamegraphSpan` (flamegraph) by typing
|
||||
* structurally — the only fields touched are `resource` and `attributes`.
|
||||
*
|
||||
* TODO: Remove tagMap fallback when phasing out V2
|
||||
*/
|
||||
export function getSpanAttribute(
|
||||
span: {
|
||||
resource?: Record<string, string>;
|
||||
attributes?: Record<string, any>;
|
||||
},
|
||||
span: SpanV3,
|
||||
key: string,
|
||||
): string | undefined {
|
||||
return (
|
||||
@@ -38,40 +31,3 @@ export function hasInfraMetadata(span: SpanV3 | undefined): boolean {
|
||||
}
|
||||
return INFRA_METADATA_KEYS.some((key) => getSpanAttribute(span, key));
|
||||
}
|
||||
|
||||
// Top-level fields that exist on the API response only to support waterfall
|
||||
// rendering. They have no value in the Span Details DataViewer. Drop the whole
|
||||
// constant + helper once the backend stops emitting them.
|
||||
const HIDDEN_SPAN_FIELDS_IN_DETAILS_VIEW: ReadonlySet<string> = new Set([
|
||||
'sub_tree_node_count',
|
||||
'has_children',
|
||||
'level',
|
||||
'service.name',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Shallow-copies the span with waterfall-only fields stripped, for display in
|
||||
* the Span Details DataViewer.
|
||||
*/
|
||||
export function getSpanDisplayData(span: SpanV3): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(span)) {
|
||||
if (!HIDDEN_SPAN_FIELDS_IN_DETAILS_VIEW.has(key)) {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* V3 pinned-attribute entries are JSON-stringified arrays (e.g.
|
||||
* `'["attributes","http.method"]'`). Legacy V2 entries are flat strings.
|
||||
* Used to distinguish V3 from V2 entries when reading the persisted value.
|
||||
*/
|
||||
export function isV3PinnedAttribute(entry: string): boolean {
|
||||
try {
|
||||
return Array.isArray(JSON.parse(entry));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import {
|
||||
WaterfallAggregationResponse,
|
||||
WaterfallAggregationType,
|
||||
} from 'types/api/trace/getTraceV3';
|
||||
|
||||
export const AGGREGATIONS = {
|
||||
EXEC_TIME_PCT: 'execution_time_percentage',
|
||||
SPAN_COUNT: 'span_count',
|
||||
DURATION: 'duration',
|
||||
} as const satisfies Record<string, WaterfallAggregationType>;
|
||||
|
||||
export function getAggregationMap(
|
||||
aggregations: WaterfallAggregationResponse[] | undefined,
|
||||
type: WaterfallAggregationType,
|
||||
fieldName: string,
|
||||
): Record<string, number> | undefined {
|
||||
return aggregations?.find(
|
||||
(a) => a.aggregation === type && a.field.name === fieldName,
|
||||
)?.value;
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
FieldContext,
|
||||
FieldDataType,
|
||||
TelemetryFieldKey,
|
||||
} from 'types/api/v5/queryRange';
|
||||
|
||||
/**
|
||||
* Map the picker's `BaseAutocompleteData.type` (`'tag' | 'resource' | '' | null`)
|
||||
* to the API's `FieldContext`. Unknown values fall back to `'attribute'`.
|
||||
*/
|
||||
function mapFieldContext(type: BaseAutocompleteData['type']): FieldContext {
|
||||
if (type === 'resource') {
|
||||
return 'resource';
|
||||
}
|
||||
return 'attribute';
|
||||
}
|
||||
|
||||
const DATA_TYPE_MAP: Record<DataTypes, FieldDataType> = {
|
||||
[DataTypes.String]: 'string',
|
||||
[DataTypes.bool]: 'bool',
|
||||
[DataTypes.Int64]: 'int64',
|
||||
[DataTypes.Float64]: 'float64',
|
||||
[DataTypes.ArrayString]: '[]string',
|
||||
[DataTypes.ArrayBool]: '[]bool',
|
||||
[DataTypes.ArrayInt64]: '[]int64',
|
||||
[DataTypes.ArrayFloat64]: '[]float64',
|
||||
[DataTypes.EMPTY]: 'string',
|
||||
};
|
||||
|
||||
function mapFieldDataType(
|
||||
dataType: BaseAutocompleteData['dataType'],
|
||||
): FieldDataType {
|
||||
if (!dataType) {
|
||||
return 'string';
|
||||
}
|
||||
return DATA_TYPE_MAP[dataType] ?? 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a picker-shaped field to the API's `TelemetryFieldKey` shape used
|
||||
* for `selectFields` on the flamegraph request.
|
||||
*/
|
||||
export function toTelemetryFieldKey(
|
||||
field: BaseAutocompleteData,
|
||||
): TelemetryFieldKey {
|
||||
return {
|
||||
name: field.key,
|
||||
fieldContext: mapFieldContext(field.type),
|
||||
fieldDataType: mapFieldDataType(field.dataType),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge two `TelemetryFieldKey` lists, de-duping by `name`. Earlier entries win
|
||||
* (so callers can pass user-controlled fields after a baseline list and have
|
||||
* the baseline's metadata be preserved).
|
||||
*/
|
||||
export function mergeTelemetryFieldKeys(
|
||||
...lists: TelemetryFieldKey[][]
|
||||
): TelemetryFieldKey[] {
|
||||
const seen = new Set<string>();
|
||||
const out: TelemetryFieldKey[] = [];
|
||||
for (const list of lists) {
|
||||
for (const f of list) {
|
||||
if (seen.has(f.name)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(f.name);
|
||||
out.push(f);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -23,14 +23,7 @@ const editorOptions: EditorProps['options'] = {
|
||||
lineHeight: 18,
|
||||
colorDecorators: true,
|
||||
scrollBeyondLastLine: false,
|
||||
scrollbar: {
|
||||
vertical: 'hidden',
|
||||
horizontal: 'hidden',
|
||||
// Once the editor can't scroll any further, release the wheel event so
|
||||
// the parent container picks it up. Without this Monaco swallows the
|
||||
// event at the boundary and outer scroll feels stuck.
|
||||
alwaysConsumeMouseWheel: false,
|
||||
},
|
||||
scrollbar: { vertical: 'hidden', horizontal: 'hidden' },
|
||||
folding: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -56,13 +56,6 @@ export interface PrettyViewProps {
|
||||
searchable?: boolean;
|
||||
showPinned?: boolean;
|
||||
drawerKey?: string;
|
||||
/**
|
||||
* Controlled list of pinned key paths (each entry is `JSON.stringify(path)`).
|
||||
* When provided, PrettyView delegates persistence to the caller via
|
||||
* `onPinnedFieldsChange` and skips its own localStorage I/O.
|
||||
*/
|
||||
pinnedFieldsValue?: string[];
|
||||
onPinnedFieldsChange?: (next: string[]) => void;
|
||||
}
|
||||
|
||||
function PrettyView({
|
||||
@@ -72,8 +65,6 @@ function PrettyView({
|
||||
searchable = true,
|
||||
showPinned = false,
|
||||
drawerKey = 'default',
|
||||
pinnedFieldsValue,
|
||||
onPinnedFieldsChange,
|
||||
}: PrettyViewProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const [, setCopy] = useCopyToClipboard();
|
||||
@@ -84,10 +75,7 @@ function PrettyView({
|
||||
pinnedEntries,
|
||||
pinnedData,
|
||||
displayKeyToForwardPath,
|
||||
} = usePinnedFields(data, drawerKey, {
|
||||
value: pinnedFieldsValue,
|
||||
onChange: onPinnedFieldsChange,
|
||||
});
|
||||
} = usePinnedFields(data, drawerKey);
|
||||
|
||||
const filteredPinnedData = useMemo(() => {
|
||||
const trimmed = searchQuery.trim();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import getLocalStorageKey from 'api/browser/localstorage/get';
|
||||
import setLocalStorageKey from 'api/browser/localstorage/set';
|
||||
|
||||
@@ -42,58 +42,17 @@ export interface UsePinnedFieldsReturn {
|
||||
displayKeyToForwardPath: Record<string, (string | number)[]>;
|
||||
}
|
||||
|
||||
export interface UsePinnedFieldsOptions {
|
||||
/**
|
||||
* Initial / controlled list of serialized key paths.
|
||||
* When provided, overrides the default localStorage read.
|
||||
*/
|
||||
value?: string[];
|
||||
/**
|
||||
* Called whenever the pin set changes. When provided, the caller is
|
||||
* responsible for persistence (e.g. backend user preference).
|
||||
*/
|
||||
onChange?: (next: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistence behavior:
|
||||
* - Controlled (`options.value`/`options.onChange` provided) → caller drives
|
||||
* state and persistence. localStorage is not touched, regardless of
|
||||
* `drawerKey`.
|
||||
* - Uncontrolled with `drawerKey` → reads/writes `pinnedFields:${drawerKey}`
|
||||
* in localStorage.
|
||||
* - Uncontrolled without `drawerKey` → in-memory only (no persistence).
|
||||
*/
|
||||
function usePinnedFields(
|
||||
data: AnyRecord,
|
||||
drawerKey?: string,
|
||||
options?: UsePinnedFieldsOptions,
|
||||
drawerKey: string,
|
||||
): UsePinnedFieldsReturn {
|
||||
const controlledValue = options?.value;
|
||||
const onChange = options?.onChange;
|
||||
const isControlled = controlledValue !== undefined || onChange !== undefined;
|
||||
const storageKey =
|
||||
!isControlled && drawerKey ? `${STORAGE_PREFIX}:${drawerKey}` : null;
|
||||
const storageKey = `${STORAGE_PREFIX}:${drawerKey}`;
|
||||
|
||||
// Stored as serialized keyPath arrays (JSON strings)
|
||||
const [pinnedSerializedKeys, setPinnedSerializedKeys] = useState<Set<string>>(
|
||||
() => {
|
||||
if (controlledValue) {
|
||||
return new Set(controlledValue);
|
||||
}
|
||||
if (storageKey) {
|
||||
return new Set(loadFromStorage(storageKey));
|
||||
}
|
||||
return new Set();
|
||||
},
|
||||
() => new Set(loadFromStorage(storageKey)),
|
||||
);
|
||||
|
||||
// Sync state with the controlled value when it changes externally.
|
||||
useEffect(() => {
|
||||
if (controlledValue) {
|
||||
setPinnedSerializedKeys(new Set(controlledValue));
|
||||
}
|
||||
}, [controlledValue]);
|
||||
|
||||
const togglePin = useCallback(
|
||||
(forwardPath: (string | number)[]): void => {
|
||||
const serialized = serializeKeyPath(forwardPath);
|
||||
@@ -104,17 +63,11 @@ function usePinnedFields(
|
||||
} else {
|
||||
next.add(serialized);
|
||||
}
|
||||
const arr = Array.from(next);
|
||||
if (storageKey) {
|
||||
saveToStorage(storageKey, arr);
|
||||
}
|
||||
if (onChange) {
|
||||
onChange(arr);
|
||||
}
|
||||
saveToStorage(storageKey, Array.from(next));
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[storageKey, onChange],
|
||||
[storageKey],
|
||||
);
|
||||
|
||||
const isPinned = useCallback(
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
export interface TraceDetailFlamegraphURLProps {
|
||||
id: string;
|
||||
}
|
||||
@@ -8,7 +6,6 @@ export interface GetTraceFlamegraphPayloadProps {
|
||||
traceId: string;
|
||||
selectedSpanId?: string;
|
||||
limit?: number;
|
||||
selectFields?: TelemetryFieldKey[];
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
@@ -29,8 +26,6 @@ export interface FlamegraphSpan {
|
||||
name: string;
|
||||
level: number;
|
||||
event: Event[];
|
||||
resource?: Record<string, string>;
|
||||
attributes?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface GetTraceFlamegraphSuccessResponse {
|
||||
|
||||
@@ -1,26 +1,9 @@
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
|
||||
export type WaterfallAggregationType =
|
||||
| 'span_count'
|
||||
| 'execution_time_percentage'
|
||||
| 'duration';
|
||||
|
||||
export interface WaterfallAggregationRequest {
|
||||
field: TelemetryFieldKey;
|
||||
aggregation: WaterfallAggregationType;
|
||||
}
|
||||
|
||||
export interface WaterfallAggregationResponse extends WaterfallAggregationRequest {
|
||||
value: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface GetTraceV3PayloadProps {
|
||||
traceId: string;
|
||||
selectedSpanId: string;
|
||||
uncollapsedSpans: string[];
|
||||
isSelectedSpanIDUnCollapsed: boolean;
|
||||
limit?: number; // Optional limit for number of spans to fetch, default can be set in API
|
||||
aggregations?: WaterfallAggregationRequest[];
|
||||
}
|
||||
|
||||
export interface TraceDetailV3URLProps {
|
||||
@@ -98,5 +81,5 @@ export interface GetTraceV3SuccessResponse {
|
||||
totalErrorSpansCount: number;
|
||||
rootServiceName: string;
|
||||
rootServiceEntryPoint: string;
|
||||
aggregations?: WaterfallAggregationResponse[];
|
||||
serviceNameToTotalDurationMap: Record<string, number>;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ export type ServerError = 500;
|
||||
export type SuccessStatusCode = Created | Success | SuccessNoContent;
|
||||
|
||||
export type ErrorStatusCode =
|
||||
| Forbidden
|
||||
| Forbidden
|
||||
| Unauthorized
|
||||
| NotFound
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('extractQueryPairs', () => {
|
||||
valueEnd: undefined,
|
||||
valueStart: undefined,
|
||||
},
|
||||
isComplete: true,
|
||||
isComplete: false,
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
@@ -204,7 +204,7 @@ describe('extractQueryPairs', () => {
|
||||
key: 'active',
|
||||
operator: 'EXISTS',
|
||||
value: undefined,
|
||||
isComplete: true,
|
||||
isComplete: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -388,7 +388,7 @@ describe('extractQueryPairs', () => {
|
||||
expect(result[0].operator).toBe('exists');
|
||||
expect(result[0].value).toBeUndefined();
|
||||
expect(result[0].valuesPosition).toStrictEqual([]);
|
||||
expect(result[0].isComplete).toBe(true);
|
||||
expect(result[0].isComplete).toBe(false);
|
||||
expect(result[1].key).toBe('service.name');
|
||||
expect(result[1].operator).toBe('contains');
|
||||
expect(result[1].value).toBe('"test"');
|
||||
|
||||
@@ -1208,7 +1208,7 @@ export function extractQueryPairs(query: string): IQueryPair[] {
|
||||
isComplete: !!(
|
||||
currentPair.key &&
|
||||
currentPair.operator &&
|
||||
(currentPair.value || isNonValueOperator(currentPair.operator))
|
||||
currentPair.value
|
||||
),
|
||||
} as IQueryPair);
|
||||
}
|
||||
@@ -1369,7 +1369,7 @@ export function extractQueryPairs(query: string): IQueryPair[] {
|
||||
isComplete: !!(
|
||||
currentPair.key &&
|
||||
currentPair.operator &&
|
||||
(currentPair.value || isNonValueOperator(currentPair.operator))
|
||||
currentPair.value
|
||||
),
|
||||
} as IQueryPair);
|
||||
|
||||
@@ -1414,7 +1414,7 @@ export function extractQueryPairs(query: string): IQueryPair[] {
|
||||
isComplete: !!(
|
||||
currentPair.key &&
|
||||
currentPair.operator &&
|
||||
(currentPair.value || isNonValueOperator(currentPair.operator))
|
||||
currentPair.value
|
||||
),
|
||||
} as IQueryPair);
|
||||
}
|
||||
|
||||
22
frontend/src/utils/types.ts
Normal file
22
frontend/src/utils/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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,6 +24,7 @@ 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"
|
||||
@@ -364,7 +365,7 @@ route:
|
||||
providerSettings := createTestProviderSettings()
|
||||
logger := providerSettings.Logger
|
||||
route := dispatch.NewRoute(conf.Route, nil)
|
||||
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
|
||||
marker := promTypes.NewMarker(prometheus.NewRegistry())
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -637,7 +638,7 @@ route:
|
||||
providerSettings := createTestProviderSettings()
|
||||
logger := providerSettings.Logger
|
||||
route := dispatch.NewRoute(conf.Route, nil)
|
||||
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
|
||||
marker := promTypes.NewMarker(prometheus.NewRegistry())
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -896,7 +897,7 @@ route:
|
||||
providerSettings := createTestProviderSettings()
|
||||
logger := providerSettings.Logger
|
||||
route := dispatch.NewRoute(conf.Route, nil)
|
||||
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
|
||||
marker := promTypes.NewMarker(prometheus.NewRegistry())
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1158,7 +1159,7 @@ func newAlert(labels model.LabelSet) *alertmanagertypes.Alert {
|
||||
|
||||
func TestDispatcherRace(t *testing.T) {
|
||||
logger := promslog.NewNopLogger()
|
||||
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
|
||||
marker := promTypes.NewMarker(prometheus.NewRegistry())
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1194,7 +1195,7 @@ route:
|
||||
route := dispatch.NewRoute(conf.Route, nil)
|
||||
providerSettings := createTestProviderSettings()
|
||||
logger := providerSettings.Logger
|
||||
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
|
||||
marker := promTypes.NewMarker(prometheus.NewRegistry())
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1264,7 +1265,7 @@ route:
|
||||
|
||||
func TestDispatcher_DoMaintenance(t *testing.T) {
|
||||
r := prometheus.NewRegistry()
|
||||
marker := alertmanagertypes.NewMarker(r)
|
||||
marker := promTypes.NewMarker(r)
|
||||
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Minute, 0, nil, promslog.NewNopLogger(), prometheus.NewRegistry(), nil)
|
||||
if err != nil {
|
||||
@@ -1370,7 +1371,7 @@ route:
|
||||
providerSettings := createTestProviderSettings()
|
||||
logger := providerSettings.Logger
|
||||
route := dispatch.NewRoute(conf.Route, nil)
|
||||
marker := alertmanagertypes.NewMarker(prometheus.NewRegistry())
|
||||
marker := promTypes.NewMarker(prometheus.NewRegistry())
|
||||
alerts, err := mem.NewAlerts(context.Background(), marker, time.Hour, 0, nil, logger, prometheus.NewRegistry(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
96
pkg/alertmanager/alertmanagerserver/maintenance_muter.go
Normal file
96
pkg/alertmanager/alertmanagerserver/maintenance_muter.go
Normal file
@@ -0,0 +1,96 @@
|
||||
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) {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
246
pkg/alertmanager/alertmanagerserver/maintenance_muter_test.go
Normal file
246
pkg/alertmanager/alertmanagerserver/maintenance_muter_test.go
Normal file
@@ -0,0 +1,246 @@
|
||||
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())
|
||||
}
|
||||
109
pkg/alertmanager/alertmanagerserver/pipeline_builder.go
Normal file
109
pkg/alertmanager/alertmanagerserver/pipeline_builder.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// 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,12 +28,10 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
// 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"
|
||||
|
||||
type Server struct {
|
||||
// logger is the logger for the alertmanager
|
||||
@@ -63,15 +61,25 @@ type Server struct {
|
||||
silencer *silence.Silencer
|
||||
silences *silence.Silences
|
||||
timeIntervals map[string][]timeinterval.TimeInterval
|
||||
pipelineBuilder *notify.PipelineBuilder
|
||||
marker *alertmanagertypes.MemMarker
|
||||
pipelineBuilder *pipelineBuilder
|
||||
muter *MaintenanceMuter
|
||||
marker *types.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) (*Server, error) {
|
||||
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) {
|
||||
server := &Server{
|
||||
logger: logger.With(slog.String("pkg", "go.signoz.io/pkg/alertmanager/alertmanagerserver")),
|
||||
registry: registry,
|
||||
@@ -84,7 +92,7 @@ func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registere
|
||||
signozRegisterer := prometheus.WrapRegistererWithPrefix("signoz_", registry)
|
||||
signozRegisterer = prometheus.WrapRegistererWith(prometheus.Labels{"org_id": server.orgID}, signozRegisterer)
|
||||
// initialize marker
|
||||
server.marker = alertmanagertypes.NewMarker(signozRegisterer)
|
||||
server.marker = types.NewMarker(signozRegisterer)
|
||||
|
||||
// get silences for initial state
|
||||
state, err := server.stateStore.Get(ctx, server.orgID)
|
||||
@@ -160,7 +168,6 @@ func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registere
|
||||
|
||||
return c, server.stateStore.Set(ctx, storableSilences)
|
||||
})
|
||||
|
||||
}()
|
||||
|
||||
// Start maintenance for notification logs
|
||||
@@ -196,17 +203,25 @@ func New(ctx context.Context, logger *slog.Logger, registry prometheus.Registere
|
||||
return nil, err
|
||||
}
|
||||
|
||||
server.pipelineBuilder = notify.NewPipelineBuilder(signozRegisterer, featurecontrol.NoopFlags{})
|
||||
server.muter = NewMaintenanceMuter(maintenanceStore, orgID, server.logger)
|
||||
server.pipelineBuilder = 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)
|
||||
}, 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)
|
||||
},
|
||||
func(labels model.LabelSet) []string {
|
||||
return server.muter.MutedBy(ctx, labels)
|
||||
},
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
func (server *Server) PutAlerts(ctx context.Context, postableAlerts alertmanagertypes.PostableAlerts) error {
|
||||
@@ -290,6 +305,7 @@ func (server *Server) SetConfig(ctx context.Context, alertmanagerConfig *alertma
|
||||
server.silencer,
|
||||
intervener,
|
||||
server.marker,
|
||||
server.muter,
|
||||
server.nflog,
|
||||
pipelinePeer,
|
||||
)
|
||||
|
||||
@@ -7,6 +7,10 @@ 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"
|
||||
|
||||
@@ -90,7 +94,8 @@ func TestEndToEndAlertManagerFlow(t *testing.T) {
|
||||
stateStore := alertmanagertypestest.NewStateStore()
|
||||
registry := prometheus.NewRegistry()
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
server, err := New(context.Background(), logger, registry, srvCfg, orgID, stateStore, notificationManager)
|
||||
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual))
|
||||
server, err := New(context.Background(), logger, registry, srvCfg, orgID, stateStore, notificationManager, maintenanceStore)
|
||||
require.NoError(t, err)
|
||||
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, orgID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -10,7 +10,11 @@ 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"
|
||||
@@ -23,9 +27,14 @@ 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)
|
||||
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager, newTestMaintenanceStore())
|
||||
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")
|
||||
@@ -37,7 +46,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)
|
||||
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), NewConfig(), "1", alertmanagertypestest.NewStateStore(), notificationManager, newTestMaintenanceStore())
|
||||
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")
|
||||
@@ -85,7 +94,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)
|
||||
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager, newTestMaintenanceStore())
|
||||
require.NoError(t, err)
|
||||
|
||||
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
|
||||
@@ -133,7 +142,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)
|
||||
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager, newTestMaintenanceStore())
|
||||
require.NoError(t, err)
|
||||
|
||||
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
|
||||
@@ -238,7 +247,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)
|
||||
server, err := New(context.Background(), slog.New(slog.DiscardHandler), prometheus.NewRegistry(), srvCfg, "1", stateStore, notificationManager, newTestMaintenanceStore())
|
||||
require.NoError(t, err)
|
||||
|
||||
amConfig, err := alertmanagertypes.NewDefaultConfig(srvCfg.Global, srvCfg.Route, "1")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package sqlrulestore
|
||||
package sqlalertmanagerstore
|
||||
|
||||
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) ruletypes.MaintenanceStore {
|
||||
func NewMaintenanceStore(store sqlstore.SQLStore) alertmanagertypes.MaintenanceStore {
|
||||
return &maintenance{sqlstore: store}
|
||||
}
|
||||
|
||||
func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string) ([]*ruletypes.PlannedMaintenance, error) {
|
||||
gettableMaintenancesRules := make([]*ruletypes.PlannedMaintenanceWithRules, 0)
|
||||
func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string) ([]*alertmanagertypes.PlannedMaintenance, error) {
|
||||
gettableMaintenancesRules := make([]*alertmanagertypes.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([]*ruletypes.PlannedMaintenance, 0)
|
||||
gettablePlannedMaintenance := make([]*alertmanagertypes.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) (*ruletypes.PlannedMaintenance, error) {
|
||||
storableMaintenanceRule := new(ruletypes.PlannedMaintenanceWithRules)
|
||||
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
|
||||
storableMaintenanceRule := new(alertmanagertypes.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 *ruletypes.PostablePlannedMaintenance) (*ruletypes.PlannedMaintenance, error) {
|
||||
func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance *alertmanagertypes.PostablePlannedMaintenance) (*alertmanagertypes.PlannedMaintenance, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storablePlannedMaintenance := ruletypes.StorablePlannedMaintenance{
|
||||
storablePlannedMaintenance := alertmanagertypes.StorablePlannedMaintenance{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
@@ -81,14 +81,14 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
|
||||
OrgID: claims.OrgID,
|
||||
}
|
||||
|
||||
maintenanceRules := make([]*ruletypes.StorablePlannedMaintenanceRule, 0)
|
||||
maintenanceRules := make([]*alertmanagertypes.StorablePlannedMaintenanceRule, 0)
|
||||
for _, ruleIDStr := range maintenance.AlertIds {
|
||||
ruleID, err := valuer.NewUUID(ruleIDStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
maintenanceRules = append(maintenanceRules, &ruletypes.StorablePlannedMaintenanceRule{
|
||||
maintenanceRules = append(maintenanceRules, &alertmanagertypes.StorablePlannedMaintenanceRule{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
@@ -125,7 +125,7 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ruletypes.PlannedMaintenance{
|
||||
return &alertmanagertypes.PlannedMaintenance{
|
||||
ID: storablePlannedMaintenance.ID,
|
||||
Name: storablePlannedMaintenance.Name,
|
||||
Description: storablePlannedMaintenance.Description,
|
||||
@@ -142,7 +142,7 @@ func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UU
|
||||
_, err := r.sqlstore.
|
||||
BunDB().
|
||||
NewDelete().
|
||||
Model(new(ruletypes.StorablePlannedMaintenance)).
|
||||
Model(new(alertmanagertypes.StorablePlannedMaintenance)).
|
||||
Where("id = ?", id.StringValue()).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
@@ -152,7 +152,7 @@ func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UU
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance *ruletypes.PostablePlannedMaintenance, id valuer.UUID) error {
|
||||
func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance *alertmanagertypes.PostablePlannedMaintenance, id valuer.UUID) error {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -163,7 +163,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
|
||||
return err
|
||||
}
|
||||
|
||||
storablePlannedMaintenance := ruletypes.StorablePlannedMaintenance{
|
||||
storablePlannedMaintenance := alertmanagertypes.StorablePlannedMaintenance{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: id,
|
||||
},
|
||||
@@ -181,14 +181,14 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
|
||||
OrgID: claims.OrgID,
|
||||
}
|
||||
|
||||
storablePlannedMaintenanceRules := make([]*ruletypes.StorablePlannedMaintenanceRule, 0)
|
||||
storablePlannedMaintenanceRules := make([]*alertmanagertypes.StorablePlannedMaintenanceRule, 0)
|
||||
for _, ruleIDStr := range maintenance.AlertIds {
|
||||
ruleID, err := valuer.NewUUID(ruleIDStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storablePlannedMaintenanceRules = append(storablePlannedMaintenanceRules, &ruletypes.StorablePlannedMaintenanceRule{
|
||||
storablePlannedMaintenanceRules = append(storablePlannedMaintenanceRules, &alertmanagertypes.StorablePlannedMaintenanceRule{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
@@ -211,7 +211,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
|
||||
_, err = r.sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewDelete().
|
||||
Model(new(ruletypes.StorablePlannedMaintenanceRule)).
|
||||
Model(new(alertmanagertypes.StorablePlannedMaintenanceRule)).
|
||||
Where("planned_maintenance_id = ?", storablePlannedMaintenance.ID.StringValue()).
|
||||
Exec(ctx)
|
||||
|
||||
@@ -39,16 +39,18 @@ 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,
|
||||
@@ -59,6 +61,7 @@ func New(
|
||||
servers: make(map[string]*alertmanagerserver.Server),
|
||||
serversMtx: sync.RWMutex{},
|
||||
notificationManager: nfManager,
|
||||
maintenanceStore: maintenanceStore,
|
||||
}
|
||||
|
||||
return service
|
||||
@@ -177,7 +180,10 @@ 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)
|
||||
server, err := alertmanagerserver.New(
|
||||
ctx, service.settings.Logger(), service.settings.PrometheusRegisterer(), service.config, orgID,
|
||||
service.stateStore, service.notificationManager, service.maintenanceStore,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,11 +4,8 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
|
||||
|
||||
amConfig "github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagerstore/sqlalertmanagerstore"
|
||||
@@ -16,6 +13,7 @@ import (
|
||||
"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"
|
||||
@@ -30,35 +28,49 @@ 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) factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config] {
|
||||
func NewFactory(
|
||||
sqlstore sqlstore.SQLStore,
|
||||
orgGetter organization.Getter,
|
||||
notificationManager nfmanager.NotificationManager,
|
||||
maintenanceStore alertmanagertypes.MaintenanceStore,
|
||||
) 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(ctx, settings, config, sqlstore, orgGetter, notificationManager)
|
||||
return New(settings, config, sqlstore, orgGetter, notificationManager, maintenanceStore)
|
||||
})
|
||||
}
|
||||
|
||||
func New(ctx context.Context, providerSettings factory.ProviderSettings, config alertmanager.Config, sqlstore sqlstore.SQLStore, orgGetter organization.Getter, notificationManager nfmanager.NotificationManager) (*provider, error) {
|
||||
func New(
|
||||
providerSettings factory.ProviderSettings,
|
||||
config alertmanager.Config,
|
||||
sqlstore sqlstore.SQLStore,
|
||||
orgGetter organization.Getter,
|
||||
notificationManager nfmanager.NotificationManager,
|
||||
maintenanceStore alertmanagertypes.MaintenanceStore,
|
||||
) (*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,6 +5,7 @@ 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"
|
||||
)
|
||||
@@ -120,8 +121,8 @@ func (provider *provider) addRulerRoutes(router *mux.Router) error {
|
||||
Tags: []string{"downtimeschedules"},
|
||||
Summary: "List downtime schedules",
|
||||
Description: "This endpoint lists all planned maintenance / downtime schedules",
|
||||
RequestQuery: new(ruletypes.ListPlannedMaintenanceParams),
|
||||
Response: make([]*ruletypes.PlannedMaintenance, 0),
|
||||
RequestQuery: new(alertmanagertypes.ListPlannedMaintenanceParams),
|
||||
Response: make([]*alertmanagertypes.PlannedMaintenance, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
@@ -134,7 +135,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(ruletypes.PlannedMaintenance),
|
||||
Response: new(alertmanagertypes.PlannedMaintenance),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
@@ -148,9 +149,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(ruletypes.PostablePlannedMaintenance),
|
||||
Request: new(alertmanagertypes.PostablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(ruletypes.PlannedMaintenance),
|
||||
Response: new(alertmanagertypes.PlannedMaintenance),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
@@ -164,7 +165,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(ruletypes.PostablePlannedMaintenance),
|
||||
Request: new(alertmanagertypes.PostablePlannedMaintenance),
|
||||
RequestContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
|
||||
@@ -32,30 +32,28 @@ import (
|
||||
)
|
||||
|
||||
type PrepareTaskOptions struct {
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
type PrepareTestRuleOptions struct {
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
const taskNameSuffix = "webAppEditor"
|
||||
@@ -89,7 +87,7 @@ type ManagerOptions struct {
|
||||
Alertmanager alertmanager.Alertmanager
|
||||
OrgGetter organization.Getter
|
||||
RuleStore ruletypes.RuleStore
|
||||
MaintenanceStore ruletypes.MaintenanceStore
|
||||
MaintenanceStore alertmanagertypes.MaintenanceStore
|
||||
SQLStore sqlstore.SQLStore
|
||||
QueryParser queryparser.QueryParser
|
||||
}
|
||||
@@ -103,7 +101,7 @@ type Manager struct {
|
||||
block chan struct{}
|
||||
// datastore to store alert definitions
|
||||
ruleStore ruletypes.RuleStore
|
||||
maintenanceStore ruletypes.MaintenanceStore
|
||||
maintenanceStore alertmanagertypes.MaintenanceStore
|
||||
|
||||
logger *slog.Logger
|
||||
cache cache.Cache
|
||||
@@ -134,7 +132,6 @@ func defaultOptions(o *ManagerOptions) *ManagerOptions {
|
||||
}
|
||||
|
||||
func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
|
||||
|
||||
rules := make([]Rule, 0)
|
||||
var task Task
|
||||
|
||||
@@ -159,7 +156,6 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
|
||||
WithMetadataStore(opts.ManagerOpts.MetadataStore),
|
||||
WithRuleStateHistoryModule(opts.ManagerOpts.RuleStateHistoryModule),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return task, err
|
||||
}
|
||||
@@ -167,7 +163,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, opts.MaintenanceStore, opts.OrgID)
|
||||
task = newTask(TaskTypeCh, opts.TaskName, taskNameSuffix, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
|
||||
|
||||
} else if opts.Rule.RuleType == ruletypes.RuleTypeProm {
|
||||
|
||||
@@ -183,7 +179,6 @@ func defaultPrepareTaskFunc(opts PrepareTaskOptions) (Task, error) {
|
||||
WithMetadataStore(opts.ManagerOpts.MetadataStore),
|
||||
WithRuleStateHistoryModule(opts.ManagerOpts.RuleStateHistoryModule),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return task, err
|
||||
}
|
||||
@@ -191,7 +186,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, opts.MaintenanceStore, opts.OrgID)
|
||||
task = newTask(TaskTypeProm, opts.TaskName, taskNameSuffix, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc)
|
||||
|
||||
} else {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
|
||||
@@ -237,10 +232,11 @@ func (m *Manager) RuleStore() ruletypes.RuleStore {
|
||||
return m.ruleStore
|
||||
}
|
||||
|
||||
func (m *Manager) MaintenanceStore() ruletypes.MaintenanceStore {
|
||||
func (m *Manager) MaintenanceStore() alertmanagertypes.MaintenanceStore {
|
||||
return m.maintenanceStore
|
||||
}
|
||||
|
||||
// TODO(jatinderjit): remove (unused)?
|
||||
func (m *Manager) Pause(b bool) {
|
||||
m.mtx.Lock()
|
||||
defer m.mtx.Unlock()
|
||||
@@ -430,19 +426,17 @@ 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,
|
||||
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,
|
||||
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,
|
||||
})
|
||||
|
||||
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")
|
||||
@@ -643,19 +637,17 @@ 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,
|
||||
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,
|
||||
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,
|
||||
})
|
||||
|
||||
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")
|
||||
@@ -703,7 +695,6 @@ 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)
|
||||
@@ -897,7 +888,6 @@ 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 {
|
||||
@@ -1029,16 +1019,15 @@ func (m *Manager) TestNotification(ctx context.Context, orgID valuer.UUID, ruleS
|
||||
}
|
||||
|
||||
alertCount, err := m.prepareTestRuleFunc(PrepareTestRuleOptions{
|
||||
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,
|
||||
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,
|
||||
})
|
||||
|
||||
return alertCount, err
|
||||
|
||||
@@ -15,7 +15,6 @@ 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
|
||||
@@ -39,14 +38,11 @@ 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, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) *PromRuleTask {
|
||||
func NewPromRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc) *PromRuleTask {
|
||||
opts.Logger.Info("initiating a new rule group", "name", name, "frequency", frequency)
|
||||
|
||||
if frequency == 0 {
|
||||
@@ -63,10 +59,8 @@ 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,
|
||||
maintenanceStore: maintenanceStore,
|
||||
logger: opts.Logger,
|
||||
orgID: orgID,
|
||||
notify: notify,
|
||||
logger: opts.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,30 +324,12 @@ 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,20 +2,18 @@ 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)
|
||||
@@ -37,34 +35,28 @@ 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, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) *RuleTask {
|
||||
|
||||
func NewRuleTask(name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc) *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,
|
||||
maintenanceStore: maintenanceStore,
|
||||
orgID: orgID,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +64,7 @@ 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
|
||||
}
|
||||
@@ -83,7 +76,7 @@ func (g *RuleTask) Type() TaskType { return TaskTypeCh }
|
||||
func (g *RuleTask) Rules() []Rule { return g.rules }
|
||||
|
||||
// Interval returns the group's interval.
|
||||
// TODO: remove (unused)?
|
||||
// TODO(jatinderjit): remove (unused)?
|
||||
func (g *RuleTask) Interval() time.Duration { return g.frequency }
|
||||
|
||||
func (g *RuleTask) Pause(b bool) {
|
||||
@@ -261,7 +254,6 @@ 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")
|
||||
@@ -306,7 +298,6 @@ 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(
|
||||
@@ -318,31 +309,11 @@ 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
|
||||
@@ -382,7 +353,6 @@ func (g *RuleTask) Eval(ctx context.Context, ts time.Time) {
|
||||
}
|
||||
|
||||
rule.SendAlerts(ctx, ts, g.opts.ResendDelay, g.frequency, g.notify)
|
||||
|
||||
}(i, rule)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,6 @@ package rules
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type TaskType string
|
||||
@@ -32,9 +29,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, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) Task {
|
||||
func newTask(taskType TaskType, name, file string, frequency time.Duration, rules []Rule, opts *ManagerOptions, notify NotifyFunc) Task {
|
||||
if taskType == TaskTypeCh {
|
||||
return NewRuleTask(name, file, frequency, rules, opts, notify, maintenanceStore, orgID)
|
||||
return NewRuleTask(name, file, frequency, rules, opts, notify)
|
||||
}
|
||||
return NewPromRuleTask(name, file, frequency, rules, opts, notify, maintenanceStore, orgID)
|
||||
return NewPromRuleTask(name, file, frequency, rules, opts, notify)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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"
|
||||
)
|
||||
@@ -44,5 +45,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() ruletypes.MaintenanceStore
|
||||
MaintenanceStore() alertmanagertypes.MaintenanceStore
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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"
|
||||
@@ -194,7 +195,7 @@ func (handler *handler) ListDowntimeSchedules(rw http.ResponseWriter, req *http.
|
||||
return
|
||||
}
|
||||
|
||||
var params ruletypes.ListPlannedMaintenanceParams
|
||||
var params alertmanagertypes.ListPlannedMaintenanceParams
|
||||
if err := binding.Query.BindQuery(req.URL.Query(), ¶ms); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -207,7 +208,7 @@ func (handler *handler) ListDowntimeSchedules(rw http.ResponseWriter, req *http.
|
||||
}
|
||||
|
||||
if params.Active != nil {
|
||||
activeSchedules := make([]*ruletypes.PlannedMaintenance, 0)
|
||||
activeSchedules := make([]*alertmanagertypes.PlannedMaintenance, 0)
|
||||
for _, schedule := range schedules {
|
||||
now := time.Now().In(time.FixedZone(schedule.Schedule.Timezone, 0))
|
||||
if schedule.IsActive(now) == *params.Active {
|
||||
@@ -218,7 +219,7 @@ func (handler *handler) ListDowntimeSchedules(rw http.ResponseWriter, req *http.
|
||||
}
|
||||
|
||||
if params.Recurring != nil {
|
||||
recurringSchedules := make([]*ruletypes.PlannedMaintenance, 0)
|
||||
recurringSchedules := make([]*alertmanagertypes.PlannedMaintenance, 0)
|
||||
for _, schedule := range schedules {
|
||||
if schedule.IsRecurring() == *params.Recurring {
|
||||
recurringSchedules = append(recurringSchedules, schedule)
|
||||
@@ -253,7 +254,7 @@ func (handler *handler) CreateDowntimeSchedule(rw http.ResponseWriter, req *http
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
schedule := new(ruletypes.PostablePlannedMaintenance)
|
||||
schedule := new(alertmanagertypes.PostablePlannedMaintenance)
|
||||
if err := binding.JSON.BindBody(req.Body, schedule); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
@@ -283,7 +284,7 @@ func (handler *handler) UpdateDowntimeScheduleByID(rw http.ResponseWriter, req *
|
||||
return
|
||||
}
|
||||
|
||||
schedule := new(ruletypes.PostablePlannedMaintenance)
|
||||
schedule := new(alertmanagertypes.PostablePlannedMaintenance)
|
||||
if err := binding.JSON.BindBody(req.Body, schedule); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
|
||||
@@ -4,6 +4,7 @@ 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"
|
||||
@@ -16,6 +17,7 @@ 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"
|
||||
@@ -44,7 +46,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 := sqlrulestore.NewMaintenanceStore(sqlstore)
|
||||
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
|
||||
|
||||
managerOpts := &rules.ManagerOptions{
|
||||
TelemetryStore: telemetryStore,
|
||||
@@ -129,6 +131,6 @@ func (provider *provider) TestNotification(ctx context.Context, orgID valuer.UUI
|
||||
return provider.manager.TestNotification(ctx, orgID, ruleStr)
|
||||
}
|
||||
|
||||
func (provider *provider) MaintenanceStore() ruletypes.MaintenanceStore {
|
||||
func (provider *provider) MaintenanceStore() alertmanagertypes.MaintenanceStore {
|
||||
return provider.manager.MaintenanceStore()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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"
|
||||
@@ -39,7 +40,8 @@ func TestNewHandlers(t *testing.T) {
|
||||
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
|
||||
notificationManager := nfmanagertest.NewMock()
|
||||
require.NoError(t, err)
|
||||
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager)
|
||||
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
|
||||
alertmanager, err := signozalertmanager.New(providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager, maintenanceStore)
|
||||
require.NoError(t, err)
|
||||
tokenizer := tokenizertest.NewMockTokenizer(t)
|
||||
emailing := emailingtest.New()
|
||||
|
||||
@@ -7,6 +7,7 @@ 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,7 +41,8 @@ func TestNewModules(t *testing.T) {
|
||||
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstore), sharder)
|
||||
notificationManager := nfmanagertest.NewMock()
|
||||
require.NoError(t, err)
|
||||
alertmanager, err := signozalertmanager.New(context.TODO(), providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager)
|
||||
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(sqlstore)
|
||||
alertmanager, err := signozalertmanager.New(providerSettings, alertmanager.Config{}, sqlstore, orgGetter, notificationManager, maintenanceStore)
|
||||
require.NoError(t, err)
|
||||
tokenizer := tokenizertest.NewMockTokenizer(t)
|
||||
emailing := emailingtest.New()
|
||||
|
||||
@@ -226,9 +226,14 @@ func NewNotificationManagerProviderFactories(routeStore alertmanagertypes.RouteS
|
||||
)
|
||||
}
|
||||
|
||||
func NewAlertmanagerProviderFactories(sqlstore sqlstore.SQLStore, orgGetter organization.Getter, nfManager nfmanager.NotificationManager) factory.NamedMap[factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config]] {
|
||||
func NewAlertmanagerProviderFactories(
|
||||
sqlstore sqlstore.SQLStore,
|
||||
orgGetter organization.Getter,
|
||||
nfManager nfmanager.NotificationManager,
|
||||
maintenanceStore alertmanagertypes.MaintenanceStore,
|
||||
) factory.NamedMap[factory.ProviderFactory[alertmanager.Alertmanager, alertmanager.Config]] {
|
||||
return factory.MustNewNamedMap(
|
||||
signozalertmanager.NewFactory(sqlstore, orgGetter, nfManager),
|
||||
signozalertmanager.NewFactory(sqlstore, orgGetter, nfManager, maintenanceStore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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"
|
||||
@@ -59,9 +60,11 @@ func TestNewProviderFactories(t *testing.T) {
|
||||
})
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
orgGetter := implorganization.NewGetter(implorganization.NewStore(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)), nil)
|
||||
store := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
|
||||
orgGetter := implorganization.NewGetter(implorganization.NewStore(store), nil)
|
||||
notificationManager := nfmanagertest.NewMock()
|
||||
NewAlertmanagerProviderFactories(sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual), orgGetter, notificationManager)
|
||||
maintenanceStore := sqlalertmanagerstore.NewMaintenanceStore(store)
|
||||
NewAlertmanagerProviderFactories(store, orgGetter, notificationManager, maintenanceStore)
|
||||
})
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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"
|
||||
@@ -369,12 +370,14 @@ 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),
|
||||
NewAlertmanagerProviderFactories(sqlstore, orgGetter, nfManager, maintenanceStore),
|
||||
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"
|
||||
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"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 *ruletypes.Schedule `bun:"schedule,type:text,notnull"`
|
||||
Schedule *alertmanagertypes.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 *ruletypes.Schedule `bun:"schedule,type:text,notnull"`
|
||||
Schedule *alertmanagertypes.Schedule `bun:"schedule,type:text,notnull"`
|
||||
OrgID string `bun:"org_id,type:text"`
|
||||
}
|
||||
|
||||
|
||||
@@ -70,39 +70,12 @@ func (b *traceOperatorCTEBuilder) build(ctx context.Context, requestType qbtypes
|
||||
|
||||
selectFromCTE := rootCTEName
|
||||
if b.operator.ReturnSpansFrom != "" {
|
||||
sourceQueryCTE := b.queryToCTEName[b.operator.ReturnSpansFrom]
|
||||
if sourceQueryCTE == "" {
|
||||
selectFromCTE = b.queryToCTEName[b.operator.ReturnSpansFrom]
|
||||
if selectFromCTE == "" {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"returnSpansFrom references query '%s' which has no corresponding CTE",
|
||||
b.operator.ReturnSpansFrom)
|
||||
}
|
||||
filteredCTEName := fmt.Sprintf("__return_from_%s", b.operator.ReturnSpansFrom)
|
||||
|
||||
// DISTINCT is essential here. The operator CTE (rootCTEName) holds one row
|
||||
// per matching *span*, not one row per matching *trace*. A single trace can
|
||||
// satisfy the operator through multiple spans — e.g. for "A -> B", every
|
||||
// A-span that is an indirect ancestor of any B-span appears as a separate
|
||||
// row. If we joined sourceQueryCTE directly against rootCTEName on trace_id,
|
||||
// each source span would be duplicated once for every operator-matching span
|
||||
// on the same trace. DISTINCT collapses rootCTEName to one row per trace_id,
|
||||
// making the join a clean membership test with no fan-out.
|
||||
matchingTracedSB := sqlbuilder.NewSelectBuilder()
|
||||
matchingTracedSB.Select("DISTINCT trace_id")
|
||||
matchingTracedSB.From(rootCTEName)
|
||||
matchedTracesSQL, matchedTracesArgs := matchingTracedSB.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
|
||||
filteredSB := sqlbuilder.NewSelectBuilder()
|
||||
filteredSB.Select("src.*")
|
||||
filteredSB.From(fmt.Sprintf("%s AS src", sourceQueryCTE))
|
||||
filteredSB.JoinWithOption(
|
||||
sqlbuilder.InnerJoin,
|
||||
fmt.Sprintf("(%s) AS matched_traces", matchedTracesSQL),
|
||||
"src.trace_id = matched_traces.trace_id",
|
||||
)
|
||||
filteredSQL, filteredArgs := filteredSB.BuildWithFlavor(sqlbuilder.ClickHouse, matchedTracesArgs...)
|
||||
|
||||
b.addCTE(filteredCTEName, filteredSQL, filteredArgs, []string{sourceQueryCTE, rootCTEName})
|
||||
selectFromCTE = filteredCTEName
|
||||
}
|
||||
|
||||
finalStmt, err := b.buildFinalQuery(ctx, selectFromCTE, requestType)
|
||||
|
||||
@@ -385,82 +385,6 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "returnSpansFrom B: A -> B return B spans filtered by operator",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
operator: qbtypes.QueryBuilderTraceOperator{
|
||||
Expression: "A -> B",
|
||||
ReturnSpansFrom: "B",
|
||||
Limit: 10,
|
||||
},
|
||||
compositeQuery: &qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'gateway'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'database'"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id), __return_from_B AS (SELECT src.* FROM B AS src INNER JOIN (SELECT DISTINCT trace_id FROM A_INDIR_DESC_B) AS matched_traces ON src.trace_id = matched_traces.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM __return_from_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "gateway", "%service.name%", "%service.name\":\"gateway%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "returnSpansFrom C: (A -> B) && C return C spans filtered by operator",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
operator: qbtypes.QueryBuilderTraceOperator{
|
||||
Expression: "(A -> B) && C",
|
||||
ReturnSpansFrom: "C",
|
||||
Limit: 10,
|
||||
},
|
||||
compositeQuery: &qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'gateway'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "B",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'database'"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: qbtypes.QueryTypeBuilder,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "C",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'auth'"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id), __resource_filter_C AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ?), C AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_C) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B_AND_C AS (SELECT l.* FROM A_INDIR_DESC_B AS l INNER JOIN C AS r ON l.trace_id = r.trace_id), __return_from_C AS (SELECT src.* FROM C AS src INNER JOIN (SELECT DISTINCT trace_id FROM A_INDIR_DESC_B_AND_C) AS matched_traces ON src.trace_id = matched_traces.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM __return_from_C ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "gateway", "%service.name%", "%service.name\":\"gateway%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "auth", "%service.name%", "%service.name\":\"auth%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
}
|
||||
|
||||
fm := NewFieldMapper()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user