Compare commits

..

3 Commits

Author SHA1 Message Date
swapnil-signoz
01cc8ec1d1 Merge branch 'main' into issue_5324 2026-06-11 18:36:41 +05:30
swapnil-signoz
8fe3f769c2 refactor: adding missing metrics in data collected and panels in dash 2026-06-11 18:36:05 +05:30
swapnil-signoz
e6d8713e1c feat: adding support for Azure SQL DB 2026-06-11 15:19:52 +05:30
75 changed files with 4859 additions and 2999 deletions

View File

@@ -409,6 +409,10 @@ components:
properties:
duration:
type: string
endTime:
format: date-time
nullable: true
type: string
repeatOn:
items:
$ref: '#/components/schemas/AlertmanagertypesRepeatOn'
@@ -416,7 +420,11 @@ components:
type: array
repeatType:
$ref: '#/components/schemas/AlertmanagertypesRepeatType'
startTime:
format: date-time
type: string
required:
- startTime
- duration
- repeatType
type: object
@@ -450,7 +458,6 @@ components:
type: string
required:
- timezone
- startTime
type: object
AuthtypesAttributeMapping:
properties:
@@ -1357,6 +1364,7 @@ components:
- appservice
- containerapp
- aks
- sqldatabase
type: string
CloudintegrationtypesServiceMetadata:
properties:

View File

