Repository navigation
feat(app): add an alert UI extension point for detection modes #3623
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a028e35
66565f3
9c9f716
0346d10
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
|---|---|---|
|
|
@@ -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'; | ||
|
|
@@ -269,6 +270,12 @@ export default function EditTimeChartForm({ | |
| resolver: zodResolver(zSavedChartConfig), | ||
| }); | ||
|
|
||
| const { beforeSave, normalizeAlert } = useAlertChartEditorUI({ | ||
| watch, | ||
| getValues, | ||
| setValue, | ||
| }); | ||
|
|
||
| const { | ||
| fields, | ||
| append, | ||
|
|
@@ -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, | ||
| }, | ||
| ), | ||
| ), | ||
| }; | ||
|
|
||
|
|
@@ -627,6 +636,7 @@ export default function EditTimeChartForm({ | |
| chartConfigAlert, | ||
| dirtyFields.alert?.scheduleOffsetMinutes, | ||
| dirtyFields.alert?.scheduleStartAt, | ||
| normalizeAlert, | ||
| ], | ||
| ); | ||
|
|
||
|
|
@@ -689,6 +699,10 @@ export default function EditTimeChartForm({ | |
|
|
||
| const handleSave = useCallback( | ||
| (form: ChartEditorFormState) => { | ||
| if (!beforeSave(form)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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({ | ||
|
|
@@ -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({ | ||
|
|
@@ -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, | ||
|
|
||
| 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 |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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'; | ||
|
|
@@ -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, | ||
|
|
@@ -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', | ||
|
|
@@ -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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 minor — Narrowed If the extension passes 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
teeohhem marked this conversation as resolved.
Resolved
|
||
| )} | ||
| size="xs" | ||
| {...field} | ||
| /> | ||
|
|
@@ -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} | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
potentially blocking here, seems
handleSaveAlertdoesn't callbeforeSaveThis check only runs in
handleSave. The edit-alert modal saves throughhandleSaveAlerta little further down, so an extension that blocks a save there would still see the alert saved. Would something like this make sense?There was a problem hiding this comment.
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:
handleSaveAlertnow runsbeforeSavefirst, same ashandleSave. The new testdoes not save an inline alert when beforeSave returns falsecovers it.