Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/alert-ui-extension-point.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hyperdx/app': patch
---

feat: add an internal extension point for alert detection modes in the chart editor; tile alerts edit, preview and save as before
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
getAlertReferenceLines,
getAlertReferenceLineValues,
} from '@/components/Alerts';
import { useAlertTimeChartPreviewUI } from '@/components/alerts/alertUIExtension';
import { ChartEditorFormState } from '@/components/ChartEditor/types';
import ChartSQLPreview from '@/components/ChartSQLPreview';
import { DBBarChart } from '@/components/DBBarChart';
Expand Down Expand Up @@ -216,6 +217,12 @@ export function ChartPreviewPanel({
const queryReady =
!isBlockedByRequiredFilters && !!isQueryReady(queriedConfig);

const alertPreview = useAlertTimeChartPreviewUI({
alert,
config: dbTimeChartConfig,
enabled: queryReady && activeTab === 'time',
});

const onTableSortingChange = useCallback(
(sortState: SortingState | null) => {
setValue('orderBy', sortingStateToOrderByString(sortState) ?? '');
Expand Down Expand Up @@ -306,25 +313,30 @@ export function ChartPreviewPanel({
<div className="flex-grow-1 d-flex flex-column" style={{ height: 400 }}>
<DBTimeChart
sourceId={sourceId}
config={dbTimeChartConfig}
config={alertPreview.config ?? dbTimeChartConfig}
setDisplayType={setDisplayType}
onTimeRangeSelect={onTimeRangeSelect}
referenceLines={
alert &&
getAlertReferenceLines({
threshold: alert.threshold,
thresholdMax: alert.thresholdMax,
thresholdType: alert.thresholdType,
})
alert && !alertPreview.hideThresholdLines
? getAlertReferenceLines({
threshold: alert.threshold,
thresholdMax: alert.thresholdMax,
thresholdType: alert.thresholdType,
})
: undefined
}
referenceLineValues={
alertPreview.hideThresholdLines ? undefined : referenceLineValues
}
referenceLineValues={referenceLineValues}
errorVariant="inline"
showMVOptimizationIndicator={false}
// Preview doesn't need the MV indicators; disabling both lets
// DBTimeChart skip the extra MV-optimization EXPLAIN query, which
// otherwise fires on every edit-modal open / submit.
showDateRangeIndicator={false}
{...alertPreview.timeChartProps}
/>
{alertPreview.footer}
</div>
)}
{queryReady && queriedConfig != null && activeTab === 'pie' && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {

import { getPreviousDateRange } from '@/ChartUtils';
import { AlertPanelLayout } from '@/components/AlertPanel';
import { useAlertChartEditorUI } from '@/components/alerts/alertUIExtension';
import ChartDisplaySettingsDrawer, {
ChartConfigDisplaySettings,
} from '@/components/ChartDisplaySettingsDrawer';
Expand Down Expand Up @@ -269,6 +270,12 @@ export default function EditTimeChartForm({
resolver: zodResolver(zSavedChartConfig),
});

const { beforeSave, normalizeAlert } = useAlertChartEditorUI({
watch,
getValues,
setValue,
});

const {
fields,
append,
Expand Down Expand Up @@ -605,15 +612,17 @@ export default function EditTimeChartForm({
? savedConfig
: {
...savedConfig,
alert: normalizeNoOpAlertScheduleFields(
savedConfig.alert,
chartConfigAlert,
{
preserveExplicitScheduleOffsetMinutes:
dirtyFields.alert?.scheduleOffsetMinutes === true,
preserveExplicitScheduleStartAt:
dirtyFields.alert?.scheduleStartAt === true,
},
alert: normalizeAlert(
normalizeNoOpAlertScheduleFields(
savedConfig.alert,
chartConfigAlert,
{
preserveExplicitScheduleOffsetMinutes:
dirtyFields.alert?.scheduleOffsetMinutes === true,
preserveExplicitScheduleStartAt:
dirtyFields.alert?.scheduleStartAt === true,
},
),
),
};

Expand All @@ -627,6 +636,7 @@ export default function EditTimeChartForm({
chartConfigAlert,
dirtyFields.alert?.scheduleOffsetMinutes,
dirtyFields.alert?.scheduleStartAt,
normalizeAlert,
],
);

Expand Down Expand Up @@ -689,6 +699,10 @@ export default function EditTimeChartForm({

const handleSave = useCallback(
(form: ChartEditorFormState) => {
if (!beforeSave(form)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

potentially blocking here, seems handleSaveAlert doesn't call beforeSave

This check only runs in handleSave. The edit-alert modal saves through handleSaveAlert a little further down, so an extension that blocks a save there would still see the alert saved. Would something like this make sense?

const handleSaveAlert = useCallback(
  (form: ChartEditorFormState) => {
    if (!beforeSave(form)) return;

return;
}

const { errors, config } = validateAndNormalize(form);
if (errors.length > 0) {
notifications.show({
Expand All @@ -704,7 +718,7 @@ export default function EditTimeChartForm({
onSave?.(config);
}
},
[validateAndNormalize, onSave],
[beforeSave, validateAndNormalize, onSave],
);

// Same validation path as a tile save, but hands the config to the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
Control,
Controller,
UseFormSetValue,
useWatch,
} from 'react-hook-form';
import {
AlertThresholdType,
isRangeThresholdType,
} from '@hyperdx/common-utils/dist/types';
import { NativeSelect, NumberInput, Text } from '@mantine/core';

import { ChartEditorFormState } from '@/components/ChartEditor/types';
import { optionsToSelectData } from '@/utils';
import { TILE_ALERT_THRESHOLD_TYPE_OPTIONS } from '@/utils/alerts';

/** The comparison, threshold and (for ranges) upper bound of a tile alert. */
export function ThresholdConditionFields({
control,
setValue,
}: {
control: Control<ChartEditorFormState>;
setValue: UseFormSetValue<ChartEditorFormState>;
}) {
const alertThresholdType = useWatch({ control, name: 'alert.thresholdType' });
const alertThreshold = useWatch({ control, name: 'alert.threshold' });
const alertThresholdMax = useWatch({ control, name: 'alert.thresholdMax' });

return (
<>
<Controller
control={control}
name="alert.thresholdType"
render={({ field }) => (
<NativeSelect
data={optionsToSelectData(TILE_ALERT_THRESHOLD_TYPE_OPTIONS)}
size="xs"
{...field}
onChange={e => {
field.onChange(e);
if (
isRangeThresholdType(e.currentTarget.value) &&
alertThresholdMax == null
) {
setValue('alert.thresholdMax', (alertThreshold ?? 0) + 1);
}
}}
/>
)}
/>
<Controller
control={control}
name="alert.threshold"
render={({ field }) => <NumberInput size="xs" w={80} {...field} />}
/>
{isRangeThresholdType(alertThresholdType as AlertThresholdType) && (
<>
<Text size="sm" opacity={0.7}>
and
</Text>
<Controller
control={control}
name="alert.thresholdMax"
render={({ field, fieldState }) => (
<NumberInput
size="xs"
w={80}
{...field}
error={fieldState.error?.message}
/>
)}
/>
</>
)}
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,13 @@ import {
} from 'react-hook-form';
import { Badge } from '@clickhouse/click-ui';
import { formatTileAlertDisplayName } from '@hyperdx/common-utils/dist/alerts';
import {
AlertThresholdType,
isRangeThresholdType,
} from '@hyperdx/common-utils/dist/types';
import { AlertThresholdType } from '@hyperdx/common-utils/dist/types';
import {
ActionIcon,
Alert,
Box,
Group,
NativeSelect,
NumberInput,
Paper,
Text,
Tooltip,
Expand All @@ -39,6 +35,7 @@ import {
import { AlertChannelForm } from '@/components/Alerts';
import { AckAlert } from '@/components/alerts/AckAlert';
import { AlertHistoryCardList } from '@/components/alerts/AlertHistoryCards';
import { useAlertConditionUI } from '@/components/alerts/alertUIExtension';
import { AlertScheduleFields } from '@/components/AlertScheduleFields';
import { ChartEditorFormState } from '@/components/ChartEditor/types';
import { useDashboards } from '@/dashboard';
Expand All @@ -48,9 +45,10 @@ import {
ALERT_CHANNEL_OPTIONS,
intervalToMinutes,
TILE_ALERT_INTERVAL_OPTIONS,
TILE_ALERT_THRESHOLD_TYPE_OPTIONS,
} from '@/utils/alerts';

import { ThresholdConditionFields } from './ThresholdConditionFields';

export function TileAlertEditor({
control,
setValue,
Expand Down Expand Up @@ -87,8 +85,7 @@ export function TileAlertEditor({
};

const alertThresholdType = useWatch({ control, name: 'alert.thresholdType' });
const alertThreshold = useWatch({ control, name: 'alert.threshold' });
const alertThresholdMax = useWatch({ control, name: 'alert.thresholdMax' });
const conditionUI = useAlertConditionUI({ control, setValue, alert });
const alertScheduleOffsetMinutes = useWatch({
control,
name: 'alert.scheduleOffsetMinutes',
Expand Down Expand Up @@ -188,63 +185,23 @@ export function TileAlertEditor({
</Group>
</Group>
<Box px="sm" pb="sm">
{conditionUI.header}
<Group gap="xs">
<Text size="sm" opacity={0.7}>
Trigger when the value
{conditionUI.conditionLabel ?? 'Trigger when the value'}
</Text>
<Controller
control={control}
name="alert.thresholdType"
render={({ field }) => (
<NativeSelect
data={optionsToSelectData(TILE_ALERT_THRESHOLD_TYPE_OPTIONS)}
size="xs"
{...field}
onChange={e => {
field.onChange(e);
if (
isRangeThresholdType(e.currentTarget.value) &&
alertThresholdMax == null
) {
setValue('alert.thresholdMax', (alertThreshold ?? 0) + 1);
}
}}
/>
)}
/>
<Controller
control={control}
name="alert.threshold"
render={({ field }) => (
<NumberInput size="xs" w={80} {...field} />
)}
/>
{isRangeThresholdType(alertThresholdType as AlertThresholdType) && (
<>
<Text size="sm" opacity={0.7}>
and
</Text>
<Controller
control={control}
name="alert.thresholdMax"
render={({ field, fieldState }) => (
<NumberInput
size="xs"
w={80}
{...field}
error={fieldState.error?.message}
/>
)}
/>
</>
{conditionUI.conditionFields ?? (
<ThresholdConditionFields control={control} setValue={setValue} />
)}
over
{conditionUI.intervalLabel ?? 'over'}
<Controller
control={control}
name="alert.interval"
render={({ field }) => (
<NativeSelect
data={optionsToSelectData(TILE_ALERT_INTERVAL_OPTIONS)}
data={optionsToSelectData(
conditionUI.intervalOptions ?? TILE_ALERT_INTERVAL_OPTIONS,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — Narrowed intervalOptions are not reconciled with the form's current interval

If the extension passes intervalOptions that leave out the alert's current interval (the default is '5m'; an existing alert might be '1m'), the NativeSelect shows a different option than the one stored, and the hidden value is what gets saved. Fix: when the current alert.interval is not a key of conditionUI.intervalOptions, reset it to the first allowed key with setValue, or document that the extension must do this and enforce it in the hook wrapper.

Advisory. Fix if it is a small defect in code this PR changes; otherwise reply in the thread. Do not widen the PR or touch files it did not already change. How to respond

)}
size="xs"
{...field}
/>
Expand All @@ -265,6 +222,7 @@ export function TileAlertEditor({
)}
/>
</Group>
{conditionUI.footer}
{alert?.createdBy && (
<Text size="xs" opacity={0.6} mt="xs">
Created by {alert.createdBy.name || alert.createdBy.email}
Expand Down
Loading
Loading