@@ -413,11 +413,21 @@ export interface AlertmanagertypesRecurrenceDTO {
* @type string
*/
duration: string;
/**
* @type string,null
* @format date-time
*/
endTime?: string | null;
/**
* @type array,null
*/
repeatOn?: AlertmanagertypesRepeatOnDTO[] | null;
repeatType: AlertmanagertypesRepeatTypeDTO;
/**
* @type string
* @format date-time
*/
startTime: string;
}
export interface AlertmanagertypesScheduleDTO {
@@ -431,7 +441,7 @@ export interface AlertmanagertypesScheduleDTO {
* @type string
* @format date-time
*/
startTime: string;
startTime?: string;
/**
* @type string
*/
@@ -2645,6 +2655,7 @@ export enum CloudintegrationtypesServiceIDDTO {
appservice = 'appservice',
containerapp = 'containerapp',
aks = 'aks',
sqldatabase = 'sqldatabase',
}
export type CloudintegrationtypesCloudIntegrationServiceDTOAnyOf = {
/**

View File

@@ -36,7 +36,6 @@ export const REACT_QUERY_KEY = {
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
GET_TRACE_V3_FLAMEGRAPH: 'GET_TRACE_V3_FLAMEGRAPH',
GET_POD_LIST: 'GET_POD_LIST',
GET_NODE_LIST: 'GET_NODE_LIST',
GET_DEPLOYMENT_LIST: 'GET_DEPLOYMENT_LIST',

View File

@@ -1,7 +1,8 @@
.billingContainer {
margin-bottom: var(--spacing-20);
padding-top: 36px;
width: 90%;
margin: 0 auto var(--spacing-20);
margin: 0 auto;
.pageHeader {
margin-bottom: var(--spacing-8);

View File

@@ -14,7 +14,7 @@ import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQue
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { AlertListTabs } from 'pages/AlertList/types';
import { CalendarClock, GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { AlertDef } from 'types/api/alerts/def';
@@ -172,24 +172,14 @@ function CreateRules(): JSX.Element {
</div>
),
},
{
label: (
<div className="periscope-tab top-level-tab">
<CalendarClock size={14} />
Planned Downtime
</div>
),
key: AlertListTabs.PLANNED_DOWNTIME,
children: null,
},
{
label: (
<div className="periscope-tab top-level-tab">
<ConfigureIcon width={14} height={14} />
Routing Policies
Configuration
</div>
),
key: AlertListTabs.ROUTING_POLICIES,
key: AlertListTabs.CONFIGURATION,
children: null,
},
];

View File

@@ -4,4 +4,5 @@ export const THRESHOLD_TAB_TOOLTIP =
export const ANOMALY_TAB_TOOLTIP =
'An alert is triggered whenever the metric deviates from an expected pattern.';
export const ROUTING_POLICIES_ROUTE = '/alerts?tab=RoutingPolicies';
export const ROUTING_POLICIES_ROUTE =
'/alerts?tab=Configuration&subTab=routing-policies';

View File

@@ -1,6 +1,6 @@
.license-key-callout {
margin: var(--spacing-4) var(--spacing-6);
width: auto !important;
width: auto;
.license-key-callout__description {
display: flex;

View File

@@ -1,41 +0,0 @@
import { useQueries } from 'react-query';
import { render, screen } from 'tests/test-utils';
import GeneralSettings from '../index';
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueries: jest.fn(),
}));
const baseQueryResult = {
isError: false,
isLoading: false,
isFetching: false,
isSuccess: true,
data: undefined,
error: null,
refetch: jest.fn(),
};
describe('GeneralSettings index', () => {
it('renders fallback message when logs query fails with a non-APIError', () => {
(useQueries as jest.Mock).mockReturnValue([
{ ...baseQueryResult },
{ ...baseQueryResult },
{
...baseQueryResult,
isError: true,
isSuccess: false,
error: new TypeError(
"Cannot read properties of undefined (reading 'code')",
),
},
{ ...baseQueryResult },
]);
render(<GeneralSettings />);
expect(screen.getByText('something_went_wrong')).toBeInTheDocument();
});
});

View File

@@ -76,9 +76,7 @@ function GeneralSettings(): JSX.Element {
if (getRetentionPeriodLogsApiResponse.isError || getDisksResponse.isError) {
return (
<Typography>
{(getRetentionPeriodLogsApiResponse.error instanceof APIError
? getRetentionPeriodLogsApiResponse.error.getErrorMessage()
: undefined) ||
{(getRetentionPeriodLogsApiResponse.error as APIError).getErrorMessage() ||
getDisksResponse.data?.error ||
t('something_went_wrong')}
</Typography>

View File

@@ -796,7 +796,7 @@ export const getClusterMetricsQueryPayload = (
key: k8sDeploymentDesiredKey,
type: 'Gauge',
},
aggregateOperator: 'latest',
aggregateOperator: 'avg',
dataSource: DataSource.METRICS,
disabled: false,
expression: 'B',
@@ -839,7 +839,7 @@ export const getClusterMetricsQueryPayload = (
reduceTo: ReduceOperators.LAST,
spaceAggregation: 'sum',
stepInterval: 60,
timeAggregation: 'latest',
timeAggregation: 'avg',
},
],
queryFormulas: [],

View File

@@ -40,7 +40,6 @@ import { K8S_ENTITY_EVENTS_EXPRESSION_KEY, useEntityEvents } from './hooks';
import { getEntityEventsQueryPayload, isEventsKeyNotFoundError } from './utils';
import styles from './EntityEvents.module.scss';
import { useTimezone } from 'providers/Timezone';
interface EventDataType {
key: string;
@@ -168,25 +167,17 @@ function EntityEventsContent({
[events],
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const columns: TableColumnsType<EventDataType> = useMemo(
() => [
{ title: 'Severity', dataIndex: 'severity', key: 'severity', width: 100 },
{
title: 'Timestamp',
dataIndex: 'timestamp',
width: 240,
ellipsis: true,
key: 'timestamp',
render: (value: string | number): string =>
formatTimezoneAdjustedTimestamp(
typeof value === 'string' ? value : value / 1e6,
),
},
{ title: 'Body', dataIndex: 'body', key: 'body' },
],
[formatTimezoneAdjustedTimestamp],
);
const columns: TableColumnsType<EventDataType> = [
{ title: 'Severity', dataIndex: 'severity', key: 'severity', width: 100 },
{
title: 'Timestamp',
dataIndex: 'timestamp',
width: 240,
ellipsis: true,
key: 'timestamp',
},
{ title: 'Body', dataIndex: 'body', key: 'body' },
];
const handleExpandRowIcon = ({
expanded,

View File

@@ -41,7 +41,6 @@ import { getTraceListColumns } from './traceListColumns';
import { getEntityTracesQueryPayload } from './utils';
import styles from './EntityTraces.module.scss';
import { useTimezone } from 'providers/Timezone';
interface Props {
timeRange: {
@@ -137,11 +136,7 @@ function EntityTracesContent({
[timeRange.startTime, timeRange.endTime, userExpression],
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const traceListColumns = getTraceListColumns(
selectedEntityTracesColumns,
formatTimezoneAdjustedTimestamp,
);
const traceListColumns = getTraceListColumns(selectedEntityTracesColumns);
const isKeyNotFound = isKeyNotFoundError(error);
const isDataEmpty =

View File

@@ -1,14 +1,15 @@
import { TableColumnsType as ColumnsType } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
import {
BlockLink,
getTraceLink,
} from 'container/TracesExplorer/ListView/utils';
import dayjs from 'dayjs';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FormatTimezoneAdjustedTimestamp } from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
const keyToLabelMap: Record<string, string> = {
timestamp: 'Timestamp',
@@ -58,7 +59,6 @@ const getValueForKey = (data: Record<string, any>, key: string): any => {
export const getTraceListColumns = (
selectedColumns: BaseAutocompleteData[],
formatTimezoneAdjustedTimestamp: FormatTimezoneAdjustedTimestamp,
): ColumnsType<RowData> => {
const columns: ColumnsType<RowData> =
selectedColumns.map(({ dataType, key, type }) => ({
@@ -73,8 +73,8 @@ export const getTraceListColumns = (
if (primaryKey === 'timestamp') {
const date =
typeof value === 'string'
? formatTimezoneAdjustedTimestamp(value)
: formatTimezoneAdjustedTimestamp(value / 1e6);
? dayjs(value).format(DATE_TIME_FORMATS.ISO_DATETIME_MS)
: dayjs(value / 1e6).format(DATE_TIME_FORMATS.ISO_DATETIME_MS);
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>

View File

@@ -1366,7 +1366,7 @@ export const getPodMetricsQueryPayload = (
orderBy: [],
queryName: 'B',
reduceTo: ReduceOperators.AVG,
spaceAggregation: 'sum',
spaceAggregation: 'avg',
stepInterval: 60,
timeAggregation: 'avg',
},

View File

@@ -86,9 +86,9 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
},
{
id: 'capacity',
header: 'Capacity',
header: 'Volume Capacity',
accessorFn: (row): number => row.volumeCapacity,
width: { min: 140 },
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const capacity = value as number;
@@ -105,9 +105,9 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
},
{
id: 'usage',
header: 'Used',
header: 'Volume Utilization',
accessorFn: (row): number => row.volumeUsage,
width: { min: 140 },
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const usage = value as number;
@@ -124,9 +124,9 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
},
{
id: 'available',
header: 'Available',
header: 'Volume Available',
accessorFn: (row): number => row.volumeAvailable,
width: { min: 140 },
width: { min: 220 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const available = value as number;
@@ -141,61 +141,4 @@ export const k8sVolumesColumnsConfig: TableColumnDef<K8sVolumesData>[] = [
);
},
},
{
id: 'inodes',
header: 'Inodes',
accessorFn: (row): number => row.volumeInodes,
width: { min: 140 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const inodes = value as number;
return (
<ValidateColumnValueWrapper
value={inodes}
entity={InfraMonitoringEntity.VOLUMES}
attribute="inodes metric"
>
<TanStackTable.Text>{inodes}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'inodesUsed',
header: 'Inodes Used',
accessorFn: (row): number => row.volumeInodesUsed,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const inodesUsed = value as number;
return (
<ValidateColumnValueWrapper
value={inodesUsed}
entity={InfraMonitoringEntity.VOLUMES}
attribute="inodes used metric"
>
<TanStackTable.Text>{inodesUsed}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
{
id: 'inodesFree',
header: 'Inodes Free',
accessorFn: (row): number => row.volumeInodesFree,
width: { min: 160 },
enableSort: true,
cell: ({ value }): React.ReactNode => {
const inodesFree = value as number;
return (
<ValidateColumnValueWrapper
value={inodesFree}
entity={InfraMonitoringEntity.VOLUMES}
attribute="inodes free metric"
>
<TanStackTable.Text>{inodesFree}</TanStackTable.Text>
</ValidateColumnValueWrapper>
);
},
},
];

View File

@@ -151,11 +151,6 @@ export function PlannedDowntimeForm(
const saveHandler = useCallback(
async (values: PlannedDowntimeFormData) => {
const { startTime, timezone } = values;
if (!startTime || !timezone) {
// unreachable: required fields should always be present on submitting.
return;
}
const data: AlertmanagertypesPostablePlannedMaintenanceDTO = {
alertIds:
values.alertRuleScope === 'all'
@@ -166,9 +161,9 @@ export function PlannedDowntimeForm(
name: values.name,
scope: values.scope,
schedule: {
startTime: startTime.format(),
startTime: values.startTime?.format(),
endTime: values.endTime?.format(),
timezone,
timezone: values.timezone!,
recurrence: values.recurrence,
},
};
@@ -205,17 +200,25 @@ export function PlannedDowntimeForm(
],
);
const onFinish = async (values: PlannedDowntimeFormData): Promise<void> => {
const rec = values.recurrence;
const recurrence =
rec && rec.repeatType !== recurrenceOptions.doesNotRepeat.value
? {
duration: `${rec.duration}${durationUnit}`,
repeatOn: rec.repeatOn,
repeatType: rec.repeatType,
}
: undefined;
const { recurrence } = values;
const recurrenceData =
!recurrence ||
recurrence.repeatType === recurrenceOptions.doesNotRepeat.value
? undefined
: {
duration: recurrence.duration
? `${recurrence.duration}${durationUnit}`
: '',
startTime: values.startTime!.format(),
endTime: values.endTime?.format(),
repeatOn: recurrence.repeatOn,
repeatType: recurrence.repeatType,
};
await saveHandler({ ...values, recurrence });
await saveHandler({
...values,
recurrence: recurrenceData,
});
};
const handleFormData = (data: Partial<PlannedDowntimeFormData>): void => {
@@ -272,6 +275,9 @@ export function PlannedDowntimeForm(
const formattedInitialValues = useMemo((): PlannedDowntimeFormData => {
const { schedule } = initialValues;
const startTime = schedule?.recurrence?.startTime || schedule?.startTime;
const endTime = schedule?.recurrence?.endTime || schedule?.endTime;
const initialAlertIds = initialValues.alertIds || [];
return {
@@ -279,12 +285,8 @@ export function PlannedDowntimeForm(
alertRuleScope:
isEditMode && initialAlertIds.length === 0 ? 'all' : 'specific',
alertRules: getAlertOptionsFromIds(initialAlertIds, alertOptions),
startTime: schedule?.startTime
? dayjs(schedule.startTime).tz(schedule.timezone)
: null,
endTime: schedule?.endTime
? dayjs(schedule.endTime).tz(schedule.timezone)
: null,
startTime: startTime ? dayjs(startTime).tz(schedule.timezone) : null,
endTime: endTime ? dayjs(endTime).tz(schedule.timezone) : null,
recurrence: {
...schedule?.recurrence,
repeatType: !isScheduleRecurring(schedule)
@@ -295,7 +297,7 @@ export function PlannedDowntimeForm(
timezone: schedule?.timezone as string,
scope: initialValues.scope || '',
};
}, [initialValues, isEditMode, alertOptions]);
}, [initialValues, alertOptions]);
useEffect(() => {
setSelectedTags(formattedInitialValues.alertRules);
@@ -339,7 +341,7 @@ export function PlannedDowntimeForm(
const formattedEndTime = endTime.format(TIME_FORMAT);
const formattedEndDate = endTime.format(DATE_FORMAT);
return `Scheduled to end maintenance on ${formattedEndDate} at ${formattedEndTime}.`;
}, [formData]);
}, [formData, recurrenceType]);
return (
<Modal

View File

@@ -5,12 +5,11 @@ import { Collapse, Flex, Space, Table, TableProps, Tooltip } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import { Typography } from '@signozhq/ui/typography';
import type { DefaultOptionType } from 'antd/es/select';
import {
AlertmanagertypesMaintenanceStatusDTO,
type ListDowntimeSchedules200,
type RenderErrorResponseDTO,
type AlertmanagertypesPlannedMaintenanceDTO,
type AlertmanagertypesScheduleDTO,
import type {
ListDowntimeSchedules200,
RenderErrorResponseDTO,
AlertmanagertypesPlannedMaintenanceDTO,
AlertmanagertypesScheduleDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import cx from 'classnames';
@@ -33,50 +32,6 @@ import './PlannedDowntime.styles.scss';
const { Panel } = Collapse;
const STATUS_BADGE_PROPS: Record<
AlertmanagertypesMaintenanceStatusDTO,
{ color: 'forest' | 'robin' | 'vanilla'; label: string }
> = {
[AlertmanagertypesMaintenanceStatusDTO.active]: {
color: 'forest',
label: 'Active',
},
[AlertmanagertypesMaintenanceStatusDTO.upcoming]: {
color: 'robin',
label: 'Upcoming',
},
[AlertmanagertypesMaintenanceStatusDTO.expired]: {
color: 'vanilla',
label: 'Expired',
},
};
const STATUS_SORT_ORDER: Record<AlertmanagertypesMaintenanceStatusDTO, number> =
{
[AlertmanagertypesMaintenanceStatusDTO.active]: 0,
[AlertmanagertypesMaintenanceStatusDTO.upcoming]: 1,
[AlertmanagertypesMaintenanceStatusDTO.expired]: 2,
};
function StatusBadge({
status,
}: {
status?: AlertmanagertypesMaintenanceStatusDTO;
}): JSX.Element | null {
if (!status) {
return null;
}
const props = STATUS_BADGE_PROPS[status];
if (!props) {
return null;
}
return (
<Badge color={props.color} variant="outline">
{props.label}
</Badge>
);
}
interface AlertRuleTagsProps {
selectedTags: DefaultOptionType | DefaultOptionType[];
closable: boolean;
@@ -128,13 +83,11 @@ export function AlertRuleTags(props: AlertRuleTagsProps): JSX.Element {
function HeaderComponent({
name,
duration,
status,
handleEdit,
handleDelete,
}: {
name: string;
duration: string;
status?: AlertmanagertypesMaintenanceStatusDTO;
handleEdit: () => void;
handleDelete: () => void;
}): JSX.Element {
@@ -142,10 +95,9 @@ function HeaderComponent({
const isCrudEnabled = user?.role !== USER_ROLES.VIEWER;
return (
<Flex className="header-content" justify="space-between">
<Flex gap={8} align="center">
<Flex gap={8}>
<Typography>{name}</Typography>
<Badge color="vanilla">{duration}</Badge>
<StatusBadge status={status} />
</Flex>
{isCrudEnabled && (
@@ -190,6 +142,7 @@ export function CollapseListContent({
updated_by_name?: string;
alertOptions?: DefaultOptionType[];
}): JSX.Element {
const repeats = schedule?.recurrence;
const renderItems = (title: string, value: ReactNode): JSX.Element => (
<div className="render-item-collapse-list">
<Typography>{title}</Typography>
@@ -240,7 +193,10 @@ export function CollapseListContent({
'Timezone',
<Typography>{schedule?.timezone || '-'}</Typography>,
)}
{renderItems('Repeats', <Typography>{recurrenceInfo(schedule)}</Typography>)}
{renderItems(
'Repeats',
<Typography>{recurrenceInfo(repeats, schedule?.timezone)}</Typography>,
)}
{renderItems(
'Alerts silenced',
alertOptions?.length ? (
@@ -273,7 +229,6 @@ export function CustomCollapseList(
createdAt,
createdBy,
schedule,
status,
updatedAt,
updatedBy,
name,
@@ -302,7 +257,6 @@ export function CustomCollapseList(
: getDuration(schedule?.startTime || '', schedule?.endTime || '')
}
name={defaultTo(name, '')}
status={status}
handleEdit={() => {
setInitialValues({ ...props });
setModalOpen(true);
@@ -376,11 +330,6 @@ export function PlannedDowntimeList({
const tableData = [...(downtimeSchedules.data?.data || [])]
.sort((a, b): number => {
const statusDiff =
(STATUS_SORT_ORDER[a.status] ?? 99) - (STATUS_SORT_ORDER[b.status] ?? 99);
if (statusDiff !== 0) {
return statusDiff;
}
if (a?.updatedAt && b?.updatedAt) {
return dayjs(b.updatedAt).diff(dayjs(a.updatedAt));
}

View File

@@ -6,7 +6,7 @@ import type {
DeleteDowntimeScheduleByIDPathParameters,
RenderErrorResponseDTO,
AlertmanagertypesPlannedMaintenanceDTO,
AlertmanagertypesScheduleDTO,
AlertmanagertypesRecurrenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { ErrorType } from 'api/generatedAPIInstance';
import { AxiosError } from 'axios';
@@ -66,17 +66,14 @@ export const getAlertOptionsFromIds = (
);
export const recurrenceInfo = (
schedule?: AlertmanagertypesScheduleDTO | null,
recurrence?: AlertmanagertypesRecurrenceDTO | null,
timezone?: string,
): string => {
if (!schedule) {
return 'No';
}
const { startTime, endTime, timezone, recurrence } = schedule;
if (!recurrence) {
return 'No';
}
const { duration, repeatOn, repeatType } = recurrence;
const { startTime, duration, repeatOn, repeatType, endTime } = recurrence;
const formattedStartTime = startTime
? formatDateTime(startTime, timezone)
@@ -98,7 +95,7 @@ export const defaultInitialValues: Partial<AlertmanagertypesPlannedMaintenanceDT
timezone: '',
endTime: undefined,
recurrence: undefined,
startTime: '',
startTime: undefined,
},
alertIds: [],
createdAt: undefined,

View File

@@ -11,7 +11,7 @@ export const buildSchedule = (
schedule: Partial<AlertmanagertypesScheduleDTO>,
): AlertmanagertypesScheduleDTO => ({
timezone: schedule?.timezone ?? '',
startTime: schedule?.startTime ?? '',
startTime: schedule?.startTime,
endTime: schedule?.endTime,
recurrence: schedule?.recurrence,
});

View File

@@ -1135,9 +1135,17 @@
.settings-dropdown,
.help-support-dropdown {
.user-settings-dropdown-logout-section {
color: var(--danger-background);
pointer-events: auto;
.ant-dropdown-menu-item {
min-height: 32px;
.ant-dropdown-menu-title-content {
color: var(--l1-foreground) !important;
}
.user-settings-dropdown-logout-section {
color: var(--danger-background);
pointer-events: auto;
}
}
}

View File

@@ -1,42 +0,0 @@
import { getFlamegraph } from 'api/generated/services/tracedetail';
import {
SpantypesGettableFlamegraphTraceDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useQuery, UseQueryResult } from 'react-query';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export interface GetTraceFlamegraphV3Props {
traceId: string;
selectedSpanId?: string;
selectFields?: TelemetryFieldKey[];
enabled?: boolean;
}
const useGetTraceFlamegraphV3 = (
props: GetTraceFlamegraphV3Props,
): UseQueryResult<SpantypesGettableFlamegraphTraceDTO, unknown> =>
useQuery({
queryFn: () =>
getFlamegraph(
{ traceID: props.traceId },
{
selectedSpanId: props.selectedSpanId,
// v5 TelemetryFieldKey and the generated DTO are runtime-identical; only
// the literal-union vs enum nominal types differ
selectFields: props.selectFields as TelemetrytypesTelemetryFieldKeyDTO[],
},
).then((res) => res.data),
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V3_FLAMEGRAPH,
props.traceId,
props.selectedSpanId,
props.selectFields,
],
enabled: props.enabled,
keepPreviousData: true,
refetchOnWindowFocus: false,
});
export default useGetTraceFlamegraphV3;

View File

@@ -22,13 +22,11 @@ interface CacheEntry {
const CACHE_SIZE_LIMIT = 1000;
const CACHE_CLEANUP_PERCENTAGE = 0.5; // Remove 50% when limit is reached
export type FormatTimezoneAdjustedTimestamp = (
input: TimestampInput,
format?: string,
) => string;
function useTimezoneFormatter({ userTimezone }: { userTimezone: Timezone }): {
formatTimezoneAdjustedTimestamp: FormatTimezoneAdjustedTimestamp;
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string;
} {
// Initialize cache using useMemo to persist between renders
const cache = useMemo(() => new Map<string, CacheEntry>(), []);

View File

@@ -7,10 +7,11 @@ const TAB_SELECTOR = '.ant-tabs-tab';
const LIST_ALERT_RULES_TEXT = 'List Alert Rules Component';
const TRIGGERED_ALERTS_TEXT = 'Triggered Alerts';
const ALERT_RULES_TEXT = 'Alert Rules';
const CONFIGURATION_TEXT = 'Configuration';
const PLANNED_DOWNTIME_TEXT = 'Planned Downtime';
const ROUTING_POLICIES_TEXT = 'Routing Policies';
const PLANNED_DOWNTIME_TAB = 'PlannedDowntime';
const ROUTING_POLICIES_TAB = 'RoutingPolicies';
const PLANNED_DOWNTIME_SUB_TAB = 'planned-downtime';
const ROUTING_POLICIES_SUB_TAB = 'routing-policies';
const mockUseLocation = jest.fn();
jest.mock('react-router-dom', () => ({
@@ -105,7 +106,7 @@ describe('AlertList', () => {
expect(screen.getByText(LIST_ALERT_RULES_TEXT)).toBeInTheDocument();
});
it('should render all four top-level tabs', () => {
it('should render all three main tabs', () => {
mockQueryParams({});
mockLocation(ALERTS_PATH);
@@ -113,8 +114,7 @@ describe('AlertList', () => {
expect(screen.getByText(TRIGGERED_ALERTS_TEXT)).toBeInTheDocument();
expect(screen.getByText(ALERT_RULES_TEXT)).toBeInTheDocument();
expect(screen.getByText(PLANNED_DOWNTIME_TEXT)).toBeInTheDocument();
expect(screen.getByText(ROUTING_POLICIES_TEXT)).toBeInTheDocument();
expect(screen.getByText(CONFIGURATION_TEXT)).toBeInTheDocument();
});
});
@@ -137,22 +137,13 @@ describe('AlertList', () => {
expect(screen.getByText(LIST_ALERT_RULES_TEXT)).toBeInTheDocument();
});
it('should render PlannedDowntime tab when tab query param is PlannedDowntime', () => {
mockQueryParams({ tab: PLANNED_DOWNTIME_TAB });
it('should render Configuration tab with default Planned Downtime sub-tab when tab query param is Configuration', () => {
mockQueryParams({ tab: 'Configuration' });
mockLocation(ALERTS_PATH);
render(<AlertList />);
expect(screen.getByText('Planned Downtime Component')).toBeInTheDocument();
});
it('should render RoutingPolicies tab when tab query param is RoutingPolicies', () => {
mockQueryParams({ tab: ROUTING_POLICIES_TAB });
mockLocation(ALERTS_PATH);
render(<AlertList />);
expect(screen.getByText('Routing Policies Component')).toBeInTheDocument();
expect(screen.getByText(PLANNED_DOWNTIME_TEXT)).toBeInTheDocument();
});
it('should navigate to TriggeredAlerts tab when clicked', () => {
@@ -166,30 +157,84 @@ describe('AlertList', () => {
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts?tab=TriggeredAlerts');
});
it('should navigate to PlannedDowntime tab when clicked', () => {
mockQueryParams({ tab: 'AlertRules' });
it('should navigate to AlertRules tab when clicked', () => {
mockQueryParams({ tab: 'TriggeredAlerts' });
mockLocation(ALERTS_PATH);
render(<AlertList />);
clickTab(PLANNED_DOWNTIME_TEXT);
clickTab(ALERT_RULES_TEXT);
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/alerts?tab=${PLANNED_DOWNTIME_TAB}`,
);
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts?tab=AlertRules');
});
});
describe('Configuration Tab', () => {
describe('Rendering', () => {
it('should render Configuration tab with default Planned Downtime sub-tab', () => {
mockQueryParams({ tab: CONFIGURATION_TEXT });
mockLocation(ALERTS_PATH);
render(<AlertList />);
expect(screen.getByText(PLANNED_DOWNTIME_TEXT)).toBeInTheDocument();
expect(screen.getByText(ROUTING_POLICIES_TEXT)).toBeInTheDocument();
expect(screen.getByText('Planned Downtime Component')).toBeInTheDocument();
});
it('should render Routing Policies sub-tab when subTab query param is routing-policies', () => {
mockQueryParams({
tab: CONFIGURATION_TEXT,
subTab: ROUTING_POLICIES_SUB_TAB,
});
mockLocation(ALERTS_PATH);
render(<AlertList />);
expect(screen.getByText('Routing Policies Component')).toBeInTheDocument();
});
});
it('should navigate to RoutingPolicies tab when clicked', () => {
mockQueryParams({ tab: 'AlertRules' });
mockLocation(ALERTS_PATH);
describe('Navigation', () => {
it('should navigate to Configuration tab with default subTab when clicked', () => {
mockQueryParams({ tab: 'AlertRules' });
mockLocation(ALERTS_PATH);
render(<AlertList />);
render(<AlertList />);
clickTab(ROUTING_POLICIES_TEXT);
clickTab(CONFIGURATION_TEXT);
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/alerts?tab=${ROUTING_POLICIES_TAB}`,
);
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/alerts?tab=Configuration&subTab=${PLANNED_DOWNTIME_SUB_TAB}`,
);
});
it('should preserve existing subTab when navigating to Configuration tab', () => {
mockQueryParams({ tab: 'AlertRules', subTab: ROUTING_POLICIES_SUB_TAB });
mockLocation(ALERTS_PATH);
render(<AlertList />);
clickTab(CONFIGURATION_TEXT);
expect(mockSafeNavigate).toHaveBeenCalledWith(
`/alerts?tab=Configuration&subTab=${ROUTING_POLICIES_SUB_TAB}`,
);
});
it('should clear subTab when navigating away from Configuration tab', () => {
mockQueryParams({
tab: CONFIGURATION_TEXT,
subTab: PLANNED_DOWNTIME_SUB_TAB,
});
mockLocation(ALERTS_PATH);
render(<AlertList />);
clickTab(ALERT_RULES_TEXT);
expect(mockSafeNavigate).toHaveBeenCalledWith('/alerts?tab=AlertRules');
});
});
});
});

View File

@@ -1,3 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { Tabs, TabsProps } from 'antd';
import ConfigureIcon from 'assets/AlertHistory/ConfigureIcon';
@@ -9,10 +10,10 @@ import RoutingPolicies from 'container/RoutingPolicies';
import TriggeredAlerts from 'container/TriggeredAlerts';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { CalendarClock, GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import { GalleryVerticalEnd, Pyramid } from '@signozhq/icons';
import AlertDetails from 'pages/AlertDetails';
import { AlertListTabs } from './types';
import { AlertListSubTabs, AlertListTabs } from './types';
import './AlertList.styles.scss';
@@ -22,9 +23,44 @@ function AllAlertList(): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const tab = urlQuery.get('tab');
const subTab = urlQuery.get('subTab');
const isAlertHistory = location.pathname === ROUTES.ALERT_HISTORY;
const isAlertOverview = location.pathname === ROUTES.ALERT_OVERVIEW;
const handleConfigurationTabChange = useCallback(
(subTab: string): void => {
const queryParams = new URLSearchParams();
queryParams.set('tab', AlertListTabs.CONFIGURATION);
queryParams.set('subTab', subTab);
safeNavigate(`/alerts?${queryParams.toString()}`);
},
[safeNavigate],
);
const configurationTab = useMemo(() => {
const tabs = [
{
label: 'Planned Downtime',
key: AlertListSubTabs.PLANNED_DOWNTIME,
children: <PlannedDowntime />,
},
{
label: 'Routing Policies',
key: AlertListSubTabs.ROUTING_POLICIES,
children: <RoutingPolicies />,
},
];
return (
<Tabs
className="configuration-tabs"
activeKey={subTab || AlertListSubTabs.PLANNED_DOWNTIME}
items={tabs}
onChange={handleConfigurationTabChange}
/>
);
}, [subTab, handleConfigurationTabChange]);
const items: TabsProps['items'] = [
{
label: (
@@ -50,25 +86,15 @@ function AllAlertList(): JSX.Element {
</div>
),
},
{
label: (
<div className="periscope-tab top-level-tab">
<CalendarClock size={14} />
Planned Downtime
</div>
),
key: AlertListTabs.PLANNED_DOWNTIME,
children: <PlannedDowntime />,
},
{
label: (
<div className="periscope-tab top-level-tab">
<ConfigureIcon width={14} height={14} />
Routing Policies
Configuration
</div>
),
key: AlertListTabs.ROUTING_POLICIES,
children: <RoutingPolicies />,
key: AlertListTabs.CONFIGURATION,
children: configurationTab,
},
];
@@ -79,7 +105,18 @@ function AllAlertList(): JSX.Element {
activeKey={tab || AlertListTabs.ALERT_RULES}
onChange={(tab): void => {
const queryParams = new URLSearchParams();
queryParams.set('tab', tab);
// If navigating to Configuration tab, set default subTab
if (tab === AlertListTabs.CONFIGURATION) {
const currentSubTab = subTab || AlertListSubTabs.PLANNED_DOWNTIME;
queryParams.set('subTab', currentSubTab);
} else {
// Clear subTab when navigating out of Configuration tab
queryParams.delete('subTab');
}
safeNavigate(`/alerts?${queryParams.toString()}`);
}}
className={`alerts-container ${

View File

@@ -1,6 +1,10 @@
export enum AlertListSubTabs {
PLANNED_DOWNTIME = 'planned-downtime',
ROUTING_POLICIES = 'routing-policies',
}
export enum AlertListTabs {
TRIGGERED_ALERTS = 'TriggeredAlerts',
ALERT_RULES = 'AlertRules',
PLANNED_DOWNTIME = 'PlannedDowntime',
ROUTING_POLICIES = 'RoutingPolicies',
CONFIGURATION = 'Configuration',
}

View File

@@ -1,103 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { SelectSimple } from '@signozhq/ui/select';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- searchable async select: no @signozhq/ui equivalent
import { Select } from 'antd';
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import useDebounce from 'hooks/useDebounce';
import { TELEMETRY_SIGNALS, type TelemetrySignal } from '../variableModel';
import styles from './VariableForm.module.scss';
interface DynamicVariableFieldsProps {
attribute: string;
signal: TelemetrySignal;
onChange: (patch: {
dynamicAttribute?: string;
dynamicSignal?: TelemetrySignal;
}) => void;
onPreview: (values: (string | number)[]) => void;
}
/** Dynamic-variable body: telemetry signal + field, whose live values preview. */
function DynamicVariableFields({
attribute,
signal,
onChange,
onPreview,
}: DynamicVariableFieldsProps): JSX.Element {
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const { data: keyData, isLoading } = useGetFieldKeys({
signal,
name: debouncedSearch || undefined,
});
// `keys` is a Record keyed BY field name; the field names are the map keys.
// When the API reports the list is `complete`, search filters locally.
const isComplete = keyData?.data?.complete === true;
const options = useMemo(
() =>
Object.keys(keyData?.data?.keys ?? {}).map((name) => ({
label: name,
value: name,
})),
[keyData],
);
const { data: valueData } = useGetFieldValues({
signal,
name: attribute,
enabled: !!attribute,
});
useEffect(() => {
const payload = valueData?.data;
const values =
payload?.normalizedValues ?? payload?.values?.StringValues ?? [];
onPreview(values);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [valueData]);
return (
<>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Source</Typography.Text>
</div>
<SelectSimple
className={styles.sortSelect}
value={signal}
items={TELEMETRY_SIGNALS.map((s) => ({ label: s, value: s }))}
onChange={(value): void =>
onChange({ dynamicSignal: value as TelemetrySignal })
}
testId="variable-signal-select"
/>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Attribute</Typography.Text>
</div>
<Select
className={styles.searchSelect}
showSearch
value={attribute || undefined}
placeholder="Select a telemetry field"
loading={isLoading}
filterOption={isComplete}
onSearch={setSearch}
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
options={options}
notFoundContent={isLoading ? 'Loading…' : 'No fields found'}
data-testid="variable-field-select"
/>
</div>
</>
);
}
export default DynamicVariableFields;

View File

@@ -1,93 +0,0 @@
import { useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import Editor from 'components/Editor';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import type { VariableSort } from '../variableModel';
import styles from './VariableForm.module.scss';
interface QueryVariableFieldsProps {
queryValue: string;
sort: VariableSort;
onChange: (queryValue: string) => void;
onPreview: (values: (string | number)[]) => void;
onError: (message: string | null) => void;
}
/** Query-variable body: SQL editor + "Test Run Query" that previews the values. */
function QueryVariableFields({
queryValue,
sort,
onChange,
onPreview,
onError,
}: QueryVariableFieldsProps): JSX.Element {
const [isRunning, setIsRunning] = useState(false);
const runTest = async (): Promise<void> => {
setIsRunning(true);
onError(null);
try {
const res = await dashboardVariablesQuery({
query: queryValue,
variables: {},
});
if (res.statusCode === 200 && res.payload) {
onPreview(
sortValues(res.payload.variableValues ?? [], sort) as (string | number)[],
);
} else {
onError(res.error || 'Failed to run query');
onPreview([]);
}
} catch (err) {
onError((err as Error).message || 'Failed to run query');
onPreview([]);
} finally {
setIsRunning(false);
}
};
return (
<div className={styles.queryContainer}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Query</Typography.Text>
</div>
<div className={styles.editorWrap}>
<Editor
language="sql"
value={queryValue}
onChange={(value): void => onChange(value)}
height="240px"
options={{
fontSize: 13,
wordWrap: 'on',
lineNumbers: 'off',
glyphMargin: false,
folding: false,
lineDecorationsWidth: 0,
lineNumbersMinChars: 0,
minimap: { enabled: false },
}}
/>
</div>
<div className={styles.testRow}>
<Button
variant="solid"
color="primary"
size="sm"
loading={isRunning}
disabled={!queryValue}
onClick={runTest}
testId="variable-test-run"
>
Test Run Query
</Button>
</div>
</div>
);
}
export default QueryVariableFields;

View File

@@ -1,310 +0,0 @@
/* Faithful reproduction of the V1 VariableItem layout, scoped as a module and
built on @signozhq components where possible. antd is retained only for the
monaco Editor, multiline TextArea, Collapse, and searchable Selects. */
.container {
display: flex;
flex-direction: column;
border: 1px solid var(--l1-border);
border-radius: 3px;
}
.allVariables {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--l1-border);
}
.allVariablesBtn {
--button-height: 24px;
--button-padding: 0;
color: var(--muted-foreground);
}
.content {
display: flex;
flex-direction: column;
gap: 20px;
padding: 12px 16px 20px;
}
/* VariableItemRow */
.row {
display: flex;
gap: 1rem;
margin-bottom: 0;
}
/* LabelContainer */
.labelContainer {
width: 200px;
}
.label {
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
font-weight: 400;
line-height: 20px;
}
.column {
flex-direction: column;
gap: 8px;
}
.input,
.textarea,
.defaultInput {
padding: 6px 6px 6px 8px;
border: 1px solid var(--l1-border);
border-radius: 2px;
background: var(--l3-background);
}
.input,
.textarea {
width: 100%;
}
.defaultInput {
width: 342px;
}
.errorText {
font-size: 12px;
color: var(--bg-amber-500);
}
/* Variable type segmented group */
.typeSection {
align-items: center;
justify-content: space-between;
}
.typeLabelContainer {
display: flex;
align-items: center;
gap: 8px;
width: auto;
}
.typeBtnGroup {
display: grid;
grid-template-columns: repeat(4, max-content);
height: 32px;
flex-shrink: 0;
border: 1px solid var(--l1-border);
border-radius: 2px;
background: var(--l2-background);
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
}
.typeBtn {
--button-height: 32px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 114px;
border-radius: 0;
color: var(--l2-foreground);
& + & {
border-left: 1px solid var(--l1-border);
}
}
.typeBtnSelected {
background: var(--l1-border);
color: var(--l1-foreground);
}
.betaTag {
margin-left: 4px;
}
/* Query */
.queryContainer {
display: flex;
flex-flow: column wrap;
gap: 1rem;
min-width: 0;
margin-bottom: 0;
}
.editorWrap {
height: 240px;
overflow: hidden;
border: 1px solid var(--l1-border);
border-radius: 2px;
}
.testRow {
display: flex;
margin-top: 8px;
}
/* Custom — antd Collapse */
.customSection {
margin-bottom: 0;
}
.customSection :global(.custom-collapse) {
width: 100%;
border: 1px solid var(--l1-border);
border-radius: 3px 3px 0 0;
:global(.ant-collapse-item) {
border-bottom: none;
}
:global(.ant-collapse-header) {
align-items: center;
gap: 8px;
height: 38px;
padding: 12px;
background: var(--l3-background);
border-radius: 3px 3px 0 0;
}
:global(.ant-collapse-header-text) {
display: flex;
align-items: center;
gap: 10px;
padding: 1px 2px;
color: var(--bg-robin-400);
font-family: 'Space Mono';
font-size: 14px;
line-height: 18px;
border-radius: 2px;
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
}
:global(.ant-collapse-content-box) {
padding: 0;
}
:global(.comma-input) {
height: 109px;
border: none;
}
}
/* Textbox */
.textboxSection {
align-items: center;
justify-content: space-between;
margin-bottom: 0;
}
/* Preview strip */
.previewSection {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 88px;
margin-bottom: 0;
padding-bottom: 8px;
border: 1px solid var(--l1-border);
border-radius: 3px;
}
.previewLabel {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 4px 8px;
color: var(--bg-robin-400);
font-family: 'Space Mono';
font-size: 14px;
line-height: 18px;
border-radius: 3px 0 2px;
background: color-mix(in srgb, var(--bg-robin-400) 8%, transparent);
}
.previewValues {
display: flex;
flex-flow: wrap;
gap: 8px;
padding: 4.5px 11px;
overflow-y: auto;
}
.previewValues [data-slot='badge'] {
height: 30px;
align-items: center;
color: var(--l1-foreground);
font-family: 'Space Mono';
font-size: 14px;
border: 1px solid var(--l1-border);
border-radius: 2px;
}
.previewError {
color: var(--bg-amber-500);
}
/* Sort / multi / all / default rows */
.sortSection,
.multiSection,
.allOptionSection,
.dynamicSection {
align-items: flex-start;
justify-content: space-between;
margin-bottom: 0;
}
.sortSection {
align-items: center;
}
.rowLabel {
width: 339px;
color: var(--l2-foreground);
font-family: Inter;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.07px;
}
.sortSelect {
width: 192px;
}
.defaultValueSection {
display: grid;
grid-template-columns: max-content 1fr;
gap: 1rem;
align-items: center;
margin-bottom: 0;
}
.defaultValueSection .label {
display: block;
margin-bottom: 2px;
}
.defaultValueDesc {
display: block;
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
line-height: 18px;
letter-spacing: -0.06px;
}
.searchSelect {
width: 100%;
}
/* Footer */
.footer {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 12px;
}

View File

@@ -1,351 +0,0 @@
import { useEffect, useState } from 'react';
import { ArrowLeft, Check, X } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { SelectSimple } from '@signozhq/ui/select';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse/searchable Select: no @signozhq/ui equivalent
import { Collapse, Input as AntdInput, Select } from 'antd';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import {
VARIABLE_SORTS,
type VariableFormModel,
type VariableSort,
type VariableType,
} from '../variableModel';
import DynamicVariableFields from './DynamicVariableFields';
import QueryVariableFields from './QueryVariableFields';
import VariableTypeSelector from './VariableTypeSelector';
import styles from './VariableForm.module.scss';
const SORT_LABEL: Record<VariableSort, string> = {
DISABLED: 'Disabled',
ASC: 'Ascending',
DESC: 'Descending',
};
function getNameError(name: string, existingNames: string[]): string | null {
if (name === '') {
return 'Variable name is required';
}
if (/\s/.test(name)) {
return 'Variable name cannot contain whitespaces';
}
if (existingNames.includes(name)) {
return 'Variable name already exists';
}
return null;
}
interface VariableFormProps {
initial: VariableFormModel;
/** Names of the other variables, for uniqueness validation. */
existingNames: string[];
isSaving: boolean;
onClose: () => void;
onSave: (model: VariableFormModel) => void;
}
/**
* In-drawer variable editor reproducing the V1 VariableItem layout, built on
* @signozhq components (antd kept only for the monaco editor, TextArea, Collapse
* and searchable selects). Master→detail: renders in place of the list.
*/
function VariableForm({
initial,
existingNames,
isSaving,
onClose,
onSave,
}: VariableFormProps): JSX.Element {
const [model, setModel] = useState<VariableFormModel>(initial);
const [previewValues, setPreviewValues] = useState<(string | number)[]>([]);
const [previewError, setPreviewError] = useState<string | null>(null);
const [defaultValue, setDefaultValue] = useState<string>(
((initial.defaultValue as { value?: string })?.value ?? '') as string,
);
useEffect(() => {
setModel(initial);
setPreviewValues([]);
setPreviewError(null);
setDefaultValue(
((initial.defaultValue as { value?: string })?.value ?? '') as string,
);
}, [initial]);
const set = (patch: Partial<VariableFormModel>): void =>
setModel((prev) => ({ ...prev, ...patch }));
const selectType = (type: VariableType): void => {
set({ type });
setPreviewValues([]);
setPreviewError(null);
};
const onCustomChange = (value: string): void => {
set({ customValue: value });
setPreviewValues(
sortValues(commaValuesParser(value), model.sort) as (string | number)[],
);
};
const trimmedName = model.name.trim();
const nameError = getNameError(trimmedName, existingNames);
const isListType =
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
const handleSave = (): void => {
onSave({
...model,
name: trimmedName,
defaultValue: defaultValue ? { value: defaultValue } : undefined,
});
};
return (
<>
<div className={styles.container}>
<div className={styles.allVariables}>
<Button
variant="ghost"
color="secondary"
className={styles.allVariablesBtn}
prefix={<ArrowLeft size={14} />}
onClick={onClose}
testId="variable-form-back"
>
All variables
</Button>
</div>
<div className={styles.content}>
{/* Name */}
<div className={cx(styles.row, styles.column)}>
<Typography.Text className={styles.label}>Name</Typography.Text>
<Input
className={styles.input}
value={model.name}
placeholder="Unique name of the variable"
onChange={(e): void => set({ name: e.target.value })}
testId="variable-name-input"
/>
{nameError ? (
<Typography.Text className={styles.errorText}>
{nameError}
</Typography.Text>
) : null}
</div>
{/* Description */}
<div className={cx(styles.row, styles.column)}>
<Typography.Text className={styles.label}>Description</Typography.Text>
<AntdInput.TextArea
className={styles.textarea}
value={model.description}
placeholder="Enter a description for the variable"
rows={3}
onChange={(e): void => set({ description: e.target.value })}
data-testid="variable-description-input"
/>
</div>
{/* Variable Type */}
<VariableTypeSelector value={model.type} onChange={selectType} />
{/* Type-specific body */}
{model.type === 'DYNAMIC' ? (
<DynamicVariableFields
attribute={model.dynamicAttribute}
signal={model.dynamicSignal}
onChange={(patch): void => set(patch)}
onPreview={setPreviewValues}
/>
) : null}
{model.type === 'QUERY' ? (
<QueryVariableFields
queryValue={model.queryValue}
sort={model.sort}
onChange={(queryValue): void => set({ queryValue })}
onPreview={setPreviewValues}
onError={setPreviewError}
/>
) : null}
{model.type === 'CUSTOM' ? (
<div className={cx(styles.row, styles.customSection)}>
<Collapse
collapsible="header"
rootClassName="custom-collapse"
defaultActiveKey={['1']}
items={[
{
key: '1',
label: 'Options',
children: (
<AntdInput.TextArea
value={model.customValue}
placeholder="Enter options separated by commas."
rootClassName="comma-input"
onChange={(e): void => onCustomChange(e.target.value)}
data-testid="variable-custom-input"
/>
),
},
]}
/>
</div>
) : null}
{model.type === 'TEXT' ? (
<div className={cx(styles.row, styles.textboxSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>
Default Value
</Typography.Text>
</div>
<Input
className={styles.defaultInput}
value={model.textValue}
placeholder="Enter a default value (if any)..."
onChange={(e): void => set({ textValue: e.target.value })}
testId="variable-text-input"
/>
</div>
) : null}
{/* Shared rows for list-type variables */}
{isListType ? (
<>
<div className={cx(styles.row, styles.previewSection)}>
<Typography.Text className={styles.previewLabel}>
Preview of Values
</Typography.Text>
<div className={styles.previewValues}>
{previewError ? (
<Typography.Text className={styles.previewError}>
{previewError}
</Typography.Text>
) : (
previewValues.map((value, idx) => (
<Badge
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
key={`${value}-${idx}`}
color="vanilla"
>
{value.toString()}
</Badge>
))
)}
</div>
</div>
<div className={cx(styles.row, styles.sortSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
</div>
<SelectSimple
className={styles.sortSelect}
value={model.sort}
items={VARIABLE_SORTS.map((sort) => ({
label: SORT_LABEL[sort],
value: sort,
}))}
onChange={(value): void => set({ sort: value as VariableSort })}
testId="variable-sort-select"
/>
</div>
<div className={cx(styles.row, styles.multiSection)}>
<Typography.Text className={styles.rowLabel}>
Enable multiple values to be checked
</Typography.Text>
<Switch
value={model.multiSelect}
onChange={(checked): void => {
set({
multiSelect: checked,
showAllOption: checked ? model.showAllOption : false,
});
}}
testId="variable-multi-switch"
/>
</div>
{model.multiSelect && showAllOptionField ? (
<div className={cx(styles.row, styles.allOptionSection)}>
<Typography.Text className={styles.rowLabel}>
Include an option for ALL values
</Typography.Text>
<Switch
value={model.showAllOption}
onChange={(checked): void => set({ showAllOption: checked })}
testId="variable-all-switch"
/>
</div>
) : null}
<div className={cx(styles.row, styles.defaultValueSection)}>
<div className={styles.labelContainer}>
<Typography.Text className={styles.label}>
Default Value
</Typography.Text>
<Typography.Text className={styles.defaultValueDesc}>
{model.type === 'QUERY'
? 'Click Test Run Query to see the values or add custom value'
: 'Select a value from the preview values or add custom value'}
</Typography.Text>
</div>
<Select
className={styles.searchSelect}
showSearch
allowClear
placeholder="Select a default value"
value={defaultValue || undefined}
onChange={(value): void => setDefaultValue(value ?? '')}
options={previewValues.map((value) => ({
label: value.toString(),
value: value.toString(),
}))}
data-testid="variable-default-select"
/>
</div>
</>
) : null}
</div>
</div>
<div className={styles.footer}>
<Button
variant="solid"
color="secondary"
prefix={<X size={14} />}
onClick={onClose}
>
Discard
</Button>
<Button
variant="solid"
color="primary"
prefix={<Check size={14} />}
disabled={!!nameError}
loading={isSaving}
onClick={handleSave}
testId="variable-save"
>
Save Variable
</Button>
</div>
</>
);
}
export default VariableForm;

View File

@@ -1,99 +0,0 @@
import {
ClipboardType,
DatabaseZap,
Info,
LayoutList,
Pyramid,
} from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import TextToolTip from 'components/TextToolTip';
import type { VariableType } from '../variableModel';
import styles from './VariableForm.module.scss';
interface VariableTypeSelectorProps {
value: VariableType;
onChange: (type: VariableType) => void;
}
/** The segmented Dynamic / Textbox / Custom / Query type picker. */
function VariableTypeSelector({
value,
onChange,
}: VariableTypeSelectorProps): JSX.Element {
return (
<div className={cx(styles.row, styles.typeSection)}>
<div className={styles.typeLabelContainer}>
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
<TextToolTip
text="Learn more about supported variable types"
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
urlText="here"
useFilledIcon={false}
outlinedIcon={<Info size={14} />}
/>
</div>
<div className={styles.typeBtnGroup}>
<Button
variant="ghost"
color="secondary"
prefix={<Pyramid size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'DYNAMIC',
})}
onClick={(): void => onChange('DYNAMIC')}
testId="variable-type-dynamic"
>
Dynamic
<Badge color="robin" className={styles.betaTag}>
Beta
</Badge>
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<ClipboardType size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'TEXT',
})}
onClick={(): void => onChange('TEXT')}
testId="variable-type-textbox"
>
Textbox
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<LayoutList size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'CUSTOM',
})}
onClick={(): void => onChange('CUSTOM')}
testId="variable-type-custom"
>
Custom
</Button>
<Button
variant="ghost"
color="secondary"
prefix={<DatabaseZap size={14} />}
className={cx(styles.typeBtn, {
[styles.typeBtnSelected]: value === 'QUERY',
})}
onClick={(): void => onChange('QUERY')}
testId="variable-type-query"
>
Query
<Badge color="amber" className={styles.betaTag}>
Not Recommended
</Badge>
</Button>
</div>
</div>
);
}
export default VariableTypeSelector;

