Skip to content
Closed
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,27 +313,35 @@ 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}
/>
</div>
)}
{queryReady &&
dbTimeChartConfig != null &&
activeTab === 'time' &&
alertPreview.footer}
{queryReady && queriedConfig != null && activeTab === 'pie' && (
<div className="flex-grow-1 d-flex flex-column" style={{ height: 400 }}>
<DBPieChart
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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, fixed in 9c9f716: handleSaveAlert now runs beforeSave first, same as handleSave. The new test does not save an inline alert when beforeSave returns false covers it.

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 — beforeSave runs before validation, so a form can fail in two rounds

beforeSave gets the raw, unvalidated form and is expected to show its own message. If the form is also invalid, the user sees only the extension's message. After they fix it, the next save shows the validateChartForm errors. Call beforeSave after validateAndNormalize succeeds, so the user sees every error in one pass and the extension never sees a malformed alert.

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

return;
}

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

// Same validation path as a tile save, but hands the config to the
// inline-alert saver. The alert must survive the round trip: the display
// type could have been switched to one that drops it since it was added.
const handleSaveAlert = useCallback(
(form: ChartEditorFormState) => {
if (!beforeSave(form)) {
return;
}

const { errors, config } = validateAndNormalize(form);
if (errors.length > 0) {
notifications.show({
Expand All @@ -737,7 +755,7 @@ export default function EditTimeChartForm({

onSaveAlert?.(config);
},
[validateAndNormalize, onSaveAlert],
[beforeSave, validateAndNormalize, onSaveAlert],
);

// The edit-alert modal requires the alert and saves it by saving the chart,
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

Comment thread
teeohhem marked this conversation as resolved.
Resolved
)}
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