mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-13 04:10:27 +01:00
Compare commits
3 Commits
main
...
feat/compo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c76d68393 | ||
|
|
ac1321b7dd | ||
|
|
75f410b5ad |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
import { SelectOption } from 'types/common/select';
|
||||
|
||||
export const metricAggregateOperatorOptions: SelectOption<string, string>[] = [
|
||||
{
|
||||
value: MetricAggregateOperator.NOOP,
|
||||
label: 'No aggregation',
|
||||
},
|
||||
{
|
||||
value: MetricAggregateOperator.COUNT,
|
||||
label: 'Count',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { MessageContext } from 'api/ai-assistant/chat';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { matchPath } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
@@ -294,11 +295,11 @@ function collectSharedMetadata(
|
||||
// Query Builder state — URL-encoded JSON written by `QueryBuilderProvider`.
|
||||
const compositeQueryRaw = params.get(QueryParams.compositeQuery);
|
||||
if (compositeQueryRaw) {
|
||||
try {
|
||||
out.query = JSON.parse(decodeURIComponent(compositeQueryRaw));
|
||||
} catch {
|
||||
// Malformed JSON in the URL — drop silently rather than throw
|
||||
// inside a context-collection helper.
|
||||
// Decode through the serializer seam (handles every tier + malformed
|
||||
// input → null); never JSON.parse the raw URL value.
|
||||
const decodedQuery = deserialize(compositeQueryRaw);
|
||||
if (decodedQuery) {
|
||||
out.query = decodedQuery;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -142,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>
|
||||
@@ -192,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 ? (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
|
||||
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
|
||||
import { normalizeTimeToMs } from 'utils/timeUtils';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import AutoRefresh from '../AutoRefreshV2';
|
||||
@@ -299,7 +300,7 @@ function DateTimeSelection({
|
||||
})),
|
||||
},
|
||||
};
|
||||
return encodeURIComponent(JSON.stringify(updatedCompositeQuery));
|
||||
return serialize(updatedCompositeQuery);
|
||||
}, [currentQuery]);
|
||||
|
||||
const onSelectHandler = useCallback(
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
|
||||
let mockUrlQuery = new URLSearchParams();
|
||||
|
||||
jest.mock('hooks/useUrlQuery', () => ({
|
||||
__esModule: true,
|
||||
default: (): URLSearchParams => mockUrlQuery,
|
||||
}));
|
||||
|
||||
describe('useGetCompositeQueryParam', () => {
|
||||
it('decodes a legacy compositeQuery param', () => {
|
||||
mockUrlQuery = new URLSearchParams({
|
||||
compositeQuery: encodeURIComponent(JSON.stringify(initialQueriesMap.logs)),
|
||||
});
|
||||
const { result } = renderHook(() => useGetCompositeQueryParam());
|
||||
expect(result.current?.builder.queryData[0].dataSource).toBe('logs');
|
||||
});
|
||||
|
||||
it('returns null when the param is absent', () => {
|
||||
mockUrlQuery = new URLSearchParams();
|
||||
const { result } = renderHook(() => useGetCompositeQueryParam());
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
convertAggregationToExpression,
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
convertHavingToExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { useMemo } from 'react';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const useGetCompositeQueryParam = (): Query | null => {
|
||||
@@ -14,59 +9,9 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
|
||||
return useMemo(() => {
|
||||
const compositeQuery = urlQuery.get(QueryParams.compositeQuery);
|
||||
let parsedCompositeQuery: Query | null = null;
|
||||
|
||||
try {
|
||||
if (!compositeQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// MDN reference - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#decoding_query_parameters_from_a_url
|
||||
// MDN reference to support + characters using encoding - https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs add later
|
||||
parsedCompositeQuery = JSON.parse(
|
||||
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
|
||||
);
|
||||
|
||||
// Convert old format to new format for each query in builder.queryData
|
||||
if (parsedCompositeQuery?.builder?.queryData) {
|
||||
parsedCompositeQuery.builder.queryData =
|
||||
parsedCompositeQuery.builder.queryData.map((query) => {
|
||||
const existingExpression = query.filter?.expression || '';
|
||||
const convertedQuery = { ...query };
|
||||
|
||||
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
|
||||
query.filters || { items: [], op: 'AND' },
|
||||
existingExpression,
|
||||
);
|
||||
convertedQuery.filter = convertedFilter.filter;
|
||||
convertedQuery.filters = convertedFilter.filters;
|
||||
|
||||
// Convert having if needed
|
||||
if (Array.isArray(query.having)) {
|
||||
const convertedHaving = convertHavingToExpression(query.having);
|
||||
convertedQuery.having = convertedHaving;
|
||||
}
|
||||
|
||||
// Convert aggregation if needed
|
||||
if (!query.aggregations && query.aggregateOperator) {
|
||||
const convertedAggregation = convertAggregationToExpression({
|
||||
aggregateOperator: query.aggregateOperator,
|
||||
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
|
||||
dataSource: query.dataSource,
|
||||
timeAggregation: query.timeAggregation,
|
||||
spaceAggregation: query.spaceAggregation,
|
||||
reduceTo: query.reduceTo,
|
||||
temporality: query.temporality,
|
||||
}) as any; // Type assertion to handle union type
|
||||
convertedQuery.aggregations = convertedAggregation;
|
||||
}
|
||||
return convertedQuery;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
parsedCompositeQuery = null;
|
||||
if (!compositeQuery) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedCompositeQuery;
|
||||
return deserialize(compositeQuery);
|
||||
}, [urlQuery]);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { deserialize } from 'lib/compositeQuery/serializer';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
|
||||
import { cloneDeep, isEqual } from 'lodash-es';
|
||||
import { withBasePath } from 'utils/basePath';
|
||||
|
||||
interface NavigateOptions {
|
||||
@@ -38,16 +39,20 @@ const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
|
||||
return false;
|
||||
}
|
||||
|
||||
const decoded1 = JSON.parse(decodeURIComponent(query1));
|
||||
const decoded2 = JSON.parse(decodeURIComponent(query2));
|
||||
// Decode through the serializer seam: the URL value may be in any
|
||||
// tier (legacy JSON or a tagged format), so never JSON.parse it raw.
|
||||
const decoded1 = deserialize(query1);
|
||||
const decoded2 = deserialize(query2);
|
||||
|
||||
const filtered1 = cloneDeep(decoded1);
|
||||
const filtered2 = cloneDeep(decoded2);
|
||||
if (!decoded1 || !decoded2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
delete filtered1.id;
|
||||
delete filtered2.id;
|
||||
// Ignore the volatile `id` when comparing queries.
|
||||
const { id: _id1, ...rest1 } = decoded1;
|
||||
const { id: _id2, ...rest2 } = decoded2;
|
||||
|
||||
return isEqual(filtered1, filtered2);
|
||||
return isEqual(rest1, rest2);
|
||||
} catch (error) {
|
||||
console.warn('Error comparing compositeQuery:', error);
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
//
|
||||
// ╔════════════════════════════════════════════════════════════════════════════╗
|
||||
// ║ ⚠️ NEVER UPDATE THESE ⚠️ ║
|
||||
// ╠════════════════════════════════════════════════════════════════════════════╣
|
||||
// ║ These snapshots guard URL backward compatibility. Every emitted URL ║
|
||||
// ║ encodes a diff against these exact baselines. ║
|
||||
// ║ ║
|
||||
// ║ If a test fails: REVERT baseline.ts, do NOT update snapshots. ║
|
||||
// ║ If you need a new schema: create BASELINE_V2 + new adapter prefix. ║
|
||||
// ╚════════════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
exports[`baseline immutability snapshots LOGS_BASELINE must never change 1`] = `
|
||||
{
|
||||
"builder": {
|
||||
"queryData": [
|
||||
{
|
||||
"aggregateAttribute": {
|
||||
"dataType": "",
|
||||
"id": "----",
|
||||
"key": "",
|
||||
"type": "",
|
||||
},
|
||||
"aggregateOperator": null,
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count() ",
|
||||
},
|
||||
],
|
||||
"dataSource": "logs",
|
||||
"disabled": false,
|
||||
"expression": "A",
|
||||
"filter": {
|
||||
"expression": "",
|
||||
},
|
||||
"filters": {
|
||||
"items": [],
|
||||
"op": "AND",
|
||||
},
|
||||
"functions": [],
|
||||
"groupBy": [],
|
||||
"having": [],
|
||||
"legend": "",
|
||||
"limit": null,
|
||||
"orderBy": [],
|
||||
"queryName": "A",
|
||||
"reduceTo": "avg",
|
||||
"source": null,
|
||||
"spaceAggregation": "sum",
|
||||
"stepInterval": null,
|
||||
"timeAggregation": "rate",
|
||||
},
|
||||
],
|
||||
"queryFormulas": [],
|
||||
"queryTraceOperator": [],
|
||||
},
|
||||
"clickhouse_sql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": "",
|
||||
},
|
||||
],
|
||||
"id": "",
|
||||
"promql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": "",
|
||||
},
|
||||
],
|
||||
"queryType": "builder",
|
||||
"unit": "",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`baseline immutability snapshots METRICS_BASELINE must never change 1`] = `
|
||||
{
|
||||
"builder": {
|
||||
"queryData": [
|
||||
{
|
||||
"aggregateAttribute": {
|
||||
"dataType": "",
|
||||
"id": "----",
|
||||
"key": "",
|
||||
"type": "",
|
||||
},
|
||||
"aggregateOperator": "noop",
|
||||
"aggregations": [
|
||||
{
|
||||
"metricName": "",
|
||||
"reduceTo": "avg",
|
||||
"spaceAggregation": "sum",
|
||||
"temporality": "",
|
||||
"timeAggregation": "avg",
|
||||
},
|
||||
],
|
||||
"dataSource": "metrics",
|
||||
"disabled": false,
|
||||
"expression": "A",
|
||||
"filter": {
|
||||
"expression": "",
|
||||
},
|
||||
"filters": {
|
||||
"items": [],
|
||||
"op": "AND",
|
||||
},
|
||||
"functions": [],
|
||||
"groupBy": [],
|
||||
"having": [],
|
||||
"legend": "",
|
||||
"limit": null,
|
||||
"orderBy": [],
|
||||
"queryName": "A",
|
||||
"reduceTo": "avg",
|
||||
"source": null,
|
||||
"spaceAggregation": "sum",
|
||||
"stepInterval": null,
|
||||
"timeAggregation": "rate",
|
||||
},
|
||||
],
|
||||
"queryFormulas": [],
|
||||
"queryTraceOperator": [],
|
||||
},
|
||||
"clickhouse_sql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": "",
|
||||
},
|
||||
],
|
||||
"id": "",
|
||||
"promql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": "",
|
||||
},
|
||||
],
|
||||
"queryType": "builder",
|
||||
"unit": "",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`baseline immutability snapshots TRACES_BASELINE must never change 1`] = `
|
||||
{
|
||||
"builder": {
|
||||
"queryData": [
|
||||
{
|
||||
"aggregateAttribute": {
|
||||
"dataType": "",
|
||||
"id": "----",
|
||||
"key": "",
|
||||
"type": "",
|
||||
},
|
||||
"aggregateOperator": null,
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count() ",
|
||||
},
|
||||
],
|
||||
"dataSource": "traces",
|
||||
"disabled": false,
|
||||
"expression": "A",
|
||||
"filter": {
|
||||
"expression": "",
|
||||
},
|
||||
"filters": {
|
||||
"items": [],
|
||||
"op": "AND",
|
||||
},
|
||||
"functions": [],
|
||||
"groupBy": [],
|
||||
"having": [],
|
||||
"legend": "",
|
||||
"limit": null,
|
||||
"orderBy": [],
|
||||
"queryName": "A",
|
||||
"reduceTo": "avg",
|
||||
"source": null,
|
||||
"spaceAggregation": "sum",
|
||||
"stepInterval": null,
|
||||
"timeAggregation": "rate",
|
||||
},
|
||||
],
|
||||
"queryFormulas": [],
|
||||
"queryTraceOperator": [],
|
||||
},
|
||||
"clickhouse_sql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": "",
|
||||
},
|
||||
],
|
||||
"id": "",
|
||||
"promql": [
|
||||
{
|
||||
"disabled": false,
|
||||
"legend": "",
|
||||
"name": "A",
|
||||
"query": "",
|
||||
},
|
||||
],
|
||||
"queryType": "builder",
|
||||
"unit": "",
|
||||
}
|
||||
`;
|
||||
123
frontend/src/lib/compositeQuery/__tests__/baseline.test.ts
Normal file
123
frontend/src/lib/compositeQuery/__tests__/baseline.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* ╔════════════════════════════════════════════════════════════════════════════╗
|
||||
* ║ ⚠️ CRITICAL WARNING ⚠️ ║
|
||||
* ╠════════════════════════════════════════════════════════════════════════════╣
|
||||
* ║ These baselines are FROZEN FOREVER. They must NEVER be modified. ║
|
||||
* ║ ║
|
||||
* ║ WHY: Every URL ever emitted by the compositeQuery serializer encodes a ║
|
||||
* ║ diff against these exact baselines. Changing a single byte here silently ║
|
||||
* ║ BREAKS ALL EXISTING URLs — dashboards, saved views, shared links, etc. ║
|
||||
* ║ ║
|
||||
* ║ If these snapshot tests fail: ║
|
||||
* ║ 1. DO NOT update the snapshots ║
|
||||
* ║ 2. REVERT your changes to baseline.ts immediately ║
|
||||
* ║ 3. If you need a new schema, create a NEW versioned baseline: ║
|
||||
* ║ - METRICS_BASELINE_V2, LOGS_BASELINE_V2, TRACES_BASELINE_V2 ║
|
||||
* ║ - Create a new adapter (e.g., V2~) that uses the new baselines ║
|
||||
* ║ - Keep the old baselines untouched for backwards compatibility ║
|
||||
* ╚════════════════════════════════════════════════════════════════════════════╝
|
||||
*/
|
||||
|
||||
import getBaselineByTag, {
|
||||
LOGS_BASELINE,
|
||||
METRICS_BASELINE,
|
||||
pickBaseline,
|
||||
TRACES_BASELINE,
|
||||
} from '../baseline';
|
||||
|
||||
describe('baseline immutability snapshots', () => {
|
||||
/**
|
||||
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
|
||||
* If this fails, you broke URL compatibility. Revert your changes.
|
||||
*/
|
||||
it('METRICS_BASELINE must never change', () => {
|
||||
expect(METRICS_BASELINE).toMatchSnapshot();
|
||||
});
|
||||
|
||||
/**
|
||||
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
|
||||
* If this fails, you broke URL compatibility. Revert your changes.
|
||||
*/
|
||||
it('LOGS_BASELINE must never change', () => {
|
||||
expect(LOGS_BASELINE).toMatchSnapshot();
|
||||
});
|
||||
|
||||
/**
|
||||
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
|
||||
* If this fails, you broke URL compatibility. Revert your changes.
|
||||
*/
|
||||
it('TRACES_BASELINE must never change', () => {
|
||||
expect(TRACES_BASELINE).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickBaseline', () => {
|
||||
it('returns metrics baseline for metrics dataSource', () => {
|
||||
const query = {
|
||||
builder: { queryData: [{ dataSource: 'metrics' }] },
|
||||
} as any;
|
||||
|
||||
const result = pickBaseline(query);
|
||||
|
||||
expect(result.baseline).toBe(METRICS_BASELINE);
|
||||
expect(result.tag).toBe('m');
|
||||
});
|
||||
|
||||
it('returns logs baseline for logs dataSource', () => {
|
||||
const query = {
|
||||
builder: { queryData: [{ dataSource: 'logs' }] },
|
||||
} as any;
|
||||
|
||||
const result = pickBaseline(query);
|
||||
|
||||
expect(result.baseline).toBe(LOGS_BASELINE);
|
||||
expect(result.tag).toBe('l');
|
||||
});
|
||||
|
||||
it('returns traces baseline for traces dataSource', () => {
|
||||
const query = {
|
||||
builder: { queryData: [{ dataSource: 'traces' }] },
|
||||
} as any;
|
||||
|
||||
const result = pickBaseline(query);
|
||||
|
||||
expect(result.baseline).toBe(TRACES_BASELINE);
|
||||
expect(result.tag).toBe('t');
|
||||
});
|
||||
|
||||
it('defaults to metrics baseline for unknown dataSource', () => {
|
||||
const query = {
|
||||
builder: { queryData: [{ dataSource: 'unknown' }] },
|
||||
} as any;
|
||||
|
||||
const result = pickBaseline(query);
|
||||
|
||||
expect(result.baseline).toBe(METRICS_BASELINE);
|
||||
expect(result.tag).toBe('m');
|
||||
});
|
||||
|
||||
it('defaults to metrics baseline when queryData is empty', () => {
|
||||
const query = {
|
||||
builder: { queryData: [] },
|
||||
} as any;
|
||||
|
||||
const result = pickBaseline(query);
|
||||
|
||||
expect(result.baseline).toBe(METRICS_BASELINE);
|
||||
expect(result.tag).toBe('m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBaselineByTag', () => {
|
||||
it('returns LOGS_BASELINE for tag "l"', () => {
|
||||
expect(getBaselineByTag('l')).toBe(LOGS_BASELINE);
|
||||
});
|
||||
|
||||
it('returns TRACES_BASELINE for tag "t"', () => {
|
||||
expect(getBaselineByTag('t')).toBe(TRACES_BASELINE);
|
||||
});
|
||||
|
||||
it('returns METRICS_BASELINE for tag "m"', () => {
|
||||
expect(getBaselineByTag('m')).toBe(METRICS_BASELINE);
|
||||
});
|
||||
});
|
||||
40
frontend/src/lib/compositeQuery/__tests__/serializer.test.ts
Normal file
40
frontend/src/lib/compositeQuery/__tests__/serializer.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { deserialize, serialize } from 'lib/compositeQuery/serializer';
|
||||
|
||||
describe('composite query serializer', () => {
|
||||
it('serialize picks shortest format', () => {
|
||||
const query = initialQueriesMap.metrics;
|
||||
const serialized = serialize(query);
|
||||
const jsonSerialized = encodeURIComponent(JSON.stringify(query));
|
||||
// Serializer should pick a format shorter than or equal to raw JSON
|
||||
expect(serialized.length).toBeLessThanOrEqual(jsonSerialized.length);
|
||||
// Should use a tagged format with baseline indicator (m/l/t)
|
||||
const usesTaggedFormat =
|
||||
/^V1[mlt]~/.test(serialized) ||
|
||||
/^TV[mlt]~/.test(serialized) ||
|
||||
/^FV[mlt]~/.test(serialized) ||
|
||||
/^FK[mlt]~/.test(serialized);
|
||||
expect(usesTaggedFormat).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trips through serialize/deserialize', () => {
|
||||
const query = initialQueriesMap.logs;
|
||||
const decoded = deserialize(serialize(query));
|
||||
expect(decoded?.builder.queryData[0].dataSource).toBe('logs');
|
||||
});
|
||||
|
||||
it('returns null on corrupt input instead of throwing', () => {
|
||||
expect(deserialize('%7Bnot-json')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for empty/missing value', () => {
|
||||
expect(deserialize('')).toBeNull();
|
||||
});
|
||||
|
||||
it('preserves id field through roundtrip', () => {
|
||||
const query = { ...initialQueriesMap.metrics, id: 'test-query-uuid-123' };
|
||||
const serialized = serialize(query);
|
||||
const decoded = deserialize(serialized);
|
||||
expect(decoded?.id).toBe('test-query-uuid-123');
|
||||
});
|
||||
});
|
||||
166
frontend/src/lib/compositeQuery/adapters/flatKeys/codec.ts
Normal file
166
frontend/src/lib/compositeQuery/adapters/flatKeys/codec.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Flat-keys codec: a baseline-diff serializer that shortens keys but keeps
|
||||
* values as raw JSON. Simpler than flat-leaf — no field-aware enum compression.
|
||||
*
|
||||
* Wire grammar (tokens joined by `*`):
|
||||
* set: <shortPath>_<escapedJSON> e.g. b.qd.0.ds_"logs"
|
||||
* delete: -<shortPath> e.g. -b.qd.0.ag.0.mn
|
||||
*/
|
||||
import getBaselineByTag, {
|
||||
BaselineTag,
|
||||
pickBaseline,
|
||||
} from 'lib/compositeQuery/baseline';
|
||||
import { INVERSE_KEY_MAP, KEY_MAP } from './maps';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
type Json = unknown;
|
||||
type PathSeg = string | number;
|
||||
|
||||
const PAIR = '*';
|
||||
const KV = '_';
|
||||
const DEL = '-';
|
||||
|
||||
const isIndex = (seg: string): boolean => /^\d+$/.test(seg);
|
||||
|
||||
const isContainer = (value: Json): value is Record<string, Json> | Json[] =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const isEmptyContainer = (value: Json): boolean =>
|
||||
isContainer(value) &&
|
||||
(Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
|
||||
|
||||
const isLeaf = (value: Json): boolean =>
|
||||
!isContainer(value) || isEmptyContainer(value);
|
||||
|
||||
const shortenSeg = (seg: PathSeg): PathSeg =>
|
||||
typeof seg === 'number' ? seg : (KEY_MAP[seg] ?? seg);
|
||||
|
||||
function leafMap(obj: Json): Record<string, Json> {
|
||||
const out: Record<string, Json> = {};
|
||||
const walk = (node: Json, segs: PathSeg[]): void => {
|
||||
if (isLeaf(node)) {
|
||||
out[segs.map(shortenSeg).join('.')] = node;
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((value, index) => walk(value, [...segs, index]));
|
||||
return;
|
||||
}
|
||||
Object.entries(node as Record<string, Json>).forEach(([key, value]) =>
|
||||
walk(value, [...segs, key]),
|
||||
);
|
||||
};
|
||||
walk(obj, []);
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeValue(str: string): string {
|
||||
let escaped = str.replace(/_/g, '__').replace(/\*/g, '_s');
|
||||
if (escaped[0] === '!') {
|
||||
escaped = `_i${escaped.slice(1)}`;
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
const UNESCAPE: Record<string, string> = { _: '_', s: '*', i: '!' };
|
||||
|
||||
function unescapeValue(str: string): string {
|
||||
let out = '';
|
||||
for (let i = 0; i < str.length; i += 1) {
|
||||
if (str[i] === '_') {
|
||||
const next = str[i + 1];
|
||||
out += UNESCAPE[next] ?? next;
|
||||
i += 1;
|
||||
} else {
|
||||
out += str[i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeLeaf(value: Json): string {
|
||||
if (value === undefined) {
|
||||
return escapeValue('null');
|
||||
}
|
||||
return escapeValue(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function decodeLeaf(token: string): Json {
|
||||
return JSON.parse(unescapeValue(token));
|
||||
}
|
||||
|
||||
function parsePath(pathStr: string): PathSeg[] {
|
||||
return pathStr
|
||||
.split('.')
|
||||
.map((seg) => (isIndex(seg) ? Number(seg) : (INVERSE_KEY_MAP[seg] ?? seg)));
|
||||
}
|
||||
|
||||
function setPath(
|
||||
root: Record<string, Json>,
|
||||
segs: PathSeg[],
|
||||
value: Json,
|
||||
): void {
|
||||
let node: Json = root;
|
||||
for (let i = 0; i < segs.length - 1; i += 1) {
|
||||
const seg = segs[i];
|
||||
const container = node as Record<string | number, Json>;
|
||||
if (!isContainer(container[seg])) {
|
||||
container[seg] = typeof segs[i + 1] === 'number' ? [] : {};
|
||||
}
|
||||
node = container[seg];
|
||||
}
|
||||
(node as Record<string | number, Json>)[segs[segs.length - 1]] = value;
|
||||
}
|
||||
|
||||
function rebuildFromLeaves(map: Record<string, Json>): Record<string, Json> {
|
||||
const root: Record<string, Json> = {};
|
||||
Object.entries(map).forEach(([path, value]) => {
|
||||
setPath(root, parsePath(path), value);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
export function encode(query: Query): { payload: string; tag: BaselineTag } {
|
||||
const { baseline, tag } = pickBaseline(query);
|
||||
const base = leafMap(baseline);
|
||||
const next = leafMap(query);
|
||||
const tokens: string[] = [];
|
||||
Object.entries(next).forEach(([path, value]) => {
|
||||
const baseVal = base[path];
|
||||
const normalizedBase = baseVal === undefined ? null : baseVal;
|
||||
const normalizedNext = value === undefined ? null : value;
|
||||
if (
|
||||
path in base &&
|
||||
JSON.stringify(normalizedBase) === JSON.stringify(normalizedNext)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
tokens.push(`${path}${KV}${encodeLeaf(value)}`);
|
||||
});
|
||||
Object.keys(base).forEach((path) => {
|
||||
if (!(path in next)) {
|
||||
tokens.push(`${DEL}${path}`);
|
||||
}
|
||||
});
|
||||
tokens.sort();
|
||||
return { payload: tokens.join(PAIR), tag };
|
||||
}
|
||||
|
||||
export function decode(payload: string, tag: BaselineTag): Query {
|
||||
const map = leafMap(getBaselineByTag(tag));
|
||||
if (payload) {
|
||||
payload.split(PAIR).forEach((token) => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
if (token[0] === DEL) {
|
||||
delete map[token.slice(1)];
|
||||
return;
|
||||
}
|
||||
const sep = token.indexOf(KV);
|
||||
const path = token.slice(0, sep);
|
||||
map[path] = decodeLeaf(token.slice(sep + 1));
|
||||
});
|
||||
}
|
||||
return rebuildFromLeaves(map) as unknown as Query;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { roundTripScenarios } from '../testing/scenarios';
|
||||
import { decodeFlatKeys, encodeFlatKeys, flatKeysAdapter } from './index';
|
||||
|
||||
const roundTrip = (query: Query): Query =>
|
||||
flatKeysAdapter.decode(flatKeysAdapter.encode(query));
|
||||
|
||||
const clone = (query: Query): Query =>
|
||||
JSON.parse(JSON.stringify(query)) as Query;
|
||||
|
||||
describe('flatKeysAdapter', () => {
|
||||
describe('round-trip scenarios', () => {
|
||||
it.each(roundTripScenarios)('$name', ({ query }) => {
|
||||
expect(roundTrip(query)).toStrictEqual(query);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag matching', () => {
|
||||
it('tags output with FKm~/FKl~/FKt~', () => {
|
||||
const metricsEncoded = flatKeysAdapter.encode(initialQueriesMap.metrics);
|
||||
expect(metricsEncoded.startsWith('FKm~')).toBe(true);
|
||||
|
||||
const logsEncoded = flatKeysAdapter.encode(initialQueriesMap.logs);
|
||||
expect(logsEncoded.startsWith('FKl~')).toBe(true);
|
||||
|
||||
const tracesEncoded = flatKeysAdapter.encode(initialQueriesMap.traces);
|
||||
expect(tracesEncoded.startsWith('FKt~')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches only own tags', () => {
|
||||
const encoded = flatKeysAdapter.encode(initialQueriesMap.metrics);
|
||||
expect(flatKeysAdapter.matches(encoded)).toBe(true);
|
||||
expect(flatKeysAdapter.matches('FVm~')).toBe(false);
|
||||
expect(flatKeysAdapter.matches('%7Bnot-mine')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('baseline behavior', () => {
|
||||
it('uses correct baseline tag per dataSource', () => {
|
||||
expect(encodeFlatKeys(initialQueriesMap.logs).tag).toBe('l');
|
||||
expect(encodeFlatKeys(initialQueriesMap.traces).tag).toBe('t');
|
||||
expect(encodeFlatKeys(initialQueriesMap.metrics).tag).toBe('m');
|
||||
});
|
||||
|
||||
it('decodeFlatKeys on empty payload returns baseline', () => {
|
||||
const decodedMetrics = decodeFlatKeys('', 'm');
|
||||
expect(decodedMetrics.queryType).toBe('builder');
|
||||
expect(decodedMetrics.builder.queryData[0].dataSource).toBe('metrics');
|
||||
|
||||
const decodedLogs = decodeFlatKeys('', 'l');
|
||||
expect(decodedLogs.builder.queryData[0].dataSource).toBe('logs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('encoding stability', () => {
|
||||
it('identical encoding after roundtrip', () => {
|
||||
const query = initialQueriesMap.metrics;
|
||||
const encoded1 = flatKeysAdapter.encode(query);
|
||||
const decoded = flatKeysAdapter.decode(encoded1);
|
||||
const encoded2 = flatKeysAdapter.encode(decoded);
|
||||
|
||||
expect(encoded2).toBe(encoded1);
|
||||
});
|
||||
|
||||
it('key order independent', () => {
|
||||
const query1 = initialQueriesMap.metrics;
|
||||
const query2 = JSON.parse(JSON.stringify(query1)) as Query;
|
||||
|
||||
const reordered = {
|
||||
unit: query2.unit,
|
||||
id: query2.id,
|
||||
queryType: query2.queryType,
|
||||
clickhouse_sql: query2.clickhouse_sql,
|
||||
promql: query2.promql,
|
||||
builder: query2.builder,
|
||||
} as Query;
|
||||
|
||||
const encoded1 = flatKeysAdapter.encode(query1);
|
||||
const encoded2 = flatKeysAdapter.encode(reordered);
|
||||
|
||||
expect(encoded2).toBe(encoded1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('undefined handling', () => {
|
||||
it('handles undefined values without breaking decode', () => {
|
||||
const query = clone(initialQueriesMap.logs);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(query.builder.queryData[0] as any).aggregateOperator = undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(query.builder.queryData[0] as any).source = undefined;
|
||||
|
||||
const encoded = flatKeysAdapter.encode(query);
|
||||
const decoded = flatKeysAdapter.decode(encoded);
|
||||
expect(decoded).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
32
frontend/src/lib/compositeQuery/adapters/flatKeys/index.ts
Normal file
32
frontend/src/lib/compositeQuery/adapters/flatKeys/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { BaselineTag } from 'lib/compositeQuery/baseline';
|
||||
import { decode, encode } from './codec';
|
||||
import { CompositeQueryAdapter } from './types';
|
||||
|
||||
const TAG_PREFIX = 'FK';
|
||||
const TAG_SUFFIX = '~';
|
||||
|
||||
/**
|
||||
* Flat-keys (FK~): stores a query as a keys-only, leaf-flattened diff from the
|
||||
* frozen baseline. Uses short keys but keeps values as raw JSON (no field-aware
|
||||
* enum compression).
|
||||
*
|
||||
* Useful for benchmarking: isolates contribution of key shortening from value compression.
|
||||
*/
|
||||
export const flatKeysAdapter: CompositeQueryAdapter = {
|
||||
name: 'flat-keys',
|
||||
encode: (query) => {
|
||||
const { payload, tag } = encode(query);
|
||||
return `${TAG_PREFIX}${tag}${TAG_SUFFIX}${payload}`;
|
||||
},
|
||||
matches: (raw) =>
|
||||
raw.startsWith(`${TAG_PREFIX}m${TAG_SUFFIX}`) ||
|
||||
raw.startsWith(`${TAG_PREFIX}l${TAG_SUFFIX}`) ||
|
||||
raw.startsWith(`${TAG_PREFIX}t${TAG_SUFFIX}`),
|
||||
decode: (raw) => {
|
||||
const tag = raw[2] as BaselineTag;
|
||||
const payload = raw.slice(4);
|
||||
return decode(payload, tag);
|
||||
},
|
||||
};
|
||||
|
||||
export { encode as encodeFlatKeys, decode as decodeFlatKeys } from './codec';
|
||||
51
frontend/src/lib/compositeQuery/adapters/flatKeys/maps.ts
Normal file
51
frontend/src/lib/compositeQuery/adapters/flatKeys/maps.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Key shortening map for flatKeys adapter.
|
||||
* No value compression - only key shortening.
|
||||
*/
|
||||
export const KEY_MAP: Record<string, string> = {
|
||||
queryType: 'qt',
|
||||
builder: 'b',
|
||||
queryData: 'qd',
|
||||
queryFormulas: 'qf',
|
||||
queryTraceOperator: 'qo',
|
||||
dataSource: 'ds',
|
||||
queryName: 'qn',
|
||||
aggregateOperator: 'ao',
|
||||
aggregateAttribute: 'aa',
|
||||
timeAggregation: 'ta',
|
||||
spaceAggregation: 'sa',
|
||||
filter: 'f',
|
||||
expression: 'e',
|
||||
aggregations: 'ag',
|
||||
functions: 'fn',
|
||||
filters: 'fl',
|
||||
items: 'i',
|
||||
disabled: 'd',
|
||||
stepInterval: 'si',
|
||||
having: 'h',
|
||||
limit: 'l',
|
||||
orderBy: 'ob',
|
||||
groupBy: 'gb',
|
||||
legend: 'lg',
|
||||
reduceTo: 'rt',
|
||||
source: 's',
|
||||
promql: 'pq',
|
||||
clickhouse_sql: 'cs',
|
||||
name: 'n',
|
||||
query: 'q',
|
||||
key: 'k',
|
||||
dataType: 'dt',
|
||||
type: 't',
|
||||
metricName: 'mn',
|
||||
temporality: 'tp',
|
||||
columnName: 'cn',
|
||||
order: 'o',
|
||||
value: 'v',
|
||||
op: 'op',
|
||||
isColumn: 'ic',
|
||||
isJSON: 'ij',
|
||||
};
|
||||
|
||||
export const INVERSE_KEY_MAP: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(KEY_MAP).map(([long, short]) => [short, long]),
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface CompositeQueryAdapter {
|
||||
readonly name: string;
|
||||
encode(query: Query): string;
|
||||
matches(raw: string): boolean;
|
||||
decode(raw: string): Query;
|
||||
}
|
||||
197
frontend/src/lib/compositeQuery/adapters/flatLeaf/codec.ts
Normal file
197
frontend/src/lib/compositeQuery/adapters/flatLeaf/codec.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Flat-leaf codec: a baseline-diff serializer that emits one token per changed
|
||||
* scalar leaf, with no JSON wrapper characters on the wire.
|
||||
*
|
||||
* Both the baseline and the query are flattened to maps of
|
||||
* `{ shortDotPath: scalarLeaf }` (arrays walked element-wise, so `queryData[0]`
|
||||
* becomes `qd.0`). Encode diffs the two maps and emits a token per changed or
|
||||
* removed leaf; decode replays those tokens onto the baseline map and rebuilds
|
||||
* the nested object. Because arrays are walked element-wise, adding one filter
|
||||
* costs a few scalar tokens instead of a whole-array JSON blob.
|
||||
*
|
||||
* Wire grammar (tokens joined by `*`):
|
||||
* set: <shortPath>_<encodedLeaf> e.g. b.qd.0.ds_0
|
||||
* delete: -<shortPath> e.g. -b.qd.0.ag.0.mn
|
||||
*/
|
||||
import getBaselineByTag, {
|
||||
BaselineTag,
|
||||
pickBaseline,
|
||||
} from 'lib/compositeQuery/baseline';
|
||||
import {
|
||||
FIELD_DOMAINS,
|
||||
INVERSE_FIELD_DOMAINS,
|
||||
INVERSE_KEY_MAP,
|
||||
KEY_MAP,
|
||||
} from './maps';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
type Json = unknown;
|
||||
type PathSeg = string | number;
|
||||
|
||||
const PAIR = '*';
|
||||
const KV = '_';
|
||||
const DEL = '-';
|
||||
|
||||
const isIndex = (seg: string): boolean => /^\d+$/.test(seg);
|
||||
|
||||
const isContainer = (value: Json): value is Record<string, Json> | Json[] =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const isEmptyContainer = (value: Json): boolean =>
|
||||
isContainer(value) &&
|
||||
(Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
|
||||
|
||||
const isLeaf = (value: Json): boolean =>
|
||||
!isContainer(value) || isEmptyContainer(value);
|
||||
|
||||
const shortenSeg = (seg: PathSeg): PathSeg =>
|
||||
typeof seg === 'number' ? seg : (KEY_MAP[seg] ?? seg);
|
||||
|
||||
const lastSeg = (path: string): string => path.slice(path.lastIndexOf('.') + 1);
|
||||
|
||||
function leafMap(obj: Json): Record<string, Json> {
|
||||
const out: Record<string, Json> = {};
|
||||
const walk = (node: Json, segs: PathSeg[]): void => {
|
||||
if (isLeaf(node)) {
|
||||
out[segs.map(shortenSeg).join('.')] = node;
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((value, index) => walk(value, [...segs, index]));
|
||||
return;
|
||||
}
|
||||
Object.entries(node as Record<string, Json>).forEach(([key, value]) =>
|
||||
walk(value, [...segs, key]),
|
||||
);
|
||||
};
|
||||
walk(obj, []);
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeValue(str: string): string {
|
||||
let escaped = str.replace(/_/g, '__').replace(/\*/g, '_s');
|
||||
if (escaped[0] === '!') {
|
||||
escaped = `_i${escaped.slice(1)}`;
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
const UNESCAPE: Record<string, string> = { _: '_', s: '*', i: '!' };
|
||||
|
||||
function unescapeValue(str: string): string {
|
||||
let out = '';
|
||||
for (let i = 0; i < str.length; i += 1) {
|
||||
if (str[i] === '_') {
|
||||
const next = str[i + 1];
|
||||
out += UNESCAPE[next] ?? next;
|
||||
i += 1;
|
||||
} else {
|
||||
out += str[i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function encodeLeaf(value: Json, shortKey: string): string {
|
||||
if (typeof value === 'string') {
|
||||
const domain = FIELD_DOMAINS[shortKey];
|
||||
if (domain?.[value] !== undefined) {
|
||||
return String(domain[value]);
|
||||
}
|
||||
return escapeValue(value);
|
||||
}
|
||||
if (value === undefined) {
|
||||
return '!null';
|
||||
}
|
||||
return `!${JSON.stringify(value)}`;
|
||||
}
|
||||
|
||||
function decodeLeaf(token: string, shortKey: string): Json {
|
||||
if (token[0] === '!') {
|
||||
return JSON.parse(token.slice(1));
|
||||
}
|
||||
const inverse = INVERSE_FIELD_DOMAINS[shortKey];
|
||||
if (inverse && isIndex(token)) {
|
||||
const mapped = inverse[Number(token)];
|
||||
if (mapped !== undefined) {
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
return unescapeValue(token);
|
||||
}
|
||||
|
||||
function parsePath(pathStr: string): PathSeg[] {
|
||||
return pathStr
|
||||
.split('.')
|
||||
.map((seg) => (isIndex(seg) ? Number(seg) : (INVERSE_KEY_MAP[seg] ?? seg)));
|
||||
}
|
||||
|
||||
function setPath(
|
||||
root: Record<string, Json>,
|
||||
segs: PathSeg[],
|
||||
value: Json,
|
||||
): void {
|
||||
let node: Json = root;
|
||||
for (let i = 0; i < segs.length - 1; i += 1) {
|
||||
const seg = segs[i];
|
||||
const container = node as Record<string | number, Json>;
|
||||
if (!isContainer(container[seg])) {
|
||||
container[seg] = typeof segs[i + 1] === 'number' ? [] : {};
|
||||
}
|
||||
node = container[seg];
|
||||
}
|
||||
(node as Record<string | number, Json>)[segs[segs.length - 1]] = value;
|
||||
}
|
||||
|
||||
function rebuildFromLeaves(map: Record<string, Json>): Record<string, Json> {
|
||||
const root: Record<string, Json> = {};
|
||||
Object.entries(map).forEach(([path, value]) => {
|
||||
setPath(root, parsePath(path), value);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
export function encode(query: Query): { payload: string; tag: BaselineTag } {
|
||||
const { baseline, tag } = pickBaseline(query);
|
||||
const base = leafMap(baseline);
|
||||
const next = leafMap(query);
|
||||
const tokens: string[] = [];
|
||||
Object.entries(next).forEach(([path, value]) => {
|
||||
const baseVal = base[path];
|
||||
const normalizedBase = baseVal === undefined ? null : baseVal;
|
||||
const normalizedNext = value === undefined ? null : value;
|
||||
if (
|
||||
path in base &&
|
||||
JSON.stringify(normalizedBase) === JSON.stringify(normalizedNext)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
tokens.push(`${path}${KV}${encodeLeaf(value, lastSeg(path))}`);
|
||||
});
|
||||
Object.keys(base).forEach((path) => {
|
||||
if (!(path in next)) {
|
||||
tokens.push(`${DEL}${path}`);
|
||||
}
|
||||
});
|
||||
tokens.sort();
|
||||
return { payload: tokens.join(PAIR), tag };
|
||||
}
|
||||
|
||||
export function decode(payload: string, tag: BaselineTag): Query {
|
||||
const map = leafMap(getBaselineByTag(tag));
|
||||
if (payload) {
|
||||
payload.split(PAIR).forEach((token) => {
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
if (token[0] === DEL) {
|
||||
delete map[token.slice(1)];
|
||||
return;
|
||||
}
|
||||
const sep = token.indexOf(KV);
|
||||
const path = token.slice(0, sep);
|
||||
map[path] = decodeLeaf(token.slice(sep + 1), lastSeg(path));
|
||||
});
|
||||
}
|
||||
return rebuildFromLeaves(map) as unknown as Query;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { roundTripScenarios } from '../testing/scenarios';
|
||||
import { decodeFlatLeaf, encodeFlatLeaf, flatLeafAdapter } from './index';
|
||||
|
||||
const roundTrip = (query: Query): Query =>
|
||||
flatLeafAdapter.decode(flatLeafAdapter.encode(query));
|
||||
|
||||
const clone = (query: Query): Query =>
|
||||
JSON.parse(JSON.stringify(query)) as Query;
|
||||
|
||||
describe('flatLeafAdapter', () => {
|
||||
describe('round-trip scenarios', () => {
|
||||
it.each(roundTripScenarios)('$name', ({ query }) => {
|
||||
expect(roundTrip(query)).toStrictEqual(query);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag matching', () => {
|
||||
it('tags output with FVm~/FVl~/FVt~', () => {
|
||||
const metricsEncoded = flatLeafAdapter.encode(initialQueriesMap.metrics);
|
||||
expect(metricsEncoded.startsWith('FVm~')).toBe(true);
|
||||
|
||||
const logsEncoded = flatLeafAdapter.encode(initialQueriesMap.logs);
|
||||
expect(logsEncoded.startsWith('FVl~')).toBe(true);
|
||||
|
||||
const tracesEncoded = flatLeafAdapter.encode(initialQueriesMap.traces);
|
||||
expect(tracesEncoded.startsWith('FVt~')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches only own tags', () => {
|
||||
const encoded = flatLeafAdapter.encode(initialQueriesMap.metrics);
|
||||
expect(flatLeafAdapter.matches(encoded)).toBe(true);
|
||||
expect(flatLeafAdapter.matches('V1m~[]')).toBe(false);
|
||||
expect(flatLeafAdapter.matches('%7Bnot-mine')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('baseline behavior', () => {
|
||||
it('uses correct baseline tag per dataSource', () => {
|
||||
expect(encodeFlatLeaf(initialQueriesMap.logs).tag).toBe('l');
|
||||
expect(encodeFlatLeaf(initialQueriesMap.traces).tag).toBe('t');
|
||||
expect(encodeFlatLeaf(initialQueriesMap.metrics).tag).toBe('m');
|
||||
});
|
||||
|
||||
it('emits no payload when query equals baseline shape', () => {
|
||||
const { payload, tag } = encodeFlatLeaf(initialQueriesMap.logs);
|
||||
expect(tag).toBe('l');
|
||||
expect(decodeFlatLeaf(payload, tag)).toStrictEqual(initialQueriesMap.logs);
|
||||
});
|
||||
|
||||
it('decodeFlatLeaf on empty payload returns baseline', () => {
|
||||
const decodedMetrics = decodeFlatLeaf('', 'm');
|
||||
expect(decodedMetrics.queryType).toBe('builder');
|
||||
expect(decodedMetrics.builder.queryData[0].dataSource).toBe('metrics');
|
||||
|
||||
const decodedLogs = decodeFlatLeaf('', 'l');
|
||||
expect(decodedLogs.builder.queryData[0].dataSource).toBe('logs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('encoding stability', () => {
|
||||
it('identical encoding after roundtrip', () => {
|
||||
const query = initialQueriesMap.metrics;
|
||||
const encoded1 = flatLeafAdapter.encode(query);
|
||||
const decoded = flatLeafAdapter.decode(encoded1);
|
||||
const encoded2 = flatLeafAdapter.encode(decoded);
|
||||
|
||||
expect(encoded2).toBe(encoded1);
|
||||
});
|
||||
|
||||
it('key order independent', () => {
|
||||
const query1 = initialQueriesMap.metrics;
|
||||
const query2 = JSON.parse(JSON.stringify(query1)) as Query;
|
||||
|
||||
const reordered = {
|
||||
unit: query2.unit,
|
||||
id: query2.id,
|
||||
queryType: query2.queryType,
|
||||
clickhouse_sql: query2.clickhouse_sql,
|
||||
promql: query2.promql,
|
||||
builder: query2.builder,
|
||||
} as Query;
|
||||
|
||||
const encoded1 = flatLeafAdapter.encode(query1);
|
||||
const encoded2 = flatLeafAdapter.encode(reordered);
|
||||
|
||||
expect(encoded2).toBe(encoded1);
|
||||
});
|
||||
|
||||
it('stable after spread/reconstruct', () => {
|
||||
const query = { ...initialQueriesMap.metrics };
|
||||
const encoded1 = flatLeafAdapter.encode(query);
|
||||
|
||||
const transformed = {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map((item) => ({
|
||||
...item,
|
||||
})),
|
||||
},
|
||||
};
|
||||
const encoded2 = flatLeafAdapter.encode(transformed);
|
||||
|
||||
expect(encoded2).toBe(encoded1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('key validation', () => {
|
||||
it('decoded query has all keys expected by replaceIncorrectObjectFields', () => {
|
||||
const decoded = flatLeafAdapter.decode(
|
||||
flatLeafAdapter.encode(initialQueriesMap.metrics),
|
||||
);
|
||||
const decodedKeys = Object.keys(decoded).sort();
|
||||
const expectedKeys = Object.keys(initialQueriesMap.metrics).sort();
|
||||
|
||||
expect(decodedKeys).toStrictEqual(expectedKeys);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEqual compatibility', () => {
|
||||
it('lodash isEqual works for decoded queries', () => {
|
||||
const query = initialQueriesMap.metrics;
|
||||
const encoded = flatLeafAdapter.encode(query);
|
||||
const decoded = flatLeafAdapter.decode(encoded);
|
||||
|
||||
const { id: _id1, ...rest1 } = query;
|
||||
const { id: _id2, ...rest2 } = decoded;
|
||||
|
||||
expect(isEqual(rest1, rest2)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('undefined handling', () => {
|
||||
it('handles undefined values without breaking decode', () => {
|
||||
const query = clone(initialQueriesMap.logs);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(query.builder.queryData[0] as any).aggregateOperator = undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(query.builder.queryData[0] as any).source = undefined;
|
||||
|
||||
const encoded = flatLeafAdapter.encode(query);
|
||||
const decoded = flatLeafAdapter.decode(encoded);
|
||||
expect(decoded).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
30
frontend/src/lib/compositeQuery/adapters/flatLeaf/index.ts
Normal file
30
frontend/src/lib/compositeQuery/adapters/flatLeaf/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { BaselineTag } from 'lib/compositeQuery/baseline';
|
||||
import { decode, encode } from './codec';
|
||||
import { CompositeQueryAdapter } from './types';
|
||||
|
||||
const TAG_PREFIX = 'FV';
|
||||
const TAG_SUFFIX = '~';
|
||||
|
||||
/**
|
||||
* Flat-leaf (FV~): stores a query as a field-aware, leaf-flattened diff from the
|
||||
* frozen baseline. Uses the smart hybrid strategy — metrics or logs baseline
|
||||
* picked by dataSource — so the tag is FVm~ (metrics) or FVl~ (logs).
|
||||
*/
|
||||
export const flatLeafAdapter: CompositeQueryAdapter = {
|
||||
name: 'flat-leaf',
|
||||
encode: (query) => {
|
||||
const { payload, tag } = encode(query);
|
||||
return `${TAG_PREFIX}${tag}${TAG_SUFFIX}${payload}`;
|
||||
},
|
||||
matches: (raw) =>
|
||||
raw.startsWith(`${TAG_PREFIX}m${TAG_SUFFIX}`) ||
|
||||
raw.startsWith(`${TAG_PREFIX}l${TAG_SUFFIX}`) ||
|
||||
raw.startsWith(`${TAG_PREFIX}t${TAG_SUFFIX}`),
|
||||
decode: (raw) => {
|
||||
const tag = raw[2] as BaselineTag;
|
||||
const payload = raw.slice(4);
|
||||
return decode(payload, tag);
|
||||
},
|
||||
};
|
||||
|
||||
export { encode as encodeFlatLeaf, decode as decodeFlatLeaf } from './codec';
|
||||
197
frontend/src/lib/compositeQuery/adapters/flatLeaf/maps.ts
Normal file
197
frontend/src/lib/compositeQuery/adapters/flatLeaf/maps.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
MetrictypesSpaceAggregationDTO,
|
||||
MetrictypesTemporalityDTO,
|
||||
MetrictypesTimeAggregationDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
AutocompleteType,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import {
|
||||
DataSource,
|
||||
LogsAggregatorOperator,
|
||||
MetricAggregateOperator,
|
||||
ReduceOperators,
|
||||
TracesAggregatorOperator,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
export const KEY_MAP: Record<string, string> = {
|
||||
queryType: 'qt',
|
||||
builder: 'b',
|
||||
queryData: 'qd',
|
||||
queryFormulas: 'qf',
|
||||
queryTraceOperator: 'qo',
|
||||
dataSource: 'ds',
|
||||
queryName: 'qn',
|
||||
aggregateOperator: 'ao',
|
||||
aggregateAttribute: 'aa',
|
||||
timeAggregation: 'ta',
|
||||
spaceAggregation: 'sa',
|
||||
filter: 'f',
|
||||
expression: 'e',
|
||||
aggregations: 'ag',
|
||||
functions: 'fn',
|
||||
filters: 'fl',
|
||||
items: 'i',
|
||||
disabled: 'd',
|
||||
stepInterval: 'si',
|
||||
having: 'h',
|
||||
limit: 'l',
|
||||
orderBy: 'ob',
|
||||
groupBy: 'gb',
|
||||
legend: 'lg',
|
||||
reduceTo: 'rt',
|
||||
source: 's',
|
||||
promql: 'pq',
|
||||
clickhouse_sql: 'cs',
|
||||
name: 'n',
|
||||
query: 'q',
|
||||
key: 'k',
|
||||
dataType: 'dt',
|
||||
type: 't',
|
||||
metricName: 'mn',
|
||||
temporality: 'tp',
|
||||
columnName: 'cn',
|
||||
order: 'o',
|
||||
value: 'v',
|
||||
op: 'op',
|
||||
isColumn: 'ic',
|
||||
isJSON: 'ij',
|
||||
};
|
||||
|
||||
export const INVERSE_KEY_MAP: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(KEY_MAP).map(([long, short]) => [short, long]),
|
||||
);
|
||||
|
||||
const DATA_SOURCE: Record<DataSource, number> = {
|
||||
[DataSource.LOGS]: 0,
|
||||
[DataSource.METRICS]: 1,
|
||||
[DataSource.TRACES]: 2,
|
||||
};
|
||||
|
||||
const QUERY_TYPE: Record<EQueryType, number> = {
|
||||
[EQueryType.QUERY_BUILDER]: 0,
|
||||
[EQueryType.PROM]: 1,
|
||||
[EQueryType.CLICKHOUSE]: 2,
|
||||
};
|
||||
|
||||
const REDUCE_TO: Record<ReduceOperators, number> = {
|
||||
[ReduceOperators.LAST]: 0,
|
||||
[ReduceOperators.SUM]: 1,
|
||||
[ReduceOperators.AVG]: 2,
|
||||
[ReduceOperators.MAX]: 3,
|
||||
[ReduceOperators.MIN]: 4,
|
||||
};
|
||||
|
||||
const TEMPORALITY: Record<MetrictypesTemporalityDTO, number> = {
|
||||
[MetrictypesTemporalityDTO.delta]: 0,
|
||||
[MetrictypesTemporalityDTO.cumulative]: 1,
|
||||
[MetrictypesTemporalityDTO.unspecified]: 2,
|
||||
};
|
||||
|
||||
const TIME_AGGREGATION: Record<MetrictypesTimeAggregationDTO, number> = {
|
||||
[MetrictypesTimeAggregationDTO.latest]: 0,
|
||||
[MetrictypesTimeAggregationDTO.sum]: 1,
|
||||
[MetrictypesTimeAggregationDTO.avg]: 2,
|
||||
[MetrictypesTimeAggregationDTO.min]: 3,
|
||||
[MetrictypesTimeAggregationDTO.max]: 4,
|
||||
[MetrictypesTimeAggregationDTO.count]: 5,
|
||||
[MetrictypesTimeAggregationDTO.count_distinct]: 6,
|
||||
[MetrictypesTimeAggregationDTO.rate]: 7,
|
||||
[MetrictypesTimeAggregationDTO.increase]: 8,
|
||||
};
|
||||
|
||||
const SPACE_AGGREGATION: Record<MetrictypesSpaceAggregationDTO, number> = {
|
||||
[MetrictypesSpaceAggregationDTO.sum]: 0,
|
||||
[MetrictypesSpaceAggregationDTO.avg]: 1,
|
||||
[MetrictypesSpaceAggregationDTO.min]: 2,
|
||||
[MetrictypesSpaceAggregationDTO.max]: 3,
|
||||
[MetrictypesSpaceAggregationDTO.count]: 4,
|
||||
[MetrictypesSpaceAggregationDTO.p50]: 5,
|
||||
[MetrictypesSpaceAggregationDTO.p75]: 6,
|
||||
[MetrictypesSpaceAggregationDTO.p90]: 7,
|
||||
[MetrictypesSpaceAggregationDTO.p95]: 8,
|
||||
[MetrictypesSpaceAggregationDTO.p99]: 9,
|
||||
};
|
||||
|
||||
const AGGREGATE_OPERATOR: Record<
|
||||
MetricAggregateOperator | TracesAggregatorOperator | LogsAggregatorOperator,
|
||||
number
|
||||
> = {
|
||||
[MetricAggregateOperator.EMPTY]: 0,
|
||||
[MetricAggregateOperator.NOOP]: 1,
|
||||
[MetricAggregateOperator.COUNT]: 2,
|
||||
[MetricAggregateOperator.COUNT_DISTINCT]: 3,
|
||||
[MetricAggregateOperator.SUM]: 4,
|
||||
[MetricAggregateOperator.AVG]: 5,
|
||||
[MetricAggregateOperator.MAX]: 6,
|
||||
[MetricAggregateOperator.MIN]: 7,
|
||||
[MetricAggregateOperator.P05]: 8,
|
||||
[MetricAggregateOperator.P10]: 9,
|
||||
[MetricAggregateOperator.P20]: 10,
|
||||
[MetricAggregateOperator.P25]: 11,
|
||||
[MetricAggregateOperator.P50]: 12,
|
||||
[MetricAggregateOperator.P75]: 13,
|
||||
[MetricAggregateOperator.P90]: 14,
|
||||
[MetricAggregateOperator.P95]: 15,
|
||||
[MetricAggregateOperator.P99]: 16,
|
||||
[MetricAggregateOperator.RATE]: 17,
|
||||
[MetricAggregateOperator.SUM_RATE]: 18,
|
||||
[MetricAggregateOperator.AVG_RATE]: 19,
|
||||
[MetricAggregateOperator.MAX_RATE]: 20,
|
||||
[MetricAggregateOperator.MIN_RATE]: 21,
|
||||
[MetricAggregateOperator.RATE_SUM]: 22,
|
||||
[MetricAggregateOperator.RATE_AVG]: 23,
|
||||
[MetricAggregateOperator.RATE_MIN]: 24,
|
||||
[MetricAggregateOperator.RATE_MAX]: 25,
|
||||
[MetricAggregateOperator.HIST_QUANTILE_50]: 26,
|
||||
[MetricAggregateOperator.HIST_QUANTILE_75]: 27,
|
||||
[MetricAggregateOperator.HIST_QUANTILE_90]: 28,
|
||||
[MetricAggregateOperator.HIST_QUANTILE_95]: 29,
|
||||
[MetricAggregateOperator.HIST_QUANTILE_99]: 30,
|
||||
[MetricAggregateOperator.INCREASE]: 31,
|
||||
[MetricAggregateOperator.LATEST]: 32,
|
||||
};
|
||||
|
||||
const DATA_TYPE: Record<DataTypes, number> = {
|
||||
[DataTypes.Int64]: 0,
|
||||
[DataTypes.String]: 1,
|
||||
[DataTypes.Float64]: 2,
|
||||
[DataTypes.bool]: 3,
|
||||
[DataTypes.ArrayFloat64]: 4,
|
||||
[DataTypes.ArrayInt64]: 5,
|
||||
[DataTypes.ArrayString]: 6,
|
||||
[DataTypes.ArrayBool]: 7,
|
||||
[DataTypes.EMPTY]: 8,
|
||||
};
|
||||
|
||||
const ATTR_TYPE: Record<AutocompleteType, number> = {
|
||||
tag: 0,
|
||||
resource: 1,
|
||||
'': 2,
|
||||
};
|
||||
|
||||
export const FIELD_DOMAINS: Record<string, Record<string, number>> = {
|
||||
ds: DATA_SOURCE,
|
||||
qt: QUERY_TYPE,
|
||||
rt: REDUCE_TO,
|
||||
tp: TEMPORALITY,
|
||||
ta: TIME_AGGREGATION,
|
||||
sa: SPACE_AGGREGATION,
|
||||
ao: AGGREGATE_OPERATOR,
|
||||
dt: DATA_TYPE,
|
||||
t: ATTR_TYPE,
|
||||
};
|
||||
|
||||
export const INVERSE_FIELD_DOMAINS: Record<
|
||||
string,
|
||||
Record<number, string>
|
||||
> = Object.fromEntries(
|
||||
Object.entries(FIELD_DOMAINS).map(([field, domain]) => [
|
||||
field,
|
||||
Object.fromEntries(
|
||||
Object.entries(domain).map(([str, int]) => [int, str]),
|
||||
) as Record<number, string>,
|
||||
]),
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface CompositeQueryAdapter {
|
||||
readonly name: string;
|
||||
encode(query: Query): string;
|
||||
matches(raw: string): boolean;
|
||||
decode(raw: string): Query;
|
||||
}
|
||||
55
frontend/src/lib/compositeQuery/adapters/json/index.ts
Normal file
55
frontend/src/lib/compositeQuery/adapters/json/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
convertAggregationToExpression,
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
convertHavingToExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { CompositeQueryAdapter } from './types';
|
||||
|
||||
function migrateLegacyFormat(parsed: Query): Query {
|
||||
if (!parsed?.builder?.queryData) {
|
||||
return parsed;
|
||||
}
|
||||
const next = parsed;
|
||||
next.builder.queryData = parsed.builder.queryData.map((query) => {
|
||||
const existingExpression = query.filter?.expression || '';
|
||||
const convertedQuery = { ...query };
|
||||
|
||||
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
|
||||
query.filters || { items: [], op: 'AND' },
|
||||
existingExpression,
|
||||
);
|
||||
convertedQuery.filter = convertedFilter.filter;
|
||||
convertedQuery.filters = convertedFilter.filters;
|
||||
|
||||
if (Array.isArray(query.having)) {
|
||||
convertedQuery.having = convertHavingToExpression(query.having);
|
||||
}
|
||||
|
||||
if (!query.aggregations && query.aggregateOperator) {
|
||||
convertedQuery.aggregations = convertAggregationToExpression({
|
||||
aggregateOperator: query.aggregateOperator,
|
||||
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
|
||||
dataSource: query.dataSource,
|
||||
timeAggregation: query.timeAggregation,
|
||||
spaceAggregation: query.spaceAggregation,
|
||||
reduceTo: query.reduceTo,
|
||||
temporality: query.temporality,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any;
|
||||
}
|
||||
return convertedQuery;
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
export const jsonAdapter: CompositeQueryAdapter = {
|
||||
name: 'json(legacy)',
|
||||
encode: (query) => encodeURIComponent(JSON.stringify(query)),
|
||||
matches: () => true,
|
||||
decode: (raw) => {
|
||||
const parsed: Query = JSON.parse(decodeURIComponent(raw.replace(/\+/g, ' ')));
|
||||
return migrateLegacyFormat(parsed);
|
||||
},
|
||||
};
|
||||
68
frontend/src/lib/compositeQuery/adapters/json/json.test.ts
Normal file
68
frontend/src/lib/compositeQuery/adapters/json/json.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { jsonAdapter } from './index';
|
||||
|
||||
const roundTrip = (query: Query): Query =>
|
||||
jsonAdapter.decode(jsonAdapter.encode(query));
|
||||
|
||||
describe('jsonAdapter', () => {
|
||||
describe('round-trip', () => {
|
||||
it.each(['metrics', 'logs', 'traces'] as const)(
|
||||
'round-trips %s baseline preserving dataSource',
|
||||
(source) => {
|
||||
const query = initialQueriesMap[source];
|
||||
const decoded = roundTrip(query);
|
||||
expect(decoded.builder.queryData[0].dataSource).toBe(source);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('legacy format compatibility', () => {
|
||||
it('encodes to legacy format (encodeURIComponent + JSON)', () => {
|
||||
const query = initialQueriesMap.logs;
|
||||
const encoded = jsonAdapter.encode(query);
|
||||
|
||||
expect(encoded).toBe(encodeURIComponent(JSON.stringify(query)));
|
||||
expect(encoded.startsWith('%7B')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tag matching', () => {
|
||||
it('matches any value (catch-all fallback)', () => {
|
||||
expect(jsonAdapter.matches('%7B%22queryType%22%3A%22builder%22%7D')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(jsonAdapter.matches('z1~abc')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration', () => {
|
||||
it('migrates old format (filters -> filter.expression)', () => {
|
||||
const legacy = {
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filters: { op: 'AND', items: [] },
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: { key: '', dataType: '', type: '' },
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
id: 'x',
|
||||
unit: '',
|
||||
};
|
||||
const raw = encodeURIComponent(JSON.stringify(legacy));
|
||||
const decoded = jsonAdapter.decode(raw);
|
||||
expect(decoded.builder.queryData[0].filter).toBeDefined();
|
||||
expect(decoded.builder.queryData[0].aggregations).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
8
frontend/src/lib/compositeQuery/adapters/json/types.ts
Normal file
8
frontend/src/lib/compositeQuery/adapters/json/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export interface CompositeQueryAdapter {
|
||||
readonly name: string;
|
||||
encode(query: Query): string;
|
||||
matches(raw: string): boolean;
|
||||
decode(raw: string): Query;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { initialQueriesMap } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export interface RoundTripScenario {
|
||||
name: string;
|
||||
query: Query;
|
||||
}
|
||||
|
||||
const clone = <T>(obj: T): T => JSON.parse(JSON.stringify(obj)) as T;
|
||||
|
||||
const makePromqlQuery = (): Query => {
|
||||
const query = clone(initialQueriesMap.metrics);
|
||||
query.queryType = EQueryType.PROM;
|
||||
query.promql[0].query = 'rate(http_requests_total[5m])';
|
||||
return query;
|
||||
};
|
||||
|
||||
const makeClickhouseQuery = (): Query => {
|
||||
const query = clone(initialQueriesMap.metrics);
|
||||
query.queryType = EQueryType.CLICKHOUSE;
|
||||
query.clickhouse_sql[0].query = 'SELECT count() FROM signoz_logs';
|
||||
return query;
|
||||
};
|
||||
|
||||
const makeModifiedBuilderQuery = (): Query => {
|
||||
const query = clone(initialQueriesMap.logs);
|
||||
const qd = query.builder.queryData[0];
|
||||
qd.aggregateOperator = 'p95';
|
||||
qd.disabled = true;
|
||||
qd.stepInterval = 60;
|
||||
qd.legend = 'error rate';
|
||||
qd.filter = { expression: "severity_text = 'ERROR'" };
|
||||
qd.filters = {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
key: {
|
||||
key: 'severity_text',
|
||||
dataType: 'string',
|
||||
type: 'tag',
|
||||
isColumn: false,
|
||||
isJSON: false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
id: 'item-1',
|
||||
op: '=',
|
||||
value: 'ERROR',
|
||||
},
|
||||
],
|
||||
};
|
||||
qd.orderBy = [{ columnName: 'timestamp', order: 'desc' }];
|
||||
return query;
|
||||
};
|
||||
|
||||
const makeQueryWithCustomId = (): Query => ({
|
||||
...initialQueriesMap.metrics,
|
||||
id: 'test-query-uuid-123',
|
||||
});
|
||||
|
||||
const makeQueryWithEnumLikeLegend = (): Query => {
|
||||
const query = clone(initialQueriesMap.metrics);
|
||||
query.builder.queryData[0].legend = 'sum';
|
||||
query.id = 'my-query-id';
|
||||
return query;
|
||||
};
|
||||
|
||||
const makeQueryWithWireDelimiters = (): Query => {
|
||||
const query = clone(initialQueriesMap.logs);
|
||||
query.builder.queryData[0].legend = '_a*b_*c';
|
||||
query.builder.queryData[0].filter = { expression: '!weird = "x_y*z"' };
|
||||
return query;
|
||||
};
|
||||
|
||||
export const roundTripScenarios: RoundTripScenario[] = [
|
||||
{ name: 'metrics baseline', query: initialQueriesMap.metrics },
|
||||
{ name: 'logs baseline', query: initialQueriesMap.logs },
|
||||
{ name: 'traces baseline', query: initialQueriesMap.traces },
|
||||
{ name: 'promql query', query: makePromqlQuery() },
|
||||
{ name: 'clickhouse query', query: makeClickhouseQuery() },
|
||||
{ name: 'modified builder query', query: makeModifiedBuilderQuery() },
|
||||
{ name: 'custom id', query: makeQueryWithCustomId() },
|
||||
{ name: 'enum-like legend preserved', query: makeQueryWithEnumLikeLegend() },
|
||||
{ name: 'wire delimiters in values', query: makeQueryWithWireDelimiters() },
|
||||
];
|
||||
179
frontend/src/lib/compositeQuery/baseline.ts
Normal file
179
frontend/src/lib/compositeQuery/baseline.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
/**
|
||||
* Frozen canonical baselines the V-raw/TV adapters diff against. Encode stores
|
||||
* only the diff from the chosen baseline; decode replays it onto a clone. These
|
||||
* MUST stay byte-stable forever — changing them silently invalidates every URL
|
||||
* already emitted against the old baseline. To evolve the schema, add NEW
|
||||
* tagged adapters (V2~) with their own baselines rather than editing these.
|
||||
*
|
||||
* `id`/`unit` are empty so a real query's values round-trip as ordinary diff
|
||||
* entries (nothing is stripped before diffing).
|
||||
*/
|
||||
|
||||
/** Baseline for metrics queries — uses metric-style aggregations object. */
|
||||
export const METRICS_BASELINE = {
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'metrics',
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
aggregateAttribute: {
|
||||
id: '----',
|
||||
key: '',
|
||||
dataType: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
filter: { expression: '' },
|
||||
aggregations: [
|
||||
{
|
||||
metricName: '',
|
||||
temporality: '',
|
||||
timeAggregation: 'avg',
|
||||
spaceAggregation: 'sum',
|
||||
reduceTo: 'avg',
|
||||
},
|
||||
],
|
||||
functions: [],
|
||||
filters: { items: [], op: 'AND' },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: null,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: 'avg',
|
||||
source: null,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
|
||||
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
|
||||
id: '',
|
||||
unit: '',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any as Query;
|
||||
|
||||
/** Baseline for logs/traces queries — uses expression-style aggregations. */
|
||||
export const LOGS_BASELINE = {
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
aggregateOperator: null,
|
||||
aggregateAttribute: {
|
||||
id: '----',
|
||||
key: '',
|
||||
dataType: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
filter: { expression: '' },
|
||||
aggregations: [{ expression: 'count() ' }],
|
||||
functions: [],
|
||||
filters: { items: [], op: 'AND' },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: null,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: 'avg',
|
||||
source: null,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
|
||||
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
|
||||
id: '',
|
||||
unit: '',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any as Query;
|
||||
|
||||
/** Baseline for traces queries — same as logs but with dataSource: traces. */
|
||||
export const TRACES_BASELINE = {
|
||||
queryType: 'builder',
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'traces',
|
||||
queryName: 'A',
|
||||
aggregateOperator: null,
|
||||
aggregateAttribute: {
|
||||
id: '----',
|
||||
key: '',
|
||||
dataType: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
filter: { expression: '' },
|
||||
aggregations: [{ expression: 'count() ' }],
|
||||
functions: [],
|
||||
filters: { items: [], op: 'AND' },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: null,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
groupBy: [],
|
||||
legend: '',
|
||||
reduceTo: 'avg',
|
||||
source: null,
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
|
||||
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
|
||||
id: '',
|
||||
unit: '',
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any as Query;
|
||||
|
||||
/** Baseline tag indicators for URL encoding. */
|
||||
export type BaselineTag = 'm' | 'l' | 't';
|
||||
|
||||
/** Pick optimal baseline based on query's primary dataSource. */
|
||||
export function pickBaseline(query: Query): {
|
||||
baseline: Query;
|
||||
tag: BaselineTag;
|
||||
} {
|
||||
const ds = query.builder?.queryData?.[0]?.dataSource;
|
||||
if (ds === 'logs') {
|
||||
return { baseline: LOGS_BASELINE, tag: 'l' };
|
||||
}
|
||||
if (ds === 'traces') {
|
||||
return { baseline: TRACES_BASELINE, tag: 't' };
|
||||
}
|
||||
return { baseline: METRICS_BASELINE, tag: 'm' };
|
||||
}
|
||||
|
||||
function getBaselineByTag(tag: BaselineTag): Query {
|
||||
if (tag === 'l') {
|
||||
return LOGS_BASELINE;
|
||||
}
|
||||
if (tag === 't') {
|
||||
return TRACES_BASELINE;
|
||||
}
|
||||
return METRICS_BASELINE;
|
||||
}
|
||||
|
||||
export default getBaselineByTag;
|
||||
28
frontend/src/lib/compositeQuery/serializer.ts
Normal file
28
frontend/src/lib/compositeQuery/serializer.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { jsonAdapter } from 'lib/compositeQuery/adapters/json';
|
||||
import { CompositeQueryAdapter } from 'lib/compositeQuery/types';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { flatKeysAdapter } from 'lib/compositeQuery/adapters/flatKeys';
|
||||
|
||||
// Order matters for decode: most-specific (tagged) adapters first, json last
|
||||
const ADAPTERS: CompositeQueryAdapter[] = [flatKeysAdapter, jsonAdapter];
|
||||
|
||||
/** Encode a query to the shortest available URL value. */
|
||||
export function serialize(query: Query): string {
|
||||
return ADAPTERS.map((adapter) => adapter.encode(query)).reduce(
|
||||
(shortest, candidate) =>
|
||||
candidate.length < shortest.length ? candidate : shortest,
|
||||
);
|
||||
}
|
||||
|
||||
/** Decode a URL value back to a Query. Total: returns null on any failure. */
|
||||
export function deserialize(raw: string): Query | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const adapter = ADAPTERS.find((item) => item.matches(raw)) ?? jsonAdapter;
|
||||
return adapter.decode(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
14
frontend/src/lib/compositeQuery/types.ts
Normal file
14
frontend/src/lib/compositeQuery/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
/**
|
||||
* A serialization tier. `encode` returns the COMPLETE URL value (tag prefix
|
||||
* included for tagged tiers). `matches` decides whether a raw value belongs to
|
||||
* this adapter on decode. `decode` receives the COMPLETE raw value and strips
|
||||
* its own tag.
|
||||
*/
|
||||
export interface CompositeQueryAdapter {
|
||||
readonly name: string;
|
||||
encode(query: Query): string;
|
||||
matches(raw: string): boolean;
|
||||
decode(raw: string): Query;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -38,6 +38,7 @@ import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQue
|
||||
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { serialize } from 'lib/compositeQuery/serializer';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
|
||||
@@ -936,7 +937,7 @@ export function QueryBuilderProvider({
|
||||
);
|
||||
|
||||
const { safeNavigate } = useSafeNavigate({
|
||||
preventSameUrlNavigation: false,
|
||||
preventSameUrlNavigation: true,
|
||||
});
|
||||
|
||||
const redirectWithQueryBuilderData = useCallback(
|
||||
@@ -990,10 +991,7 @@ export function QueryBuilderProvider({
|
||||
);
|
||||
}
|
||||
|
||||
urlQuery.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(currentGeneratedQuery)),
|
||||
);
|
||||
urlQuery.set(QueryParams.compositeQuery, serialize(currentGeneratedQuery));
|
||||
|
||||
if (searchParams) {
|
||||
Object.keys(searchParams).forEach((param) =>
|
||||
|
||||
@@ -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,21 +135,18 @@ 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 {
|
||||
@@ -187,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,
|
||||
@@ -206,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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +213,6 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewCloudIntegrationRemoveCascadeDeleteFactory(sqlschema),
|
||||
sqlmigration.NewAddUserDashboardPreferenceFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewRecreateUserDashboardPreferenceFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewMigrateRecurrenceBoundsFactory(sqlstore),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
// StartTime–EndTime 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 recurrence’s
|
||||
// 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 {
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user