View File

@@ -1,101 +0,0 @@
.container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px 16px;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.titleRow {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
}
.title {
font-size: 14px;
font-weight: 500;
color: var(--l1-foreground);
}
.subtitle {
font-size: 12px;
color: var(--l2-foreground);
}
.empty {
padding: 32px;
text-align: center;
border: 1px dashed var(--l1-border);
border-radius: 4px;
color: var(--l2-foreground);
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--l1-border);
border-radius: 4px;
background: var(--l1-background);
}
.rowMain {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.varName {
font-weight: 500;
color: var(--l1-foreground);
}
.varDesc {
min-width: 0;
overflow: hidden;
font-size: 12px;
color: var(--l2-foreground);
text-overflow: ellipsis;
white-space: nowrap;
}
.typeTag {
flex-shrink: 0;
padding: 1px 8px;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--l2-foreground);
text-transform: uppercase;
background: var(--l2-background);
border-radius: 10px;
}
.rowActions {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 2px;
}
.confirmText {
margin-right: 4px;
font-size: 12px;
color: var(--l2-foreground);
}

View File

@@ -1,139 +0,0 @@
import {
Check,
ChevronDown,
ChevronUp,
PenLine,
Trash2,
X,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { VariableFormModel } from './variableModel';
import styles from './Variables.module.scss';
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
QUERY: 'Query',
CUSTOM: 'Custom',
TEXT: 'Text',
DYNAMIC: 'Dynamic',
};
interface VariablesListProps {
variables: VariableFormModel[];
canEdit: boolean;
/** Index whose delete is awaiting inline confirmation, if any. */
confirmingIndex: number | null;
onEdit: (index: number) => void;
onRequestDelete: (index: number) => void;
onConfirmDelete: (index: number) => void;
onCancelDelete: () => void;
onMove: (from: number, to: number) => void;
}
function VariablesList({
variables,
canEdit,
confirmingIndex,
onEdit,
onRequestDelete,
onConfirmDelete,
onCancelDelete,
onMove,
}: VariablesListProps): JSX.Element {
return (
<div className={styles.list} data-testid="variables-list">
{variables.map((variable, index) => (
<div
className={styles.row}
key={variable.name || `variable-${index}`}
data-testid={`variable-row-${variable.name}`}
>
<div className={styles.rowMain}>
<Typography.Text className={styles.varName}>
${variable.name}
</Typography.Text>
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
{variable.description ? (
<Typography.Text className={styles.varDesc}>
{variable.description}
</Typography.Text>
) : null}
</div>
{canEdit && confirmingIndex === index ? (
<div className={styles.rowActions}>
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
<Button
variant="ghost"
color="destructive"
size="icon"
onClick={(): void => onConfirmDelete(index)}
aria-label="Confirm delete"
testId={`variable-delete-confirm-${variable.name}`}
>
<Check size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={onCancelDelete}
aria-label="Cancel delete"
>
<X size={14} />
</Button>
</div>
) : null}
{canEdit && confirmingIndex !== index ? (
<div className={styles.rowActions}>
<Button
variant="ghost"
color="secondary"
size="icon"
disabled={index === 0}
onClick={(): void => onMove(index, index - 1)}
aria-label="Move up"
>
<ChevronUp size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
disabled={index === variables.length - 1}
onClick={(): void => onMove(index, index + 1)}
aria-label="Move down"
>
<ChevronDown size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onEdit(index)}
aria-label="Edit variable"
testId={`variable-edit-${variable.name}`}
>
<PenLine size={14} />
</Button>
<Button
variant="ghost"
color="secondary"
size="icon"
onClick={(): void => onRequestDelete(index)}
aria-label="Delete variable"
testId={`variable-delete-${variable.name}`}
>
<Trash2 size={14} />
</Button>
</div>
) : null}
</div>
))}
</div>
);
}
export default VariablesList;

View File

@@ -1,147 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Typography } from '@signozhq/ui/typography';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { useDashboardStore } from '../../store/useDashboardStore';
import { useSaveVariables } from './useSaveVariables';
import { dtoToFormModel } from './variableAdapters';
import {
emptyVariableFormModel,
type VariableFormModel,
} from './variableModel';
import VariableForm from './VariableForm/VariableForm';
import VariablesList from './VariablesList';
import styles from './Variables.module.scss';
interface VariablesSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
/** `null` index = adding a new variable; a number = editing that row. */
type EditingState = { index: number | null } | null;
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const { save, isSaving } = useSaveVariables();
const initialModels = useMemo(
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
[dashboard.spec?.variables],
);
const [variables, setVariables] = useState<VariableFormModel[]>(initialModels);
// Resync from the dashboard after a save round-trips (refetch bumps updatedAt).
useEffect(() => {
setVariables(initialModels);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dashboard.updatedAt]);
const [editing, setEditing] = useState<EditingState>(null);
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
null,
);
const editingModel: VariableFormModel | null = useMemo(() => {
if (!editing) {
return null;
}
return editing.index === null
? emptyVariableFormModel()
: variables[editing.index];
}, [editing, variables]);
const existingNames = useMemo(() => {
const self = editing?.index ?? null;
return variables.filter((_, i) => i !== self).map((v) => v.name);
}, [variables, editing]);
const persist = (next: VariableFormModel[]): void => {
setVariables(next);
void save(next);
};
const handleFormSave = (model: VariableFormModel): void => {
const next = [...variables];
if (editing?.index == null) {
next.push(model);
} else {
next[editing.index] = model;
}
setEditing(null);
persist(next);
};
const handleMove = (from: number, to: number): void => {
if (to < 0 || to >= variables.length) {
return;
}
const next = [...variables];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
persist(next);
};
const handleConfirmDelete = (index: number): void => {
persist(variables.filter((_, i) => i !== index));
setConfirmDeleteIndex(null);
};
// Detail view — edit/new form replaces the list in place (no modal).
if (editingModel) {
return (
<VariableForm
initial={editingModel}
existingNames={existingNames}
isSaving={isSaving}
onClose={(): void => setEditing(null)}
onSave={handleFormSave}
/>
);
}
// Master view — the variables list.
return (
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.titleRow}>
<Typography.Text className={styles.title}>Variables</Typography.Text>
<Typography.Text className={styles.subtitle}>
Define variables to parameterize panel queries.
</Typography.Text>
</div>
{isEditable ? (
<Button
variant="solid"
color="primary"
prefix={<Plus size={14} />}
onClick={(): void => setEditing({ index: null })}
testId="add-variable"
>
New variable
</Button>
) : null}
</div>
{variables.length === 0 ? (
<div className={styles.empty}>
<Typography.Text>No variables defined yet.</Typography.Text>
</div>
) : (
<VariablesList
variables={variables}
canEdit={isEditable}
confirmingIndex={confirmDeleteIndex}
onEdit={(index): void => setEditing({ index })}
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
onConfirmDelete={handleConfirmDelete}
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
onMove={handleMove}
/>
)}
</div>
);
}
export default VariablesSettings;

View File

@@ -1,51 +0,0 @@
import { useCallback, useState } from 'react';
import { patchDashboardV2 } from 'api/generated/services/dashboard';
import { toast } from '@signozhq/ui/sonner';
import { useErrorModal } from 'providers/ErrorModalProvider';
import APIError from 'types/api/error';
import { useDashboardStore } from '../../store/useDashboardStore';
import { formModelToDto } from './variableAdapters';
import type { VariableFormModel } from './variableModel';
import { buildVariablesPatch } from './variablePatchOps';
interface UseSaveVariables {
save: (variables: VariableFormModel[]) => Promise<boolean>;
isSaving: boolean;
}
/**
* Persists the dashboard's variable list via a single `/spec/variables` patch,
* then refetches. Mirrors the General-settings save flow (patch → toast →
* refetch → surface errors).
*/
export function useSaveVariables(): UseSaveVariables {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const refetch = useDashboardStore((s) => s.refetch);
const { showErrorModal } = useErrorModal();
const [isSaving, setIsSaving] = useState(false);
const save = useCallback(
async (variables: VariableFormModel[]): Promise<boolean> => {
if (!dashboardId) {
return false;
}
const dtos = variables.map(formModelToDto);
try {
setIsSaving(true);
await patchDashboardV2({ id: dashboardId }, buildVariablesPatch(dtos));
toast.success('Variables updated');
refetch();
return true;
} catch (error) {
showErrorModal(error as APIError);
return false;
} finally {
setIsSaving(false);
}
},
[dashboardId, refetch, showErrorModal],
);
return { save, isSaving };
}

View File

@@ -1,153 +0,0 @@
import {
DashboardtypesVariableEnvelopeGithubComPersesSpecGoDashboardTextVariableSpecDTOKind as TextEnvelopeKind,
DashboardtypesVariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesListVariableSpecDTOKind as ListEnvelopeKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTOKind as QueryPluginKind,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type {
DashboardtypesListVariableSpecDTO,
DashboardtypesVariableDTO,
DashboardtypesVariablePluginDTO,
DashboardTextVariableSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
emptyVariableFormModel,
PLUGIN_KIND,
type TelemetrySignal,
type VariableFormModel,
type VariableSort,
} from './variableModel';
/** DTO envelope → flat form model (for display / editing). */
export function dtoToFormModel(
dto: DashboardtypesVariableDTO,
): VariableFormModel {
const base = emptyVariableFormModel();
const display = dto.spec?.display;
const common: VariableFormModel = {
...base,
name: dto.spec?.name ?? display?.name ?? '',
description: display?.description ?? '',
};
// Text variable — a distinct envelope (no list plugin).
if (dto.kind === TextEnvelopeKind.TextVariable) {
const spec = dto.spec as DashboardTextVariableSpecDTO;
return {
...common,
type: 'TEXT',
textValue: spec.value ?? '',
textConstant: spec.constant ?? false,
};
}
// List variable — Query / Custom / Dynamic, distinguished by plugin.kind.
const spec = dto.spec as DashboardtypesListVariableSpecDTO;
const listCommon: VariableFormModel = {
...common,
multiSelect: spec.allowMultiple ?? false,
showAllOption: spec.allowAllValue ?? false,
sort: (spec.sort as VariableSort) ?? 'DISABLED',
defaultValue: spec.defaultValue,
};
const plugin = spec.plugin;
if (plugin?.kind === CustomPluginKind['signoz/CustomVariable']) {
return {
...listCommon,
type: 'CUSTOM',
customValue: plugin.spec.customValue ?? '',
};
}
if (plugin?.kind === DynamicPluginKind['signoz/DynamicVariable']) {
return {
...listCommon,
type: 'DYNAMIC',
dynamicAttribute: plugin.spec.name ?? '',
dynamicSignal: (plugin.spec.signal as TelemetrySignal) ?? 'traces',
};
}
// Default to Query (also covers a query plugin or a missing/unknown plugin).
return {
...listCommon,
type: 'QUERY',
queryValue:
plugin?.kind === QueryPluginKind['signoz/QueryVariable']
? (plugin.spec.queryValue ?? '')
: '',
};
}
function buildPlugin(
model: VariableFormModel,
): DashboardtypesVariablePluginDTO {
switch (model.type) {
case 'CUSTOM':
return {
kind: CustomPluginKind['signoz/CustomVariable'],
spec: { customValue: model.customValue },
};
case 'DYNAMIC':
return {
kind: DynamicPluginKind['signoz/DynamicVariable'],
spec: {
name: model.dynamicAttribute,
signal: model.dynamicSignal as TelemetrytypesSignalDTO,
},
};
case 'QUERY':
default:
return {
kind: QueryPluginKind['signoz/QueryVariable'],
spec: { queryValue: model.queryValue },
};
}
}
/** Flat form model → DTO envelope (for persistence). */
export function formModelToDto(
model: VariableFormModel,
): DashboardtypesVariableDTO {
const display = {
name: model.name,
description: model.description,
hidden: model.hidden,
};
if (model.type === 'TEXT') {
return {
kind: TextEnvelopeKind.TextVariable,
spec: {
name: model.name,
display,
value: model.textValue,
constant: model.textConstant,
},
};
}
return {
kind: ListEnvelopeKind.ListVariable,
spec: {
name: model.name,
display,
allowMultiple: model.multiSelect,
allowAllValue: model.showAllOption,
sort: model.sort,
defaultValue: model.defaultValue,
plugin: buildPlugin(model),
},
};
}
/** Maps the V2 plugin/envelope to the four UI-facing variable types. */
export function variableTypeOf(
dto: DashboardtypesVariableDTO,
): VariableFormModel['type'] {
return dtoToFormModel(dto).type;
}
export { PLUGIN_KIND };

View File

@@ -1,78 +0,0 @@
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
/**
* Flat, UI-friendly representation of a V2 dashboard variable. The wire format
* (`DashboardtypesVariableDTO`) is a nested envelope/plugin union that is awkward
* to bind a form to; `variableAdapters` converts between this model and the DTO.
*/
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
export type VariableSort = 'DISABLED' | 'ASC' | 'DESC';
export type TelemetrySignal = 'traces' | 'logs' | 'metrics';
/** Wire `kind` discriminators (string values of the generated enums). */
export const ENVELOPE_KIND = {
LIST: 'ListVariable',
TEXT: 'TextVariable',
} as const;
export const PLUGIN_KIND = {
QUERY: 'signoz/QueryVariable',
CUSTOM: 'signoz/CustomVariable',
DYNAMIC: 'signoz/DynamicVariable',
} as const;
export const VARIABLE_SORTS: VariableSort[] = ['DISABLED', 'ASC', 'DESC'];
export const TELEMETRY_SIGNALS: TelemetrySignal[] = [
'traces',
'logs',
'metrics',
];
export interface VariableFormModel {
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
name: string;
description: string;
hidden: boolean;
type: VariableType;
// List-variable common fields (Query / Custom / Dynamic).
multiSelect: boolean;
showAllOption: boolean;
sort: VariableSort;
// Type-specific.
queryValue: string; // QUERY
customValue: string; // CUSTOM
textValue: string; // TEXT
textConstant: boolean; // TEXT
dynamicAttribute: string; // DYNAMIC — the telemetry field name
dynamicSignal: TelemetrySignal; // DYNAMIC — the telemetry signal
/**
* Runtime-selected default, not editable in the management tab yet; carried
* through edits so saving a definition doesn't clobber it.
*/
defaultValue?: VariableDefaultValueDTO;
}
export function emptyVariableFormModel(): VariableFormModel {
return {
name: '',
description: '',
hidden: false,
type: 'QUERY',
multiSelect: false,
showAllOption: false,
sort: 'DISABLED',
queryValue: '',
customValue: '',
textValue: '',
textConstant: false,
dynamicAttribute: '',
dynamicSignal: 'traces',
};
}

View File

@@ -1,22 +0,0 @@
import type {
DashboardtypesJSONPatchOperationDTO,
DashboardtypesVariableDTO,
} from 'api/generated/services/sigNoz.schemas';
/**
* Builds the JSON-Patch to persist the dashboard's variable list. Add/edit/
* delete/reorder all replace the whole `/spec/variables` array in one atomic op
* — simpler and race-free vs per-index patches. RFC-6902 `add` on an object
* member sets-or-replaces, so it works whether or not `variables` already exists.
*/
export function buildVariablesPatch(
variables: DashboardtypesVariableDTO[],
): DashboardtypesJSONPatchOperationDTO[] {
return [
{
op: 'add' as DashboardtypesJSONPatchOperationDTO['op'],
path: '/spec/variables',
value: variables,
},
];
}

View File

@@ -1,39 +1,48 @@
import { useMemo } from 'react';
import { Braces, Globe, Table } from '@signozhq/icons';
import { TabItemProps, Tabs } from '@signozhq/ui/tabs';
import { Tabs } from '@signozhq/ui/tabs';
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import GeneralSettings from './General';
import { SettingsTabPlaceholder } from './utils';
import VariablesSettings from './Variables';
import styles from './DashboardSettings.module.scss';
interface DashboardSettingsProps {
dashboard: DashboardtypesGettableDashboardV2DTO;
}
function tabLabel(icon: JSX.Element, text: string): JSX.Element {
return (
<span className={styles.tabLabel}>
{icon}
{text}
</span>
);
}
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
const items: TabItemProps[] = useMemo(
const items = useMemo(
() => [
{
key: 'general',
label: 'General',
label: tabLabel(<Table size={14} />, 'General'),
children: <GeneralSettings dashboard={dashboard} />,
prefixIcon: <Table size={14} />,
},
{
key: 'variables',
label: 'Variables',
children: <VariablesSettings dashboard={dashboard} />,
prefixIcon: <Braces size={14} />,
label: tabLabel(<Braces size={14} />, 'Variables'),
children: (
<SettingsTabPlaceholder message="V2 dashboard variables coming next." />
),
},
{
key: 'public-dashboard',
label: 'Publish',
label: tabLabel(<Globe size={14} />, 'Publish'),
children: (
<SettingsTabPlaceholder message="V2 public dashboard publishing coming next." />
),
prefixIcon: <Globe size={14} />,
},
],
[dashboard],

View File

@@ -1,12 +1,8 @@
.settings-page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
max-height: 100vh;
overflow: hidden;
.settings-page-header {
flex-shrink: 0;
border-bottom: 1px solid var(--l1-border);
background: var(--l1-background);
backdrop-filter: blur(20px);
@@ -28,14 +24,13 @@
}
.settings-page-content-container {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
align-items: stretch;
align-items: flex-start;
.settings-page-sidenav {
width: 240px;
height: calc(100vh - 48px);
border-right: 1px solid var(--l1-border);
background: var(--l1-background);
padding-top: var(--padding-1);
@@ -79,6 +74,7 @@
.settings-page-content {
flex: 1;
height: calc(100vh - 48px);
background: var(--l1-background);
padding: 10px 8px;
overflow-y: auto;

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import { Skeleton } from 'antd';
import useGetTraceFlamegraphV3 from 'hooks/trace/useGetTraceFlamegraphV3';
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
import useUrlQuery from 'hooks/useUrlQuery';
import { TraceDetailFlamegraphURLProps } from 'types/api/trace/getTraceFlamegraph';
import { SpanV3 } from 'types/api/trace/getTraceV3';
@@ -53,9 +53,6 @@ function TraceFlamegraph({
);
const previewFields = useTraceStore((s) => s.previewFields);
// Gate the fetch until prefs load, else selectFields (in the query key)
// repopulates and triggers a second fetch.
const userPrefsReady = useTraceStore((s) => s.userPreferences !== null);
// Color-by fields baseline + user-picked preview fields. De-duped by `name`,
// color-by entries first so their canonical metadata wins on collision.
@@ -73,14 +70,17 @@ function TraceFlamegraph({
data,
isFetching,
error: fetchError,
} = useGetTraceFlamegraphV3({
} = useGetTraceFlamegraph({
traceId,
selectedSpanId: selectedSpanIdForFetch,
limit: FLAMEGRAPH_SPAN_LIMIT,
selectFields: flamegraphSelectFields,
enabled: !!traceId && userPrefsReady,
});
const spans = useMemo(() => data?.spans || [], [data?.spans]);
const spans = useMemo(
() => data?.payload?.spans || [],
[data?.payload?.spans],
);
const {
layout,
@@ -99,8 +99,8 @@ function TraceFlamegraph({
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
onSpanClick={handleSpanClick}
traceMetadata={{
startTime: data?.startTimestampMillis || 0,
endTime: data?.endTimestampMillis || 0,
startTime: data?.payload?.startTimestampMillis || 0,
endTime: data?.payload?.endTimestampMillis || 0,
}}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
@@ -124,7 +124,7 @@ function TraceFlamegraph({
if (fetchError || workerError) {
return <Error error={(fetchError || workerError) as any} />;
}
if (data?.spans && data.spans.length === 0) {
if (data?.payload?.spans && data.payload.spans.length === 0) {
return <div>No data found for trace {traceId}</div>;
}
return (
@@ -134,17 +134,17 @@ function TraceFlamegraph({
setFirstSpanAtFetchLevel={setFirstSpanAtFetchLevel}
onSpanClick={handleSpanClick}
traceMetadata={{
startTime: data?.startTimestampMillis || 0,
endTime: data?.endTimestampMillis || 0,
startTime: data?.payload?.startTimestampMillis || 0,
endTime: data?.payload?.endTimestampMillis || 0,
}}
filteredSpanIds={filteredSpanIds}
isFilterActive={isFilterActive}
/>
);
}, [
data?.endTimestampMillis,
data?.startTimestampMillis,
data?.spans,
data?.payload?.endTimestampMillis,
data?.payload?.startTimestampMillis,
data?.payload?.spans,
fetchError,
filteredSpanIds,
firstSpanAtFetchLevel,

View File

@@ -1,12 +1,12 @@
import { render } from '@testing-library/react';
import useGetTraceFlamegraphV3 from 'hooks/trace/useGetTraceFlamegraphV3';
import useGetTraceFlamegraph from 'hooks/trace/useGetTraceFlamegraph';
import { AllTheProviders } from 'tests/test-utils';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { FLAMEGRAPH_SPAN_LIMIT } from '../constants';
import TraceFlamegraph from '../TraceFlamegraph';
jest.mock('hooks/trace/useGetTraceFlamegraphV3');
jest.mock('hooks/trace/useGetTraceFlamegraph');
// Short-circuit the worker so the test doesn't depend on layout computation.
jest.mock('../hooks/useVisualLayoutWorker', () => ({
@@ -17,8 +17,9 @@ jest.mock('../hooks/useVisualLayoutWorker', () => ({
}),
}));
const mockUseGetTraceFlamegraph =
useGetTraceFlamegraphV3 as jest.MockedFunction<typeof useGetTraceFlamegraphV3>;
const mockUseGetTraceFlamegraph = useGetTraceFlamegraph as jest.MockedFunction<
typeof useGetTraceFlamegraph
>;
function renderFlamegraph(props: {
selectedSpan: SpanV3 | undefined;
@@ -44,7 +45,7 @@ describe('TraceFlamegraph - selectedSpanId pass-through', () => {
beforeEach(() => {
mockUseGetTraceFlamegraph.mockReset();
mockUseGetTraceFlamegraph.mockReturnValue({
data: { spans: [] },
data: { payload: { spans: [] } },
isFetching: false,
error: null,
} as never);

View File

@@ -1,4 +1,4 @@
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import {
computeVisualLayout,
@@ -14,12 +14,12 @@ function makeSpan(
): FlamegraphSpan {
return {
parentSpanId: '',
traceId: 'trace-1',
hasError: false,
serviceName: 'svc',
name: 'op',
level: 0,
event: [],
resource: {},
attributes: {},
...overrides,
};
}

View File

@@ -1,4 +1,4 @@
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
/** Minimal FlamegraphSpan for unit tests */
export const MOCK_SPAN: FlamegraphSpan = {
@@ -6,12 +6,12 @@ export const MOCK_SPAN: FlamegraphSpan = {
durationNano: 50_000_000, // 50ms
spanId: 'span-1',
parentSpanId: '',
traceId: 'trace-1',
hasError: false,
serviceName: 'test-service',
name: 'test-span',
level: 0,
event: [],
resource: {},
attributes: {},
};
/** Nested spans structure for findSpanById tests */

View File

@@ -65,25 +65,37 @@ describe('Presentation / Styling Utils', () => {
describe('getFlamegraphSpanGroupValue', () => {
it('returns resource[field.name] when present', () => {
const value = getFlamegraphSpanGroupValue(
{ resource: { 'service.name': 'svc-from-resource' } },
{
serviceName: 'legacy',
resource: { 'service.name': 'svc-from-resource' },
},
SERVICE_FIELD,
);
expect(value).toBe('svc-from-resource');
});
it('returns "unknown" for service.name when resource is empty', () => {
const value = getFlamegraphSpanGroupValue({ resource: {} }, SERVICE_FIELD);
expect(value).toBe('unknown');
it('falls back to top-level serviceName for service.name when resource is empty', () => {
const value = getFlamegraphSpanGroupValue(
{ serviceName: 'svc-legacy', resource: {} },
SERVICE_FIELD,
);
expect(value).toBe('svc-legacy');
});
it('returns "unknown" for non-service fields when resource is missing', () => {
const value = getFlamegraphSpanGroupValue({ resource: {} }, HOST_FIELD);
const value = getFlamegraphSpanGroupValue(
{ serviceName: 'svc', resource: {} },
HOST_FIELD,
);
expect(value).toBe('unknown');
});
it('reads host.name from resource when present', () => {
const value = getFlamegraphSpanGroupValue(
{ resource: { 'host.name': 'host-1' } },
{
serviceName: 'svc',
resource: { 'host.name': 'host-1' },
},
HOST_FIELD,
);
expect(value).toBe('host-1');

View File

@@ -1,10 +1,11 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
export interface ConnectorLine {
parentRow: number;
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>;
@@ -158,8 +159,24 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
}
}
// Extract parentSpanId — the field may be missing at runtime when the API
// returns `references` instead. Fall back to the first CHILD_OF reference.
function getParentId(span: FlamegraphSpan): string {
return span.parentSpanId || '';
if (span.parentSpanId) {
return span.parentSpanId;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const refs = (span as any).references as
| Array<{ spanId?: string; refType?: string }>
| undefined;
if (refs) {
for (const ref of refs) {
if (ref.refType === 'CHILD_OF' && ref.spanId) {
return ref.spanId;
}
}
}
return '';
}
// Build children map and identify roots
@@ -463,6 +480,7 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
parentRow,
childRow,
timestampMs: child.timestamp,
serviceName: child.serviceName,
resource: child.resource,
});
}

View File

@@ -1,7 +1,7 @@
import React, { RefObject, useCallback, useMemo, useRef } from 'react';
import { generateColorPair } from 'pages/TraceDetailsV3/utils/generateColorPair';
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { ConnectorLine } from '../computeVisualLayout';
@@ -200,7 +200,7 @@ function drawConnectorLines(args: DrawConnectorLinesArgs): void {
}
const groupValue = getFlamegraphSpanGroupValue(
{ resource: conn.resource },
{ serviceName: conn.serviceName, resource: conn.resource },
colorByField,
);
const pair = generateColorPair(groupValue);

View File

@@ -11,9 +11,10 @@ import {
import { useTraceStore } from 'pages/TraceDetailsV3/stores/traceStore';
import { RESERVED_PREVIEW_KEYS } from 'pages/TraceDetailsV3/SpanHoverCard/SpanHoverCard';
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { EventRect, ITraceMetadata, SpanRect } from '../types';
import { EventRect, SpanRect } from '../types';
import { ITraceMetadata } from '../types';
import {
getFlamegraphServiceName,
getFlamegraphSpanGroupValue,
@@ -199,7 +200,7 @@ export function useFlamegraphHover(
if (eventRect) {
const { event, span } = eventRect;
const eventTimeMs = (event.timeUnixNano ?? 0) / 1e6;
const eventTimeMs = event.timeUnixNano / 1e6;
setHoveredEventKey(`${span.spanId}-${event.name}-${event.timeUnixNano}`);
setHoveredSpanId(span.spanId);
setTooltipContent({
@@ -219,10 +220,10 @@ export function useFlamegraphHover(
return isDarkMode ? pair.color : pair.colorDark;
})(),
event: {
name: event.name ?? '',
name: event.name,
timeOffsetMs: eventTimeMs - span.timestamp,
isError: event.isError ?? false,
attributeMap: (event.attributeMap as Record<string, string>) ?? {},
isError: event.isError,
attributeMap: event.attributeMap || {},
},
});
updateCursor(canvas, eventRect.span);

View File

@@ -5,7 +5,7 @@ import {
SetStateAction,
useEffect,
} from 'react';
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { MIN_VISIBLE_SPAN_MS } from '../constants';
import { ITraceMetadata } from '../types';

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { computeVisualLayout, VisualLayout } from '../computeVisualLayout';
import { LayoutWorkerResponse } from '../visualLayoutWorkerTypes';

View File

@@ -1,8 +1,5 @@
import {
SpantypesEventDTO as FlamegraphEvent,
SpantypesFlamegraphSpanDTO as FlamegraphSpan,
} from 'api/generated/services/sigNoz.schemas';
import { Dispatch, SetStateAction } from 'react';
import { Event, FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { VisualLayout } from './computeVisualLayout';
@@ -31,7 +28,7 @@ export interface SpanRect {
}
export interface EventRect {
event: FlamegraphEvent;
event: Event;
span: FlamegraphSpan;
cx: number;
cy: number;

View File

@@ -7,7 +7,7 @@ import {
generateColorPair,
RESERVED_ERROR,
} from 'pages/TraceDetailsV3/utils/generateColorPair';
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import {
@@ -74,25 +74,34 @@ export function getFlamegraphRowMetrics(
/**
* Resolve the displayed service.name for a flamegraph span. Used by tooltips
* (service identity, independent of the active colour-by field). Reads
* `resource['service.name']`.
* (service identity, independent of the active colour-by field). Prefers
* `resource['service.name']` with legacy top-level `serviceName` fallback.
*/
export function getFlamegraphServiceName(
span: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
span: Pick<FlamegraphSpan, 'serviceName' | 'resource' | 'attributes'>,
): string {
return getSpanAttribute(span, 'service.name') || '';
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]` (contract from `selectFields`), falling
* back to `'unknown'`.
* 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: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
span: Pick<FlamegraphSpan, 'serviceName' | 'resource' | 'attributes'>,
field: TelemetryFieldKey,
): string {
return getSpanAttribute(span, field.name) || 'unknown';
const fromAttribute = getSpanAttribute(span, field.name);
if (fromAttribute) {
return fromAttribute;
}
if (field.name === 'service.name') {
return span.serviceName || 'unknown';
}
return 'unknown';
}
interface GetSpanColorArgs {
@@ -287,7 +296,7 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
return;
}
const eventTimeMs = (event.timeUnixNano ?? 0) / 1e6;
const eventTimeMs = event.timeUnixNano / 1e6;
const eventOffsetPercent =
((eventTimeMs - span.timestamp) / spanDurationMs) * 100;
const clampedOffset = clamp(eventOffsetPercent, 1, 99);
@@ -297,11 +306,7 @@ export function drawSpanBar(args: DrawSpanBarArgs): void {
// Event dots derive from the effective bar color so they track the
// light/dark variant the bar is rendered with.
const parentBarColor = isDarkMode ? color : colorDark;
const dotColor = getEventDotColor(
parentBarColor,
event.isError ?? false,
isDarkMode,
);
const dotColor = getEventDotColor(parentBarColor, event.isError, isDarkMode);
const eventKey = `${span.spanId}-${event.name}-${event.timeUnixNano}`;
const isEventHovered = hoveredEventKey === eventKey;
const dotSize = isEventHovered

View File

@@ -1,4 +1,4 @@
import { SpantypesFlamegraphSpanDTO as FlamegraphSpan } from 'api/generated/services/sigNoz.schemas';
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import { VisualLayout } from './computeVisualLayout';

View File

@@ -19,14 +19,17 @@ import {
} from 'components/CustomTimePicker/timezoneUtils';
import { LOCALSTORAGE } from 'constants/localStorage';
import useTimezoneFormatter, {
FormatTimezoneAdjustedTimestamp,
TimestampInput,
} from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
export interface TimezoneContextType {
timezone: Timezone;
browserTimezone: Timezone;
updateTimezone: (timezone: Timezone) => void;
formatTimezoneAdjustedTimestamp: FormatTimezoneAdjustedTimestamp;
formatTimezoneAdjustedTimestamp: (
input: TimestampInput,
format?: string,
) => string;
isAdaptationEnabled: boolean;
setIsAdaptationEnabled: Dispatch<SetStateAction<boolean>>;
}

View File

@@ -2,7 +2,6 @@ package sqlalertmanagerstore
import (
"context"
"encoding/json"
"log/slog"
"time"
@@ -40,20 +39,16 @@ func (r *maintenance) ListPlannedMaintenance(ctx context.Context, orgID string)
return nil, err
}
plannedMaintenances := make([]*alertmanagertypes.PlannedMaintenance, 0, len(gettableMaintenancesRules))
gettablePlannedMaintenance := make([]*alertmanagertypes.PlannedMaintenance, 0)
for _, gettableMaintenancesRule := range gettableMaintenancesRules {
pm, err := gettableMaintenancesRule.ToPlannedMaintenance()
if err != nil {
// Don't return an error because we want to process all the valid records.
// Log and skip instead.
r.logger.WarnContext(ctx, "skipping planned maintenance", slog.String("maintenance_id", gettableMaintenancesRule.ID.StringValue()), errors.Attr(err))
continue
m := gettableMaintenancesRule.ToPlannedMaintenance()
gettablePlannedMaintenance = append(gettablePlannedMaintenance, m)
if m.HasScheduleRecurrenceBoundsMismatch() {
r.logger.WarnContext(ctx, "planned_downtime_recurrence_schedule_mismatch", slog.String("maintenance_id", m.ID.StringValue()))
}
plannedMaintenances = append(plannedMaintenances, pm)
}
return plannedMaintenances, nil
return gettablePlannedMaintenance, nil
}
func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.UUID) (*alertmanagertypes.PlannedMaintenance, error) {
@@ -69,7 +64,7 @@ func (r *maintenance) GetPlannedMaintenanceByID(ctx context.Context, id valuer.U
return nil, r.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "planned maintenance with ID: %s does not exist", id.StringValue())
}
return storableMaintenanceRule.ToPlannedMaintenance()
return storableMaintenanceRule.ToPlannedMaintenance(), nil
}
func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance *alertmanagertypes.PostablePlannedMaintenance) (*alertmanagertypes.PlannedMaintenance, error) {
@@ -78,11 +73,6 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
return nil, err
}
schedule, err := json.Marshal(maintenance.Schedule)
if err != nil {
return nil, err
}
storablePlannedMaintenance := alertmanagertypes.StorablePlannedMaintenance{
Identifiable: types.Identifiable{
ID: valuer.GenerateUUID(),
@@ -97,7 +87,7 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
},
Name: maintenance.Name,
Description: maintenance.Description,
Schedule: string(schedule),
Schedule: maintenance.Schedule,
OrgID: claims.OrgID,
Scope: maintenance.Scope,
}
@@ -145,47 +135,32 @@ func (r *maintenance) CreatePlannedMaintenance(ctx context.Context, maintenance
return nil, err
}
pm := &alertmanagertypes.PlannedMaintenance{
return &alertmanagertypes.PlannedMaintenance{
ID: storablePlannedMaintenance.ID,
Name: storablePlannedMaintenance.Name,
Description: storablePlannedMaintenance.Description,
Schedule: storablePlannedMaintenance.Schedule,
RuleIDs: maintenance.AlertIds,
Scope: maintenance.Scope,
CreatedAt: storablePlannedMaintenance.CreatedAt,
CreatedBy: storablePlannedMaintenance.CreatedBy,
UpdatedAt: storablePlannedMaintenance.UpdatedAt,
UpdatedBy: storablePlannedMaintenance.UpdatedBy,
}
if err = json.Unmarshal([]byte(storablePlannedMaintenance.Schedule), &pm.Schedule); err != nil {
return nil, err
}
return pm, nil
}, nil
}
func (r *maintenance) DeletePlannedMaintenance(ctx context.Context, id valuer.UUID) error {
return r.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
_, err := r.sqlstore.
BunDBCtx(ctx).
NewDelete().
Model(new(alertmanagertypes.StorablePlannedMaintenanceRule)).
Where("planned_maintenance_id = ?", id.StringValue()).
Exec(ctx)
if err != nil {
return err
}
_, err := r.sqlstore.
BunDB().
NewDelete().
Model(new(alertmanagertypes.StorablePlannedMaintenance)).
Where("id = ?", id.StringValue()).
Exec(ctx)
if err != nil {
return r.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "cannot delete planned maintenance because it is referenced by associated rules, remove the rules from the planned maintenance first")
}
_, err = r.sqlstore.
BunDBCtx(ctx).
NewDelete().
Model(new(alertmanagertypes.StorablePlannedMaintenance)).
Where("id = ?", id.StringValue()).
Exec(ctx)
if err != nil {
return err
}
return nil
})
return nil
}
func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance *alertmanagertypes.PostablePlannedMaintenance, id valuer.UUID) error {
@@ -199,11 +174,6 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
return err
}
schedule, err := json.Marshal(maintenance.Schedule)
if err != nil {
return err
}
storablePlannedMaintenance := alertmanagertypes.StorablePlannedMaintenance{
Identifiable: types.Identifiable{
ID: id,
@@ -218,7 +188,7 @@ func (r *maintenance) UpdatePlannedMaintenance(ctx context.Context, maintenance
},
Name: maintenance.Name,
Description: maintenance.Description,
Schedule: string(schedule),
Schedule: maintenance.Schedule,
OrgID: claims.OrgID,
Scope: maintenance.Scope,
}

View File

@@ -1,94 +0,0 @@
package sqlalertmanagerstore
import (
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func newTestStore(t *testing.T) sqlstore.SQLStore {
t.Helper()
store, err := sqlitesqlstore.New(t.Context(), factorytest.NewSettings(), sqlstore.Config{
Provider: "sqlite",
Connection: sqlstore.ConnectionConfig{
MaxOpenConns: 1,
MaxConnLifetime: 0,
},
Sqlite: sqlstore.SqliteConfig{
Path: filepath.Join(t.TempDir(), "test.db"),
Mode: "wal",
BusyTimeout: 5 * time.Second,
TransactionMode: "deferred",
},
})
require.NoError(t, err)
_, err = store.BunDB().NewCreateTable().
Model((*alertmanagertypes.StorablePlannedMaintenance)(nil)).
IfNotExists().
Exec(t.Context())
require.NoError(t, err)
_, err = store.BunDB().NewCreateTable().
Model((*alertmanagertypes.StorablePlannedMaintenanceRule)(nil)).
IfNotExists().
Exec(t.Context())
require.NoError(t, err)
return store
}
// TestListPlannedMaintenanceSkipsInvalid asserts that a single corrupt record
// (here, an unloadable timezone) is skipped rather than failing the whole list.
func TestListPlannedMaintenanceSkipsInvalid(t *testing.T) {
store := newTestStore(t)
orgID := valuer.GenerateUUID().StringValue()
now := time.Now().UTC()
valid := &alertmanagertypes.StorablePlannedMaintenance{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
Name: "valid",
Schedule: `{"timezone":"UTC","startTime":"2024-01-01T12:00:00Z","recurrence":{"duration":"2h","repeatType":"daily"}}`,
OrgID: orgID,
}
result, err := store.BunDB().NewInsert().Model(valid).Exec(t.Context())
require.NoError(t, err)
rowsAffected, err := result.RowsAffected()
require.NoError(t, err)
require.Equal(t, int64(1), rowsAffected)
// A schedule with "zero" startTime
invalid := &alertmanagertypes.StorablePlannedMaintenance{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{
CreatedAt: now,
UpdatedAt: now,
},
Name: "invalid",
Schedule: `{"timezone":"UTC","recurrence":{"duration":"2h","repeatType":"daily"}}`,
OrgID: orgID,
}
result, err = store.BunDB().NewInsert().Model(invalid).Exec(t.Context())
require.NoError(t, err)
rowsAffected, err = result.RowsAffected()
require.NoError(t, err)
require.Equal(t, int64(1), rowsAffected)
maintenanceStore := NewMaintenanceStore(store, factorytest.NewSettings())
list, err := maintenanceStore.ListPlannedMaintenance(t.Context(), orgID)
require.NoError(t, err)
require.Len(t, list, 1)
assert.Equal(t, valid.ID, list[0].ID)
}

View File

@@ -3,8 +3,6 @@ package alertmanager
import (
"context"
"net/url"
"os"
"strings"
"testing"
"time"
@@ -16,23 +14,7 @@ import (
"github.com/stretchr/testify/require"
)
const prefix = "SIGNOZ_"
// clearSignozEnv unsets all existing SIGNOZ_* env vars for the duration of the test.
func clearSignozEnv(t *testing.T) {
t.Helper()
for _, kv := range os.Environ() {
if strings.HasPrefix(kv, prefix) {
key := strings.SplitN(kv, "=", 2)[0]
orig, _ := os.LookupEnv(key)
_ = os.Unsetenv(key)
t.Cleanup(func() { _ = os.Setenv(key, orig) })
}
}
}
func TestNewWithEnvProvider(t *testing.T) {
clearSignozEnv(t)
t.Setenv("SIGNOZ_ALERTMANAGER_PROVIDER", "signoz")
t.Setenv("SIGNOZ_ALERTMANAGER_LEGACY_API__URL", "http://localhost:9093/api")
t.Setenv("SIGNOZ_ALERTMANAGER_SIGNOZ_ROUTE_REPEAT__INTERVAL", "5m")

View File

@@ -18,8 +18,8 @@ func clearSignozEnv(t *testing.T) {
if strings.HasPrefix(kv, prefix) {
key := strings.SplitN(kv, "=", 2)[0]
orig, _ := os.LookupEnv(key)
_ = os.Unsetenv(key)
t.Cleanup(func() { _ = os.Setenv(key, orig) })
os.Unsetenv(key)
t.Cleanup(func() { os.Setenv(key, orig) })
}
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18"><defs><linearGradient id="f67d1585-6164-4ad0-b2dd-f9cc59b2969f" x1="9.908" y1="15.943" x2="7.516" y2="2.383" gradientUnits="userSpaceOnUse"><stop offset="0.15" stop-color="#0078d4"/><stop offset="0.8" stop-color="#5ea0ef"/><stop offset="1" stop-color="#83b9f9"/></linearGradient></defs><g id="a4fd1868-54fe-4ca6-8ff6-3b01866dc27b"><path d="M14.49,7.15A5.147,5.147,0,0,0,9.24,2.164,5.272,5.272,0,0,0,4.216,5.653,4.869,4.869,0,0,0,0,10.4a4.946,4.946,0,0,0,5.068,4.814H13.82A4.292,4.292,0,0,0,18,11.127,4.105,4.105,0,0,0,14.49,7.15Z" fill="url(#f67d1585-6164-4ad0-b2dd-f9cc59b2969f)"/><path d="M12.9,11.4V8H12v4.13h2.46V11.4ZM5.76,9.73a1.825,1.825,0,0,1-.51-.31.441.441,0,0,1-.12-.32.342.342,0,0,1,.15-.3.683.683,0,0,1,.42-.12,1.62,1.62,0,0,1,1,.29V8.11a2.58,2.58,0,0,0-1-.16,1.641,1.641,0,0,0-1.09.34,1.08,1.08,0,0,0-.42.89c0,.51.32.91,1,1.21a2.907,2.907,0,0,1,.62.36.419.419,0,0,1,.15.32.381.381,0,0,1-.16.31.806.806,0,0,1-.45.11,1.66,1.66,0,0,1-1.09-.42V12a2.173,2.173,0,0,0,1.07.24,1.877,1.877,0,0,0,1.18-.33A1.08,1.08,0,0,0,6.84,11a1.048,1.048,0,0,0-.25-.7A2.425,2.425,0,0,0,5.76,9.73ZM11,11.32A2.191,2.191,0,0,0,11,9a1.808,1.808,0,0,0-.7-.75,2,2,0,0,0-1-.26,2.112,2.112,0,0,0-1.08.27A1.856,1.856,0,0,0,7.49,9a2.465,2.465,0,0,0-.26,1.14,2.256,2.256,0,0,0,.24,1,1.766,1.766,0,0,0,.69.74,2.056,2.056,0,0,0,1,.3l.86,1h1.21L10,12.08A1.79,1.79,0,0,0,11,11.32Zm-1-.25a.941.941,0,0,1-.76.35.916.916,0,0,1-.76-.36,1.523,1.523,0,0,1-.29-1,1.529,1.529,0,0,1,.29-1,1,1,0,0,1,.78-.37.869.869,0,0,1,.75.37,1.619,1.619,0,0,1,.27,1A1.459,1.459,0,0,1,10,11.07Z" fill="#f2f2f2"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,565 @@
{
"id": "sqldatabase",
"title": "Azure SQL Database",
"icon": "file://icon.svg",
"overview": "file://overview.md",
"supportedSignals": {
"metrics": true,
"logs": true
},
"dataCollected": {
"metrics": [
{
"name": "azure_cpu_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Percentage of CPU used by the database workload, relative to its limit."
},
{
"name": "azure_cpu_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Percentage of CPU used by the database workload, relative to its limit."
},
{
"name": "azure_cpu_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Percentage of CPU used by the database workload, relative to its limit."
},
{
"name": "azure_sql_instance_cpu_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Total CPU usage (user plus system) of the SQL instance, as a percentage."
},
{
"name": "azure_sql_instance_cpu_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Total CPU usage (user plus system) of the SQL instance, as a percentage."
},
{
"name": "azure_sql_instance_cpu_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Total CPU usage (user plus system) of the SQL instance, as a percentage."
},
{
"name": "azure_sql_instance_memory_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Memory usage of the SQL instance, as a percentage of its limit."
},
{
"name": "azure_sql_instance_memory_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Memory usage of the SQL instance, as a percentage of its limit."
},
{
"name": "azure_sql_instance_memory_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Memory usage of the SQL instance, as a percentage of its limit."
},
{
"name": "azure_physical_data_read_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Data file IO usage as a percentage of the limit."
},
{
"name": "azure_physical_data_read_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Data file IO usage as a percentage of the limit."
},
{
"name": "azure_physical_data_read_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Data file IO usage as a percentage of the limit."
},
{
"name": "azure_log_write_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Transaction log write throughput as a percentage of the limit."
},
{
"name": "azure_log_write_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Transaction log write throughput as a percentage of the limit."
},
{
"name": "azure_log_write_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Transaction log write throughput as a percentage of the limit."
},
{
"name": "azure_workers_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Worker threads in use as a percentage of the limit."
},
{
"name": "azure_workers_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Worker threads in use as a percentage of the limit."
},
{
"name": "azure_workers_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Worker threads in use as a percentage of the limit."
},
{
"name": "azure_sessions_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Active sessions as a percentage of the limit."
},
{
"name": "azure_sessions_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Active sessions as a percentage of the limit."
},
{
"name": "azure_sessions_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Active sessions as a percentage of the limit."
},
{
"name": "azure_sessions_count_average",
"unit": "Count",
"type": "Gauge",
"description": "Number of active sessions."
},
{
"name": "azure_sessions_count_maximum",
"unit": "Count",
"type": "Gauge",
"description": "Number of active sessions."
},
{
"name": "azure_sessions_count_minimum",
"unit": "Count",
"type": "Gauge",
"description": "Number of active sessions."
},
{
"name": "azure_dtu_consumption_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "DTU consumption as a percentage of the limit (DTU-based purchasing model)."
},
{
"name": "azure_dtu_consumption_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "DTU consumption as a percentage of the limit (DTU-based purchasing model)."
},
{
"name": "azure_dtu_consumption_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "DTU consumption as a percentage of the limit (DTU-based purchasing model)."
},
{
"name": "azure_dtu_used_average",
"unit": "Count",
"type": "Gauge",
"description": "DTUs used (DTU-based purchasing model)."
},
{
"name": "azure_dtu_used_maximum",
"unit": "Count",
"type": "Gauge",
"description": "DTUs used (DTU-based purchasing model)."
},
{
"name": "azure_dtu_used_minimum",
"unit": "Count",
"type": "Gauge",
"description": "DTUs used (DTU-based purchasing model)."
},
{
"name": "azure_dtu_limit_average",
"unit": "Count",
"type": "Gauge",
"description": "DTU limit (DTU-based purchasing model)."
},
{
"name": "azure_dtu_limit_maximum",
"unit": "Count",
"type": "Gauge",
"description": "DTU limit (DTU-based purchasing model)."
},
{
"name": "azure_dtu_limit_minimum",
"unit": "Count",
"type": "Gauge",
"description": "DTU limit (DTU-based purchasing model)."
},
{
"name": "azure_cpu_used_average",
"unit": "Count",
"type": "Gauge",
"description": "vCores used (vCore-based purchasing model)."
},
{
"name": "azure_cpu_used_maximum",
"unit": "Count",
"type": "Gauge",
"description": "vCores used (vCore-based purchasing model)."
},
{
"name": "azure_cpu_used_minimum",
"unit": "Count",
"type": "Gauge",
"description": "vCores used (vCore-based purchasing model)."
},
{
"name": "azure_cpu_limit_average",
"unit": "Count",
"type": "Gauge",
"description": "vCore limit (vCore-based purchasing model)."
},
{
"name": "azure_cpu_limit_maximum",
"unit": "Count",
"type": "Gauge",
"description": "vCore limit (vCore-based purchasing model)."
},
{
"name": "azure_cpu_limit_minimum",
"unit": "Count",
"type": "Gauge",
"description": "vCore limit (vCore-based purchasing model)."
},
{
"name": "azure_storage_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Data space used by the database."
},
{
"name": "azure_storage_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": "Data space used by the database."
},
{
"name": "azure_storage_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": "Data space used by the database."
},
{
"name": "azure_storage_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "Data space used as a percentage of the maximum data size."
},
{
"name": "azure_storage_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Data space used as a percentage of the maximum data size."
},
{
"name": "azure_storage_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Data space used as a percentage of the maximum data size."
},
{
"name": "azure_allocated_data_storage_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Data space allocated to the database (includes unused space)."
},
{
"name": "azure_allocated_data_storage_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": "Data space allocated to the database (includes unused space)."
},
{
"name": "azure_allocated_data_storage_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": "Data space allocated to the database (includes unused space)."
},
{
"name": "azure_xtp_storage_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "In-Memory OLTP storage used as a percentage of the limit."
},
{
"name": "azure_xtp_storage_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "In-Memory OLTP storage used as a percentage of the limit."
},
{
"name": "azure_xtp_storage_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "In-Memory OLTP storage used as a percentage of the limit."
},
{
"name": "azure_full_backup_size_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Cumulative full backup storage size."
},
{
"name": "azure_full_backup_size_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": "Cumulative full backup storage size."
},
{
"name": "azure_full_backup_size_bytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": "Cumulative full backup storage size."
},
{
"name": "azure_diff_backup_size_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Cumulative differential backup storage size."
},
{
"name": "azure_diff_backup_size_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": "Cumulative differential backup storage size."
},
{
"name": "azure_diff_backup_size_bytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": "Cumulative differential backup storage size."
},
{
"name": "azure_log_backup_size_bytes_average",
"unit": "Bytes",
"type": "Gauge",
"description": "Cumulative transaction log backup storage size."
},
{
"name": "azure_log_backup_size_bytes_maximum",
"unit": "Bytes",
"type": "Gauge",
"description": "Cumulative transaction log backup storage size."
},
{
"name": "azure_log_backup_size_bytes_minimum",
"unit": "Bytes",
"type": "Gauge",
"description": "Cumulative transaction log backup storage size."
},
{
"name": "azure_replication_lag_seconds_average",
"unit": "Seconds",
"type": "Gauge",
"description": "Geo-replication lag (RPO) in seconds; reported on the primary database only."
},
{
"name": "azure_replication_lag_seconds_maximum",
"unit": "Seconds",
"type": "Gauge",
"description": "Geo-replication lag (RPO) in seconds; reported on the primary database only."
},
{
"name": "azure_replication_lag_seconds_minimum",
"unit": "Seconds",
"type": "Gauge",
"description": "Geo-replication lag (RPO) in seconds; reported on the primary database only."
},
{
"name": "azure_connection_successful_total",
"unit": "Count",
"type": "Sum",
"description": "Number of successful connections."
},
{
"name": "azure_connection_successful_count",
"unit": "Count",
"type": "Gauge",
"description": "Number of successful connections."
},
{
"name": "azure_connection_failed_total",
"unit": "Count",
"type": "Sum",
"description": "Number of failed connections caused by system errors."
},
{
"name": "azure_connection_failed_count",
"unit": "Count",
"type": "Gauge",
"description": "Number of failed connections caused by system errors."
},
{
"name": "azure_connection_failed_user_error_total",
"unit": "Count",
"type": "Sum",
"description": "Number of failed connections caused by user errors."
},
{
"name": "azure_connection_failed_user_error_count",
"unit": "Count",
"type": "Gauge",
"description": "Number of failed connections caused by user errors."
},
{
"name": "azure_blocked_by_firewall_total",
"unit": "Count",
"type": "Sum",
"description": "Number of connection attempts blocked by the firewall."
},
{
"name": "azure_blocked_by_firewall_count",
"unit": "Count",
"type": "Gauge",
"description": "Number of connection attempts blocked by the firewall."
},
{
"name": "azure_deadlock_total",
"unit": "Count",
"type": "Sum",
"description": "Number of deadlocks."
},
{
"name": "azure_deadlock_count",
"unit": "Count",
"type": "Gauge",
"description": "Number of deadlocks."
},
{
"name": "azure_availability_average",
"unit": "Percent",
"type": "Gauge",
"description": "Database availability percentage (100 if connections succeed, 0 if all fail) per minute."
},
{
"name": "azure_availability_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "Database availability percentage (100 if connections succeed, 0 if all fail) per minute."
},
{
"name": "azure_availability_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "Database availability percentage (100 if connections succeed, 0 if all fail) per minute."
},
{
"name": "azure_availability_count",
"unit": "Percent",
"type": "Gauge",
"description": "Database availability percentage (100 if connections succeed, 0 if all fail) per minute."
},
{
"name": "azure_availability_total",
"unit": "Percent",
"type": "Sum",
"description": "Database availability percentage (100 if connections succeed, 0 if all fail) per minute."
},
{
"name": "azure_tempdb_data_size_average",
"unit": "Count",
"type": "Gauge",
"description": "Space used in tempdb data files, in kilobytes."
},
{
"name": "azure_tempdb_data_size_maximum",
"unit": "Count",
"type": "Gauge",
"description": "Space used in tempdb data files, in kilobytes."
},
{
"name": "azure_tempdb_data_size_minimum",
"unit": "Count",
"type": "Gauge",
"description": "Space used in tempdb data files, in kilobytes."
},
{
"name": "azure_tempdb_log_size_average",
"unit": "Count",
"type": "Gauge",
"description": "Space used in the tempdb transaction log file, in kilobytes."
},
{
"name": "azure_tempdb_log_size_maximum",
"unit": "Count",
"type": "Gauge",
"description": "Space used in the tempdb transaction log file, in kilobytes."
},
{
"name": "azure_tempdb_log_size_minimum",
"unit": "Count",
"type": "Gauge",
"description": "Space used in the tempdb transaction log file, in kilobytes."
},
{
"name": "azure_tempdb_log_used_percent_average",
"unit": "Percent",
"type": "Gauge",
"description": "tempdb transaction log space used as a percentage."
},
{
"name": "azure_tempdb_log_used_percent_maximum",
"unit": "Percent",
"type": "Gauge",
"description": "tempdb transaction log space used as a percentage."
},
{
"name": "azure_tempdb_log_used_percent_minimum",
"unit": "Percent",
"type": "Gauge",
"description": "tempdb transaction log space used as a percentage."
}
],
"logs": [
{
"name": "Resource ID",
"path": "resources.azure.resource.id",
"type": "string"
}
]
},
"telemetryCollectionStrategy": {
"azure": {
"resourceProvider": "Microsoft.Sql",
"resourceType": "servers/databases",
"metrics": {},
"logs": {
"categoryGroups": [
"allLogs"
]
}
}
},
"assets": {
"dashboards": [
{
"id": "overview",
"title": "Azure SQL Database Overview",
"description": "Overview of Azure SQL Database metrics",
"definition": "file://assets/dashboards/overview.json"
}
]
}
}

View File

@@ -0,0 +1,7 @@
### Monitor Azure SQL Database with SigNoz
Collect key Azure SQL Database (single database) metrics and view them with an out of the box dashboard.
This integration collects platform metrics for the `Microsoft.Sql/servers/databases` resource type.
Note: This integration is for Azure SQL Database (the PaaS offering). Azure SQL Managed Instance and SQL Server on Azure VMs expose a different set of metrics and are not covered here.

View File

@@ -10,9 +10,7 @@ import (
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/SigNoz/signoz/pkg/types/instrumentationtypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
@@ -50,8 +48,6 @@ func NewModule(
}
func (m *module) ListHosts(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableHosts) (*inframonitoringtypes.Hosts, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListHosts")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -165,8 +161,6 @@ func (m *module) ListHosts(ctx context.Context, orgID valuer.UUID, req *inframon
}
func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostablePods) (*inframonitoringtypes.Pods, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListPods")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -250,8 +244,6 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
}
func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (*inframonitoringtypes.Nodes, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListNodes")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -340,8 +332,6 @@ func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframon
}
func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNamespaces) (*inframonitoringtypes.Namespaces, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListNamespaces")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -424,8 +414,6 @@ func (m *module) ListNamespaces(ctx context.Context, orgID valuer.UUID, req *inf
}
func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableClusters) (*inframonitoringtypes.Clusters, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListClusters")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -515,8 +503,6 @@ func (m *module) ListClusters(ctx context.Context, orgID valuer.UUID, req *infra
}
func (m *module) ListVolumes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableVolumes) (*inframonitoringtypes.Volumes, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListVolumes")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -600,8 +586,6 @@ func (m *module) ListVolumes(ctx context.Context, orgID valuer.UUID, req *infram
}
func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableDeployments) (*inframonitoringtypes.Deployments, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListDeployments")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -690,8 +674,6 @@ func (m *module) ListDeployments(ctx context.Context, orgID valuer.UUID, req *in
}
func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableStatefulSets) (*inframonitoringtypes.StatefulSets, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListStatefulSets")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -782,8 +764,6 @@ func (m *module) ListStatefulSets(ctx context.Context, orgID valuer.UUID, req *i
}
func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableJobs) (*inframonitoringtypes.Jobs, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListJobs")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -874,8 +854,6 @@ func (m *module) ListJobs(ctx context.Context, orgID valuer.UUID, req *inframoni
}
func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableDaemonSets) (*inframonitoringtypes.DaemonSets, error) {
ctx = m.withInfraMonitoringContext(ctx, "ListDaemonSets")
if err := req.Validate(); err != nil {
return nil, err
}
@@ -964,12 +942,3 @@ func (m *module) ListDaemonSets(ctx context.Context, orgID valuer.UUID, req *inf
return resp, nil
}
func (m *module) withInfraMonitoringContext(ctx context.Context, functionName string) context.Context {
comments := map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalMetrics.StringValue(),
instrumentationtypes.CodeNamespace: "infra-monitoring",
instrumentationtypes.CodeFunctionName: functionName,
}
return ctxtypes.NewContextWithCommentVals(ctx, comments)
}

View File

@@ -182,7 +182,7 @@ func (m *module) getFullFlamegraph(ctx context.Context, traceID string, summary
return nil, spantypes.ErrTraceNotFound
}
flamegraphTrace := spantypes.NewFlamegraphTraceFromStorable(fullSpans, selectFields)
return spantypes.NewGettableFlamegraphTrace(flamegraphTrace.GetAllLevels(), summary.Start, summary.End, false), nil
return spantypes.NewGettableFlamegraphTrace(flamegraphTrace.GetAllLevels(), summary.Start.UnixMilli(), summary.End.UnixMilli(), false), nil
}
// getWindowedFlamegraph returns a window of a max levels and max sampled spans per level around the selected span.
@@ -209,6 +209,10 @@ func (m *module) getWindowedFlamegraph(ctx context.Context, traceID, selectedSpa
return nil, err
}
enrichedSpans := flamegraphTrace.EnrichSelectedSpans(selectedSpans, fullSpans, selectFields)
return spantypes.NewGettableFlamegraphTrace(enrichedSpans, summary.Start, summary.End, true), nil
return spantypes.NewGettableFlamegraphTrace(
flamegraphTrace.EnrichSelectedSpans(selectedSpans, fullSpans, selectFields),
summary.Start.UnixMilli(),
summary.End.UnixMilli(),
true,
), nil
}

View File

@@ -62,7 +62,7 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
numericColsCount := 0
for i, ct := range colTypes {
slots[i] = reflect.New(ct.ScanType()).Interface()
if isNumericKind(ct.ScanType()) {
if numericKind(ct.ScanType().Kind()) {
numericColsCount++
}
}
@@ -270,14 +270,8 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
}, nil
}
func isNumericKind(t reflect.Type) bool {
if t == nil {
return false
}
for t.Kind() == reflect.Ptr || t.Kind() == reflect.UnsafePointer {
t = t.Elem()
}
switch t.Kind() {
func numericKind(k reflect.Kind) bool {
switch k {
case reflect.Float32, reflect.Float64,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
@@ -296,13 +290,7 @@ func readAsScalar(rows driver.Rows, queryName string) (*qbtypes.ScalarData, erro
var aggIndex int64
for i, name := range colNames {
colType := qbtypes.ColumnTypeGroup
// Builder queries aliases aggregation columns as __result_N (always numeric) and wraps group-by keys with toString (always string);
// Raw ClickHouse queries may use any aliases.
// Handling Builder queries, If name like __result_N -> aggregation, otherwise group-by column
// Handling Raw ClickHouse queries, If type is numeric -> aggregation, otherwise group-by column
// NOTE: For clickhouse queries, its wrong to assume that numeric columns are always aggregations, user might be grouping by on integer status_code.
// However, we are fine with this for now. If need arises, simplest way would be to solve this on the frontend side by asking user a mapping of column names to column types.
if aggRe.MatchString(name) || isNumericKind(colTypes[i].ScanType()) {
if aggRe.MatchString(name) {
colType = qbtypes.ColumnTypeAggregation
}
cd[i] = &qbtypes.ColumnDescriptor{

View File

@@ -213,7 +213,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewCloudIntegrationRemoveCascadeDeleteFactory(sqlschema),
sqlmigration.NewAddUserDashboardPreferenceFactory(sqlstore, sqlschema),
sqlmigration.NewRecreateUserDashboardPreferenceFactory(sqlstore, sqlschema),
sqlmigration.NewMigrateRecurrenceBoundsFactory(sqlstore),
)
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
@@ -56,15 +57,15 @@ type newRule struct {
type existingMaintenance struct {
bun.BaseModel `bun:"table:planned_maintenance"`
ID int `bun:"id,pk,autoincrement"`
Name string `bun:"name,type:text,notnull"`
Description string `bun:"description,type:text"`
AlertIDs *AlertIds `bun:"alert_ids,type:text"`
Schedule *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"`
UpdatedBy string `bun:"updated_by,type:text,notnull"`
ID int `bun:"id,pk,autoincrement"`
Name string `bun:"name,type:text,notnull"`
Description string `bun:"description,type:text"`
AlertIDs *AlertIds `bun:"alert_ids,type:text"`
Schedule *alertmanagertypes.Schedule `bun:"schedule,type:text,notnull"`
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"`
UpdatedBy string `bun:"updated_by,type:text,notnull"`
}
type newMaintenance struct {
@@ -72,10 +73,10 @@ type newMaintenance struct {
types.Identifiable
types.TimeAuditable
types.UserAuditable
Name string `bun:"name,type:text,notnull"`
Description string `bun:"description,type:text"`
Schedule *schedule `bun:"schedule,type:text,notnull"`
OrgID string `bun:"org_id,type:text"`
Name string `bun:"name,type:text,notnull"`
Description string `bun:"description,type:text"`
Schedule *alertmanagertypes.Schedule `bun:"schedule,type:text,notnull"`
OrgID string `bun:"org_id,type:text"`
}
type storablePlannedMaintenanceRule struct {
@@ -91,21 +92,6 @@ type ruleHistory struct {
RuleUUID valuer.UUID `bun:"rule_uuid"`
}
type schedule struct {
Timezone string `json:"timezone"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime,omitzero"`
Recurrence *recurrence `json:"recurrence"`
}
type recurrence struct {
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime,omitzero"`
Duration valuer.TextDuration `json:"duration"`
RepeatType string `json:"repeatType"`
RepeatOn []string `json:"repeatOn"`
}
func NewUpdateRulesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("update_rules"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return newUpdateRules(ctx, ps, c, sqlstore)

View File

@@ -1,128 +0,0 @@
package sqlmigration
import (
"context"
"encoding/json"
"log/slog"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type migrateRecurrenceBounds struct {
sqlstore sqlstore.SQLStore
logger *slog.Logger
}
type plannedMaintenanceScheduleRow struct {
bun.BaseModel `bun:"table:planned_maintenance"`
ID string `bun:"id"`
Schedule string `bun:"schedule"`
}
func NewMigrateRecurrenceBoundsFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("migrate_recurrence_bounds"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &migrateRecurrenceBounds{sqlstore: sqlstore, logger: ps.Logger}, nil
},
)
}
func (migration *migrateRecurrenceBounds) Register(migrations *migrate.Migrations) error {
if err := migrations.Register(migration.Up, migration.Down); err != nil {
return err
}
return nil
}
// Up moves the start/end bounds of a recurring planned maintenance from the
// nested recurrence object up to the schedule level. Until now both the
// schedule and its recurrence carried their own startTime/endTime, with the
// recurrence values taking precedence when a recurrence was present. The
// recurrence fields are being dropped, so the recurrence bounds (the source of
// truth for recurring maintenances) are promoted to the schedule before the
// struct loses those fields.
//
// We deliberately operate on the raw JSON instead of the Recurrence struct:
// that struct loses its StartTime/EndTime fields in the same change set, so it
// can no longer read the values this migration needs to move.
func (migration *migrateRecurrenceBounds) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
rows := make([]*plannedMaintenanceScheduleRow, 0)
if err := tx.NewSelect().Model(&rows).Scan(ctx); err != nil {
return err
}
for _, row := range rows {
schedule := make(map[string]json.RawMessage)
if err := json.Unmarshal([]byte(row.Schedule), &schedule); err != nil {
// A single corrupt row must not abort the whole migration (which would block startup).
migration.logger.WarnContext(ctx, "skipping planned maintenance with unreadable schedule", slog.String("maintenance_id", row.ID), errors.Attr(err))
continue
}
recurrenceRaw, ok := schedule["recurrence"]
if !ok || string(recurrenceRaw) == "null" {
continue
}
recurrence := make(map[string]json.RawMessage)
if err := json.Unmarshal(recurrenceRaw, &recurrence); err != nil {
migration.logger.WarnContext(ctx, "skipping planned maintenance with unreadable recurrence", slog.String("maintenance_id", row.ID), errors.Attr(err))
continue
}
// Promote the recurrence bounds (source of truth) to the schedule
// level, then drop them from the recurrence.
if startTime, ok := recurrence["startTime"]; ok {
schedule["startTime"] = startTime
delete(recurrence, "startTime")
}
if endTime, ok := recurrence["endTime"]; ok && string(endTime) != "null" {
schedule["endTime"] = endTime
} else {
// The recurrence had no end time, so the schedule must not carry
// a stale one duplicated by the UI.
delete(schedule, "endTime")
}
delete(recurrence, "endTime")
newRecurrence, err := json.Marshal(recurrence)
if err != nil {
return err
}
schedule["recurrence"] = newRecurrence
newSchedule, err := json.Marshal(schedule)
if err != nil {
return err
}
if _, err := tx.NewUpdate().
Model((*plannedMaintenanceScheduleRow)(nil)).
Set("schedule = ?", string(newSchedule)).
Where("id = ?", row.ID).
Exec(ctx); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *migrateRecurrenceBounds) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -3,7 +3,6 @@ package alertmanagertypes
import (
"context"
"encoding/json"
"slices"
"time"
"github.com/expr-lang/expr"
@@ -60,11 +59,11 @@ type StorablePlannedMaintenance struct {
types.Identifiable
types.TimeAuditable
types.UserAuditable
Name string `bun:"name,type:text,notnull"`
Description string `bun:"description,type:text"`
Schedule string `bun:"schedule,type:text,notnull"`
OrgID string `bun:"org_id,type:text"`
Scope string `bun:"scope,type:text"`
Name string `bun:"name,type:text,notnull"`
Description string `bun:"description,type:text"`
Schedule *Schedule `bun:"schedule,type:text,notnull"`
OrgID string `bun:"org_id,type:text"`
Scope string `bun:"scope,type:text"`
}
type PlannedMaintenance struct {
@@ -100,9 +99,18 @@ func (p *PostablePlannedMaintenance) Validate() error {
if p.Schedule == nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing schedule in the payload")
}
if p.Schedule.Timezone == "" {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing timezone in the payload")
}
if !p.Schedule.EndTime.IsZero() && p.Schedule.StartTime.After(p.Schedule.EndTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "start time cannot be after end time")
if _, err := time.LoadLocation(p.Schedule.Timezone); err != nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "invalid timezone in the payload")
}
if !p.Schedule.StartTime.IsZero() && !p.Schedule.EndTime.IsZero() {
if p.Schedule.StartTime.After(p.Schedule.EndTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "start time cannot be after end time")
}
}
if p.Schedule.Recurrence != nil {
@@ -112,6 +120,9 @@ func (p *PostablePlannedMaintenance) Validate() error {
if p.Schedule.Recurrence.Duration.IsZero() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing duration in the payload")
}
if p.Schedule.Recurrence.EndTime != nil && p.Schedule.Recurrence.EndTime.Before(p.Schedule.Recurrence.StartTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "end time cannot be before start time")
}
}
if p.Scope != "" {
if _, err := expr.Compile(p.Scope, expr.AllowUndefinedVariables(), expr.AsBool()); err != nil {
@@ -137,85 +148,134 @@ type PlannedMaintenanceWithRules struct {
Rules []*StorablePlannedMaintenanceRule `bun:"rel:has-many,join:id=planned_maintenance_id"`
}
// AppliesTo reports whether this maintenance applies to the given rule.
// An empty RuleIDs set means the maintenance applies to all rules.
func (m *PlannedMaintenance) AppliesTo(ruleID string) bool {
return len(m.RuleIDs) == 0 || slices.Contains(m.RuleIDs, ruleID)
// HasScheduleRecurrenceBoundsMismatch reports whether a recurring maintenance
// has different start/end bounds in Schedule and Schedule.Recurrence.
//
// This is used to detect if there are any entries with recurrence that don't
// have the same timestamps stored at the schedule-level.
// UI payloads duplicated those values in both places, but direct API users may
// have stored bounds that are missing from, or different than, the schedule-level bounds.
// We need to observe these before we can safely drop Recurrence.StartTime and
// Recurrence.EndTime.
func (m *PlannedMaintenance) HasScheduleRecurrenceBoundsMismatch() bool {
recurrence := m.Schedule.Recurrence
if recurrence == nil {
return false
}
return !recurrence.StartTime.Equal(m.Schedule.StartTime) ||
(recurrence.EndTime == nil && !m.Schedule.EndTime.IsZero()) ||
(recurrence.EndTime != nil && !recurrence.EndTime.Equal(m.Schedule.EndTime))
}
func (m *PlannedMaintenance) ShouldSkip(ruleID string, now time.Time, lset model.LabelSet) (bool, error) {
if !m.AppliesTo(ruleID) || !m.IsActive(now) {
// Check if the alert ID is in the maintenance window
found := false
if len(m.RuleIDs) > 0 {
for _, alertID := range m.RuleIDs {
if alertID == ruleID {
found = true
break
}
}
}
// If no alert ids, then skip all alerts
if len(m.RuleIDs) == 0 {
found = true
}
if !found {
return false, nil
}
if !m.IsActive(now) {
return false, nil
}
if m.Scope != "" {
skip, err := EvalScopeExpression(m.Scope, lset)
if err != nil || !skip {
result, err := EvalScopeExpression(m.Scope, lset)
if err != nil {
return false, err
}
if !result {
return false, nil
}
}
return true, nil
}
// IsActive reports whether [now] falls inside the maintenance window's schedule.
func (m *PlannedMaintenance) IsActive(now time.Time) bool {
// Check if maintenance window has not started yet
if now.Before(m.Schedule.StartTime) {
return false
}
// Check if maintenance window has expired
if !m.Schedule.EndTime.IsZero() && now.After(m.Schedule.EndTime) {
return false
}
// Fixed schedule
if m.Schedule.Recurrence == nil {
return true
}
// If alert is found, we check if it should be skipped based on the schedule
loc, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
return false
}
switch m.Schedule.Recurrence.RepeatType {
case RepeatTypeDaily:
return m.checkDaily(now, loc)
case RepeatTypeWeekly:
return m.checkWeekly(now, loc)
case RepeatTypeMonthly:
return m.checkMonthly(now, loc)
default:
return false
startTime := m.Schedule.StartTime
endTime := m.Schedule.EndTime
recurrence := m.Schedule.Recurrence
// fixed schedule — only when no recurrence is configured.
// When recurrence is set, the recurring check below handles everything;
// falling through here would cause the window to match the absolute
// StartTimeEndTime range instead of the daily/weekly/monthly pattern.
if recurrence == nil && !startTime.IsZero() && !endTime.IsZero() {
if now.Equal(startTime) || now.Equal(endTime) ||
(now.After(startTime) && now.Before(endTime)) {
return true
}
}
// recurring schedule
if recurrence != nil {
// Make sure the recurrence has started
if now.Before(recurrence.StartTime) {
return false
}
// Check if recurrence has expired
if recurrence.EndTime != nil {
if !recurrence.EndTime.IsZero() && now.After(*recurrence.EndTime) {
return false
}
}
currentTime := now.In(loc)
switch recurrence.RepeatType {
case RepeatTypeDaily:
return m.checkDaily(currentTime, recurrence, loc)
case RepeatTypeWeekly:
return m.checkWeekly(currentTime, recurrence, loc)
case RepeatTypeMonthly:
return m.checkMonthly(currentTime, recurrence, loc)
}
}
return false
}
// checkDaily rebases the recurrence start to today (or yesterday if needed)
// and returns true if currentTime is within [candidate, candidate+Duration].
func (m *PlannedMaintenance) checkDaily(currentTime time.Time, loc *time.Location) bool {
currentTime = currentTime.In(loc)
func (m *PlannedMaintenance) checkDaily(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
candidate := time.Date(
currentTime.Year(), currentTime.Month(), currentTime.Day(),
m.Schedule.StartTime.Hour(), m.Schedule.StartTime.Minute(), 0, 0,
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
loc,
)
if candidate.After(currentTime) {
candidate = candidate.AddDate(0, 0, -1)
}
return currentTime.Sub(candidate) <= m.Schedule.Recurrence.Duration.Duration()
return currentTime.Sub(candidate) <= rec.Duration.Duration()
}
// checkWeekly finds the most recent allowed occurrence by rebasing the recurrences
// time-of-day onto the allowed weekday. It does this for each allowed day and returns true
// if the current time falls within the candidate window.
func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, loc *time.Location) bool {
currentTime = currentTime.In(loc)
rec := m.Schedule.Recurrence
func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
// If no days specified, treat as every day (like daily).
if len(rec.RepeatOn) == 0 {
return m.checkDaily(currentTime, loc)
return m.checkDaily(currentTime, rec, loc)
}
for _, day := range rec.RepeatOn {
@@ -228,7 +288,7 @@ func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, loc *time.Locati
// Build a candidate occurrence by rebasing today's date to the allowed weekday.
candidate := time.Date(
currentTime.Year(), currentTime.Month(), currentTime.Day(),
m.Schedule.StartTime.Hour(), m.Schedule.StartTime.Minute(), 0, 0,
rec.StartTime.Hour(), rec.StartTime.Minute(), 0, 0,
loc,
).AddDate(0, 0, delta)
// If the candidate is in the future, subtract 7 days.
@@ -244,10 +304,8 @@ func (m *PlannedMaintenance) checkWeekly(currentTime time.Time, loc *time.Locati
// checkMonthly rebases the candidate occurrence using the recurrence's day-of-month.
// If the candidate for the current month is in the future, it uses the previous month.
func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, loc *time.Location) bool {
currentTime = currentTime.In(loc)
startTime := m.Schedule.StartTime
refDay := startTime.Day()
func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, rec *Recurrence, loc *time.Location) bool {
refDay := rec.StartTime.Day()
year, month, _ := currentTime.Date()
lastDay := time.Date(year, month+1, 0, 0, 0, 0, 0, loc).Day()
day := refDay
@@ -255,7 +313,7 @@ func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, loc *time.Locat
day = lastDay
}
candidate := time.Date(year, month, day,
startTime.Hour(), startTime.Minute(), startTime.Second(), startTime.Nanosecond(),
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
loc,
)
if candidate.After(currentTime) {
@@ -265,30 +323,33 @@ func (m *PlannedMaintenance) checkMonthly(currentTime time.Time, loc *time.Locat
lastDayPrev := time.Date(y, m+1, 0, 0, 0, 0, 0, loc).Day()
if refDay > lastDayPrev {
candidate = time.Date(y, m, lastDayPrev,
startTime.Hour(), startTime.Minute(), startTime.Second(), startTime.Nanosecond(),
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
loc,
)
} else {
candidate = time.Date(y, m, refDay,
startTime.Hour(), startTime.Minute(), startTime.Second(), startTime.Nanosecond(),
rec.StartTime.Hour(), rec.StartTime.Minute(), rec.StartTime.Second(), rec.StartTime.Nanosecond(),
loc,
)
}
}
return currentTime.Sub(candidate) <= m.Schedule.Recurrence.Duration.Duration()
return currentTime.Sub(candidate) <= rec.Duration.Duration()
}
func (m *PlannedMaintenance) IsUpcoming() bool {
now := time.Now()
if m.IsRecurring() {
// Note: this would return true even if the maintenance is active.
// This isn't an issue right now because the only usage happens after the `IsActive` check.
return m.Schedule.EndTime.IsZero() || now.Before(m.Schedule.EndTime)
loc, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
return false
}
now := time.Now().In(loc)
// Fixed schedule
return now.Before(m.Schedule.StartTime)
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
return now.Before(m.Schedule.StartTime)
}
if m.Schedule.Recurrence != nil {
return now.Before(m.Schedule.Recurrence.StartTime)
}
return false
}
func (m *PlannedMaintenance) IsRecurring() bool {
@@ -302,8 +363,19 @@ func (m *PlannedMaintenance) Validate() error {
if m.Schedule == nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing schedule in the payload")
}
if !m.Schedule.EndTime.IsZero() && m.Schedule.StartTime.After(m.Schedule.EndTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "start time cannot be after end time")
if m.Schedule.Timezone == "" {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing timezone in the payload")
}
_, err := time.LoadLocation(m.Schedule.Timezone)
if err != nil {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "invalid timezone in the payload")
}
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() {
if m.Schedule.StartTime.After(m.Schedule.EndTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "start time cannot be after end time")
}
}
if m.Schedule.Recurrence != nil {
@@ -313,31 +385,28 @@ func (m *PlannedMaintenance) Validate() error {
if m.Schedule.Recurrence.Duration.IsZero() {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "missing duration in the payload")
}
}
if m.Scope != "" {
if _, err := expr.Compile(m.Scope, expr.AllowUndefinedVariables(), expr.AsBool()); err != nil {
err := errors.Newf(
errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload,
"invalid scope: %s", err.Error(),
)
return err.WithUrl(scopeDocUrl)
if m.Schedule.Recurrence.EndTime != nil && m.Schedule.Recurrence.EndTime.Before(m.Schedule.Recurrence.StartTime) {
return errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidPlannedMaintenancePayload, "end time cannot be before start time")
}
}
return nil
}
func (m PlannedMaintenance) MarshalJSON() ([]byte, error) {
now := time.Now().In(time.FixedZone(m.Schedule.Timezone, 0))
var status MaintenanceStatus
if m.IsActive(time.Now()) {
if m.IsActive(now) {
status = MaintenanceStatusActive
} else if m.IsUpcoming() {
status = MaintenanceStatusUpcoming
} else {
status = MaintenanceStatusExpired
}
var kind MaintenanceKind
kind := MaintenanceKindFixed
if m.Schedule.Recurrence != nil {
if !m.Schedule.StartTime.IsZero() && !m.Schedule.EndTime.IsZero() && m.Schedule.EndTime.After(m.Schedule.StartTime) {
kind = MaintenanceKindFixed
} else {
kind = MaintenanceKindRecurring
}
@@ -370,29 +439,26 @@ func (m PlannedMaintenance) MarshalJSON() ([]byte, error) {
})
}
func (m *PlannedMaintenanceWithRules) ToPlannedMaintenance() (*PlannedMaintenance, error) {
schedule := &Schedule{}
if err := json.Unmarshal([]byte(m.Schedule), &schedule); err != nil {
return nil, err
}
ruleIDs := make([]string, 0, len(m.Rules))
for _, storableMaintenanceRule := range m.Rules {
ruleIDs = append(ruleIDs, storableMaintenanceRule.RuleID.StringValue())
func (m *PlannedMaintenanceWithRules) ToPlannedMaintenance() *PlannedMaintenance {
ruleIDs := []string{}
if m.Rules != nil {
for _, storableMaintenanceRule := range m.Rules {
ruleIDs = append(ruleIDs, storableMaintenanceRule.RuleID.StringValue())
}
}
return &PlannedMaintenance{
ID: m.ID,
Name: m.Name,
Description: m.Description,
Schedule: schedule,
Schedule: m.Schedule,
RuleIDs: ruleIDs,
Scope: m.Scope,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
CreatedBy: m.CreatedBy,
UpdatedBy: m.UpdatedBy,
}, nil
}
}
type ListPlannedMaintenanceParams struct {

View File

@@ -8,6 +8,11 @@ import (
"github.com/prometheus/common/model"
)
// Helper function to create a time pointer.
func timePtr(t time.Time) *time.Time {
return &t
}
func TestShouldSkipMaintenance(t *testing.T) {
cases := []struct {
name string
@@ -19,9 +24,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "only-on-saturday",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "Europe/London",
StartTime: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC),
Timezone: "Europe/London",
Recurrence: &Recurrence{
StartTime: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("24h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday, RepeatOnTuesday, RepeatOnWednesday, RepeatOnThursday, RepeatOnFriday, RepeatOnSunday},
@@ -36,10 +41,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-across-midnight-previous-day",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
Timezone: "UTC",
Recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("4h"), // Until Tuesday 02:00
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
Duration: valuer.MustParseTextDuration("4h"), // Until Tuesday 02:00
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday
},
@@ -53,10 +58,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-across-midnight-previous-day",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
Timezone: "UTC",
Recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("4h"), // Until Tuesday 02:00
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
Duration: valuer.MustParseTextDuration("4h"), // Until Tuesday 02:00
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday
},
@@ -70,10 +75,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-across-midnight-previous-day",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
Timezone: "UTC",
Recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("52h"), // Until Thursday 02:00
StartTime: time.Date(2024, 4, 1, 22, 0, 0, 0, time.UTC), // Monday 22:00
Duration: valuer.MustParseTextDuration("52h"), // Until Thursday 02:00
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday
},
@@ -87,10 +92,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-across-midnight-previous-day-not-in-repeaton",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 2, 22, 0, 0, 0, time.UTC), // Tuesday 22:00
Timezone: "UTC",
Recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("4h"), // Until Wednesday 02:00
StartTime: time.Date(2024, 4, 2, 22, 0, 0, 0, time.UTC), // Tuesday 22:00
Duration: valuer.MustParseTextDuration("4h"), // Until Wednesday 02:00
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnTuesday}, // Only Tuesday
},
@@ -104,10 +109,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "daily-maintenance-across-midnight",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 23, 0, 0, 0, time.UTC), // 23:00
Timezone: "UTC",
Recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("2h"), // Until 01:00 next day
StartTime: time.Date(2024, 1, 1, 23, 0, 0, 0, time.UTC), // 23:00
Duration: valuer.MustParseTextDuration("2h"), // Until 01:00 next day
RepeatType: RepeatTypeDaily,
},
},
@@ -120,9 +125,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "at-start-time-boundary",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
@@ -136,9 +141,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "at-end-time-boundary",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
@@ -152,9 +157,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-multi-day-duration",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 28, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 28, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("72h"), // 3 days
RepeatType: RepeatTypeMonthly,
},
@@ -168,9 +173,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-multi-day-duration",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 28, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 28, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("72h"), // 3 days
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnSunday},
@@ -185,9 +190,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-crosses-to-next-month",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 30, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 30, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("48h"), // 2 days, crosses to Feb 1
RepeatType: RepeatTypeMonthly,
},
@@ -201,9 +206,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "timezone-offset-test",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "America/New_York", // UTC-5 or UTC-4 depending on DST
StartTime: time.Date(2024, 1, 1, 22, 0, 0, 0, time.FixedZone("America/New_York", -5*3600)),
Timezone: "America/New_York", // UTC-5 or UTC-4 depending on DST
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 22, 0, 0, 0, time.FixedZone("America/New_York", -5*3600)),
Duration: valuer.MustParseTextDuration("4h"),
RepeatType: RepeatTypeDaily,
},
@@ -217,9 +222,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "daily-maintenance-time-outside-window",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
@@ -233,10 +238,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring-maintenance-with-past-end-date",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
EndTime: time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
EndTime: timePtr(time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC)),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
@@ -250,10 +255,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-maintenance-spans-month-end",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 3, 31, 22, 0, 0, 0, time.UTC), // March 31, 22:00
Timezone: "UTC",
Recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("6h"), // Until April 1, 04:00
StartTime: time.Date(2024, 3, 31, 22, 0, 0, 0, time.UTC), // March 31, 22:00
Duration: valuer.MustParseTextDuration("6h"), // Until April 1, 04:00
RepeatType: RepeatTypeMonthly,
},
},
@@ -266,9 +271,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-empty-repeaton",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{}, // Empty - should apply to all days
@@ -283,9 +288,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-maintenance-february-fewer-days",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -298,9 +303,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "daily-maintenance-crosses-midnight",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 23, 30, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 23, 30, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("1h"), // Crosses to 00:30 next day
RepeatType: RepeatTypeDaily,
},
@@ -313,9 +318,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-maintenance-crosses-month-end",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -328,9 +333,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-maintenance-crosses-month-end-and-duration-is-2-days",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 30, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 30, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("48h"), // 2 days duration
RepeatType: RepeatTypeMonthly,
},
@@ -343,10 +348,10 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "weekly-maintenance-crosses-midnight",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 23, 0, 0, 0, time.UTC), // Monday 23:00
Timezone: "UTC",
Recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("2h"), // Until Tuesday 01:00
StartTime: time.Date(2024, 4, 1, 23, 0, 0, 0, time.UTC), // Monday 23:00
Duration: valuer.MustParseTextDuration("2h"), // Until Tuesday 01:00
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday}, // Only Monday
},
@@ -359,9 +364,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-maintenance-crosses-month-end-and-duration-is-2-days",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC), // January 31st
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -374,9 +379,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "daily-maintenance-crosses-midnight",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 22, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 22, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("4h"), // Until 02:00 next day
RepeatType: RepeatTypeDaily,
},
@@ -389,9 +394,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "monthly-maintenance-crosses-month-end-and-duration-is-2-hours",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 31, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -440,9 +445,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat sunday, saturday, weekly for 24 hours, in Us/Eastern timezone",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "US/Eastern",
StartTime: time.Date(2025, 3, 29, 20, 0, 0, 0, time.FixedZone("US/Eastern", -4*3600)),
Timezone: "US/Eastern",
Recurrence: &Recurrence{
StartTime: time.Date(2025, 3, 29, 20, 0, 0, 0, time.FixedZone("US/Eastern", -4*3600)),
Duration: valuer.MustParseTextDuration("24h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnSunday, RepeatOnSaturday},
@@ -453,57 +458,57 @@ func TestShouldSkipMaintenance(t *testing.T) {
skip: true,
},
{
name: "recurring maintenance, repeat daily from 12:00 to 14:00, ts < start",
name: "recurring maintenance, repeat daily from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
},
},
ts: time.Date(2024, 1, 10, 11, 0, 0, 0, time.UTC),
skip: false,
},
{
name: "recurring maintenance, repeat daily from 12:00 to 14:00, start <= ts <= end",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
},
},
ts: time.Date(2024, 1, 10, 13, 0, 0, 0, time.UTC),
ts: time.Date(2024, 1, 1, 12, 10, 0, 0, time.UTC),
skip: true,
},
{
name: "recurring maintenance, repeat daily from 12:00 to 14:00, start > end",
name: "recurring maintenance, repeat daily from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
},
},
ts: time.Date(2024, 1, 10, 15, 0, 0, 0, time.UTC),
skip: false,
ts: time.Date(2024, 1, 1, 14, 0, 0, 0, time.UTC),
skip: true,
},
{
name: "recurring maintenance, repeat daily from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
},
},
ts: time.Date(2024, 4, 1, 12, 10, 0, 0, time.UTC),
skip: true,
},
{
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
@@ -517,9 +522,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
@@ -533,9 +538,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
@@ -549,9 +554,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
@@ -565,9 +570,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat weekly on monday from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
@@ -581,9 +586,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat monthly on 4th from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -596,9 +601,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat monthly on 4th from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -611,9 +616,9 @@ func TestShouldSkipMaintenance(t *testing.T) {
name: "recurring maintenance, repeat monthly on 4th from 12:00 to 14:00",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Timezone: "UTC",
Recurrence: &Recurrence{
StartTime: time.Date(2024, 4, 4, 12, 0, 0, 0, time.UTC),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
@@ -622,6 +627,45 @@ func TestShouldSkipMaintenance(t *testing.T) {
ts: time.Date(2024, 5, 4, 12, 10, 0, 0, time.UTC),
skip: true,
},
// The recurrence should govern, when set. Not the fixed range.
{
name: "recurring-daily-with-fixed-times-outside-daily-window",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
// These fixed fields should be ignored when Recurrence is set.
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 4, 30, 18, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC), // daily at 14:00
Duration: valuer.MustParseTextDuration("2h"), // until 16:00
RepeatType: RepeatTypeDaily,
},
},
},
// 2026-04-15 11:00 is inside the fixed range but outside the daily 14:00-16:00 window.
ts: time.Date(2026, 4, 15, 11, 0, 0, 0, time.UTC),
skip: false,
},
{
name: "recurring-daily-with-fixed-times-inside-daily-window",
maintenance: &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC),
EndTime: time.Date(2026, 4, 30, 18, 0, 0, 0, time.UTC),
Recurrence: &Recurrence{
StartTime: time.Date(2026, 4, 1, 14, 0, 0, 0, time.UTC),
EndTime: timePtr(time.Date(2026, 4, 30, 18, 0, 0, 0, time.UTC)),
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
},
},
},
// 15:00 is inside the daily 14:00-16:00 window. Should skip.
ts: time.Date(2026, 4, 15, 15, 0, 0, 0, time.UTC),
skip: true,
},
}
for idx, c := range cases {
@@ -635,211 +679,13 @@ func TestShouldSkipMaintenance(t *testing.T) {
}
}
func TestIsActiveFixedSchedule(t *testing.T) {
start := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
end := time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC)
cases := []struct {
name string
startTime time.Time
endTime time.Time
now time.Time
active bool
}{
{
name: "no end, t < start",
startTime: start,
now: start.Add(-time.Hour),
active: false,
},
{
name: "no end, start == t",
startTime: start,
now: start,
active: true,
},
{
// A fixed schedule with no end time stays active indefinitely.
name: "no end, start << t",
startTime: start,
now: start.AddDate(10, 0, 0),
active: true,
},
{
name: "with end, start < t < end",
startTime: start,
endTime: end,
now: start.Add(24 * time.Hour),
active: true,
},
{
name: "with end, t == end",
startTime: start,
endTime: end,
now: end,
active: true,
},
{
name: "with end, end < t",
startTime: start,
endTime: end,
now: end.Add(time.Hour),
active: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
m := &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: c.startTime,
EndTime: c.endTime,
},
}
if got := m.IsActive(c.now); got != c.active {
t.Errorf("IsActive() = %v, want %v", got, c.active)
}
})
}
}
func TestIsActiveRecurringSchedule(t *testing.T) {
// Daily window 12:00-14:00, starting 2024-01-01 (a Monday).
start := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
daily := &Recurrence{
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeDaily,
}
cases := []struct {
name string
startTime time.Time
endTime time.Time
recurrence *Recurrence
now time.Time
active bool
}{
{
// The recurrence has not begun yet, even though the time-of-day matches.
name: "daily: t < recurrence start",
startTime: start,
recurrence: daily,
now: time.Date(2023, 12, 31, 13, 0, 0, 0, time.UTC),
active: false,
},
{
name: "daily: no end, within window",
startTime: start,
recurrence: daily,
now: time.Date(2024, 6, 15, 13, 0, 0, 0, time.UTC),
active: true,
},
{
name: "daily: no end, outside window",
startTime: start,
recurrence: daily,
now: time.Date(2024, 6, 15, 15, 0, 0, 0, time.UTC),
active: false,
},
{
name: "daily: at window start boundary",
startTime: start,
recurrence: daily,
now: time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC),
active: true,
},
{
name: "daily: at window end boundary",
startTime: start,
recurrence: daily,
now: time.Date(2024, 6, 15, 14, 0, 0, 0, time.UTC),
active: true,
},
{
// Past the recurrence end, the time-of-day match no longer applies.
name: "daily: t > recurrence end",
startTime: start,
endTime: time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC),
recurrence: daily,
now: time.Date(2024, 1, 15, 13, 0, 0, 0, time.UTC),
active: false,
},
{
name: "daily: before recurrence end, within window",
startTime: start,
endTime: time.Date(2024, 1, 10, 23, 0, 0, 0, time.UTC),
recurrence: daily,
now: time.Date(2024, 1, 10, 13, 0, 0, 0, time.UTC),
active: true,
},
{
name: "weekly: on allowed day, within window",
startTime: start, // Monday
recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
},
now: time.Date(2024, 4, 15, 13, 0, 0, 0, time.UTC), // a Monday
active: true,
},
{
name: "weekly: on non-allowed day",
startTime: start,
recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeWeekly,
RepeatOn: []RepeatOn{RepeatOnMonday},
},
now: time.Date(2024, 4, 16, 13, 0, 0, 0, time.UTC), // a Tuesday
active: false,
},
{
name: "monthly: on day-of-month, within window",
startTime: time.Date(2024, 1, 4, 12, 0, 0, 0, time.UTC),
recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
now: time.Date(2024, 5, 4, 13, 0, 0, 0, time.UTC),
active: true,
},
{
name: "monthly: on different day-of-month",
startTime: time.Date(2024, 1, 4, 12, 0, 0, 0, time.UTC),
recurrence: &Recurrence{
Duration: valuer.MustParseTextDuration("2h"),
RepeatType: RepeatTypeMonthly,
},
now: time.Date(2024, 5, 5, 13, 0, 0, 0, time.UTC),
active: false,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
m := &PlannedMaintenance{
Schedule: &Schedule{
Timezone: "UTC",
StartTime: c.startTime,
EndTime: c.endTime,
Recurrence: c.recurrence,
},
}
if got := m.IsActive(c.now); got != c.active {
t.Errorf("IsActive() = %v, want %v", got, c.active)
}
})
}
}
func TestShouldSkip_Scope(t *testing.T) {
activeSchedule := &Schedule{
Timezone: "UTC",
StartTime: time.Now().UTC().Add(-time.Hour),
EndTime: time.Now().UTC().Add(time.Hour),
activeSchedule := func() *Schedule {
return &Schedule{
Timezone: "UTC",
StartTime: time.Now().UTC().Add(-time.Hour),
EndTime: time.Now().UTC().Add(time.Hour),
}
}
now := time.Now().UTC()
@@ -853,7 +699,7 @@ func TestShouldSkip_Scope(t *testing.T) {
}{
{
name: "empty scope - no label filtering applied",
maintenance: &PlannedMaintenance{Schedule: activeSchedule},
maintenance: &PlannedMaintenance{Schedule: activeSchedule()},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
@@ -861,7 +707,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "scope matches labels",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
@@ -869,7 +715,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "scope does not match labels",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "staging"},
@@ -877,7 +723,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "AND expression - both conditions match",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production" AND service = "api"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" AND service = "api"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production", "service": "api"},
@@ -885,7 +731,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "AND expression - one condition does not match",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production" AND service = "api"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" AND service = "api"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production", "service": "worker"},
@@ -893,7 +739,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "OR expression - first alternative matches",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production" OR env = "staging"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" OR env = "staging"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
@@ -901,7 +747,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "OR expression - second alternative matches",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production" OR env = "staging"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" OR env = "staging"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "staging"},
@@ -909,7 +755,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "OR expression - neither alternative matches",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production" OR env = "staging"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production" OR env = "staging"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "development"},
@@ -917,7 +763,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "scope references label absent from lset",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env = "production"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"service": "api"},
@@ -925,7 +771,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "in expression - value is in list",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env in ["production", "staging"]`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env in ["production", "staging"]`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "staging"},
@@ -933,7 +779,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "in expression - value not in list",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, Scope: `env in ["production", "staging"]`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), Scope: `env in ["production", "staging"]`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "development"},
@@ -941,7 +787,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "ruleID in list and scope matches - should skip",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, RuleIDs: []string{"rule-1", "rule-2"}, Scope: `env = "production"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), RuleIDs: []string{"rule-1", "rule-2"}, Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
@@ -949,7 +795,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "ruleID not in list and scope matches - ruleID gate prevents skip",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, RuleIDs: []string{"rule-2"}, Scope: `env = "production"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), RuleIDs: []string{"rule-2"}, Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "production"},
@@ -957,7 +803,7 @@ func TestShouldSkip_Scope(t *testing.T) {
},
{
name: "ruleID in list but scope does not match - should not skip",
maintenance: &PlannedMaintenance{Schedule: activeSchedule, RuleIDs: []string{"rule-1"}, Scope: `env = "production"`},
maintenance: &PlannedMaintenance{Schedule: activeSchedule(), RuleIDs: []string{"rule-1"}, Scope: `env = "production"`},
ruleID: "rule-1",
ts: now,
lset: model.LabelSet{"env": "staging"},

View File

@@ -66,9 +66,9 @@ var RepeatOnAllMap = map[RepeatOn]time.Weekday{
RepeatOnSaturday: time.Saturday,
}
// Recurrence describes the repeat pattern of a planned maintenance.
// The window bounds (start/end) live on the enclosing Schedule.
type Recurrence struct {
StartTime time.Time `json:"startTime" required:"true"`
EndTime *time.Time `json:"endTime,omitempty"`
Duration valuer.TextDuration `json:"duration" required:"true"`
RepeatType RepeatType `json:"repeatType" required:"true"`
RepeatOn []RepeatOn `json:"repeatOn"`

View File

@@ -11,7 +11,7 @@ import (
type Schedule struct {
Timezone string `json:"timezone" required:"true"`
StartTime time.Time `json:"startTime" required:"true"`
StartTime time.Time `json:"startTime,omitempty"`
EndTime time.Time `json:"endTime,omitzero"`
Recurrence *Recurrence `json:"recurrence"`
}
@@ -39,12 +39,29 @@ func (s Schedule) MarshalJSON() ([]byte, error) {
return nil, err
}
// Marshal times in the selected timezone.
// This ensures that recurring events are handled correctly when DST is involved.
startTime := s.StartTime.In(loc)
var endTime time.Time
var startTime, endTime time.Time
if !s.StartTime.IsZero() {
startTime = time.Date(s.StartTime.Year(), s.StartTime.Month(), s.StartTime.Day(), s.StartTime.Hour(), s.StartTime.Minute(), s.StartTime.Second(), s.StartTime.Nanosecond(), loc)
}
if !s.EndTime.IsZero() {
endTime = s.EndTime.In(loc)
endTime = time.Date(s.EndTime.Year(), s.EndTime.Month(), s.EndTime.Day(), s.EndTime.Hour(), s.EndTime.Minute(), s.EndTime.Second(), s.EndTime.Nanosecond(), loc)
}
var recurrence *Recurrence
if s.Recurrence != nil {
recStartTime := time.Date(s.Recurrence.StartTime.Year(), s.Recurrence.StartTime.Month(), s.Recurrence.StartTime.Day(), s.Recurrence.StartTime.Hour(), s.Recurrence.StartTime.Minute(), s.Recurrence.StartTime.Second(), s.Recurrence.StartTime.Nanosecond(), loc)
var recEndTime *time.Time
if s.Recurrence.EndTime != nil {
end := time.Date(s.Recurrence.EndTime.Year(), s.Recurrence.EndTime.Month(), s.Recurrence.EndTime.Day(), s.Recurrence.EndTime.Hour(), s.Recurrence.EndTime.Minute(), s.Recurrence.EndTime.Second(), s.Recurrence.EndTime.Nanosecond(), loc)
recEndTime = &end
}
recurrence = &Recurrence{
StartTime: recStartTime,
EndTime: recEndTime,
Duration: s.Recurrence.Duration,
RepeatType: s.Recurrence.RepeatType,
RepeatOn: s.Recurrence.RepeatOn,
}
}
return json.Marshal(&struct {
@@ -56,7 +73,7 @@ func (s Schedule) MarshalJSON() ([]byte, error) {
Timezone: s.Timezone,
StartTime: startTime,
EndTime: endTime,
Recurrence: s.Recurrence,
Recurrence: recurrence,
})
}
@@ -71,35 +88,55 @@ func (s *Schedule) UnmarshalJSON(data []byte) error {
return err
}
if aux.Timezone == "" {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "missing timezone")
}
loc, err := time.LoadLocation(aux.Timezone)
if err != nil {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, `invalid timezone "%s"`, aux.Timezone)
return err
}
startTime, err := time.Parse(time.RFC3339, aux.StartTime)
if err != nil {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, `invalid start time "%s"`, aux.StartTime)
var startTime time.Time
if aux.StartTime != "" {
startTime, err = time.Parse(time.RFC3339, aux.StartTime)
if err != nil {
return err
}
s.StartTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), startTime.Hour(), startTime.Minute(), startTime.Second(), startTime.Nanosecond(), loc)
}
startTime = startTime.In(loc)
var endTime time.Time
if aux.EndTime != "" {
endTime, err = time.Parse(time.RFC3339, aux.EndTime)
if err != nil {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, `invalid end time "%s"`, aux.EndTime)
}
if !endTime.IsZero() {
endTime = endTime.In(loc)
return err
}
// TODO(jatinderjit): if endTime.IsZero() then we should not set the endTime
s.EndTime = time.Date(endTime.Year(), endTime.Month(), endTime.Day(), endTime.Hour(), endTime.Minute(), endTime.Second(), endTime.Nanosecond(), loc)
}
s.Timezone = aux.Timezone
s.StartTime = startTime
s.EndTime = endTime
s.Recurrence = aux.Recurrence
if aux.Recurrence != nil {
recStartTime, err := time.Parse(time.RFC3339, aux.Recurrence.StartTime.Format(time.RFC3339))
if err != nil {
return err
}
var recEndTime *time.Time
if aux.Recurrence.EndTime != nil {
end, err := time.Parse(time.RFC3339, aux.Recurrence.EndTime.Format(time.RFC3339))
if err != nil {
return err
}
endConverted := time.Date(end.Year(), end.Month(), end.Day(), end.Hour(), end.Minute(), end.Second(), end.Nanosecond(), loc)
recEndTime = &endConverted
}
s.Recurrence = &Recurrence{
StartTime: time.Date(recStartTime.Year(), recStartTime.Month(), recStartTime.Day(), recStartTime.Hour(), recStartTime.Minute(), recStartTime.Second(), recStartTime.Nanosecond(), loc),
EndTime: recEndTime,
Duration: aux.Recurrence.Duration,
RepeatType: aux.Recurrence.RepeatType,
RepeatOn: aux.Recurrence.RepeatOn,
}
}
return nil
}

View File

@@ -31,6 +31,7 @@ var (
AzureServiceAppService = ServiceID{valuer.NewString("appservice")}
AzureServiceContainerApp = ServiceID{valuer.NewString("containerapp")}
AzureServiceAKS = ServiceID{valuer.NewString("aks")}
AzureServiceSQLDatabase = ServiceID{valuer.NewString("sqldatabase")}
)
func (ServiceID) Enum() []any {
@@ -54,6 +55,7 @@ func (ServiceID) Enum() []any {
AzureServiceAppService,
AzureServiceContainerApp,
AzureServiceAKS,
AzureServiceSQLDatabase,
}
}
@@ -81,6 +83,7 @@ var SupportedServices = map[CloudProviderType][]ServiceID{
AzureServiceAppService,
AzureServiceContainerApp,
AzureServiceAKS,
AzureServiceSQLDatabase,
},
}

View File

@@ -1,8 +1,6 @@
package spantypes
import (
"time"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -39,17 +37,11 @@ type GettableFlamegraphTrace struct {
HasMore bool `json:"hasMore" required:"true"`
}
func NewGettableFlamegraphTrace(spans [][]*FlamegraphSpan, start, end time.Time, hasMore bool) *GettableFlamegraphTrace {
// convert timestamp to millisecond since client expect that
for _, level := range spans {
for _, span := range level {
span.Timestamp /= 1_000_000
}
}
func NewGettableFlamegraphTrace(spans [][]*FlamegraphSpan, startMs, endMs int64, hasMore bool) *GettableFlamegraphTrace {
return &GettableFlamegraphTrace{
Spans: spans,
StartTimestampMillis: start.UnixMilli(),
EndTimestampMillis: end.UnixMilli(),
StartTimestampMillis: startMs,
EndTimestampMillis: endMs,
HasMore: hasMore,
}
}

View File

@@ -9,28 +9,6 @@ from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
BASE_URL = "/api/v2/dashboards"
# v1 list returns every dashboard regardless of schema. v2 list converts each row
# to the perses schema and 501s if any stored dashboard isn't perses-schema, so
# listing for cleanup against a shared DB must go through v1.
V1_BASE_URL = "/api/v1/dashboards"
def _wipe_all_dashboards(signoz: SigNoz, token: str) -> None:
response = requests.get(
signoz.self.host_configs["8080"].get(V1_BASE_URL),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert response.status_code == HTTPStatus.OK, response.text
for dashboard in response.json()["data"]:
metadata = (dashboard.get("data") or {}).get("metadata") or {}
base = BASE_URL if metadata.get("schemaVersion") == "v6" else V1_BASE_URL
del_res = requests.delete(
signoz.self.host_configs["8080"].get(f"{base}/{dashboard['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
assert del_res.status_code == HTTPStatus.NO_CONTENT, del_res.text
# ─── failure cases (create no dashboards) ────────────────────────────────────
@@ -280,7 +258,18 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
# runs, so start from a clean slate: delete every dashboard (which also clears
# pins via the delete cascade). This test then owns the whole dashboard space
# and asserts on global counts.
_wipe_all_dashboards(signoz, token)
existing = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).json()["data"]["dashboards"]
for dashboard in existing:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
dashboard_requests = [
(
@@ -698,7 +687,18 @@ def test_dashboard_v2_pin_limit(
# Wipe the dashboard space (see lifecycle) so the per-user pin cap this test
# asserts against starts empty — deleting dashboards clears their pins.
_wipe_all_dashboards(signoz, token)
existing = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).json()["data"]["dashboards"]
for dashboard in existing:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
ids: list[str] = []
for i in range(max_pinned + 1):
@@ -785,7 +785,18 @@ def test_dashboard_v2_like_escaping(
# Wipe the dashboard space (see lifecycle) so the filter assertions run
# against only the dashboards this test creates.
_wipe_all_dashboards(signoz, token)
existing = requests.get(
signoz.self.host_configs["8080"].get(BASE_URL),
params={"limit": 200},
headers={"Authorization": f"Bearer {token}"},
timeout=5,
).json()["data"]["dashboards"]
for dashboard in existing:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard['id']}"),
headers={"Authorization": f"Bearer {token}"},
timeout=5,
)
dashboard_requests = [
("esc-pct", "Cost 50% Report"),

View File

@@ -1,74 +0,0 @@
"""
Integration tests for raw ClickHouse SQL queries in the querier.
"""
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from http import HTTPStatus
from fixtures import querier, types
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
def test_clickhouse_scalar_numeric_result_alias_classified_as_aggregation(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
"""A numeric column aliased ``__result_0`` is classified as an aggregation."""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = querier.make_query_request(
signoz,
token,
int((now - timedelta(hours=1)).timestamp() * 1000),
int(now.timestamp() * 1000),
[
{
"type": "clickhouse_sql",
"spec": {
"name": "A",
"query": "SELECT toFloat64(1.5) AS `__result_0`",
"disabled": False,
},
}
],
request_type=querier.RequestType.SCALAR,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
columns = querier.get_scalar_columns(response.json())
assert len(columns) == 1
assert columns[0]["name"] == "__result_0"
assert columns[0]["columnType"] == "aggregation"
assert columns[0]["aggregationIndex"] == 0
response = querier.make_query_request(
signoz,
token,
int((now - timedelta(hours=1)).timestamp() * 1000),
int(now.timestamp() * 1000),
[
{
"type": "clickhouse_sql",
"spec": {
"name": "A",
"query": "SELECT toNullable(toFloat64(1.5)) AS value",
"disabled": False,
},
}
],
request_type=querier.RequestType.SCALAR,
)
assert response.status_code == HTTPStatus.OK
assert response.json()["status"] == "success"
columns = querier.get_scalar_columns(response.json())
assert len(columns) == 1
assert columns[0]["name"] == "value"
assert columns[0]["columnType"] == "aggregation"
assert columns[0]["aggregationIndex"] == 0