diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItem.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItem.interface.ts index e0b7ac692286..ff256798f930 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItem.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Alerts/AlertFormSourceItem/AlertFormSourceItem.interface.ts @@ -11,8 +11,8 @@ * limitations under the License. */ -import { FilterResourceDescriptor } from '../../../generated/events/filterResourceDescriptor'; - export interface AlertFormSourceItemProps { - filterResources?: FilterResourceDescriptor[]; + filterResources?: { + name?: string; + }[]; } diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.interface.ts b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.interface.ts index 79abb21a6173..e97b881ee507 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.interface.ts @@ -11,15 +11,62 @@ * limitations under the License. */ +import type { FormInstance } from 'antd'; +import type { ComponentType } from 'react'; +import type { InlineAlertProps } from '../../components/common/InlineAlert/InlineAlert.interface'; +import type { + OperationPermission, + ResourceEntity, +} from '../../context/PermissionProvider/PermissionProvider.interface'; import { NotificationTemplate } from '../../generated/entity/events/notificationTemplate'; import { CreateEventSubscription } from '../../generated/events/api/createEventSubscription'; import { Destination, + EventFilterRule, EventSubscription, SubscriptionCategory, SubscriptionType, Webhook, } from '../../generated/events/eventSubscription'; +import type { AddAlertFormWidgetProps } from '../../utils/AlertsClassBase'; +import { AddAlertPageLoadingState } from '../AddNotificationPage/AddNotificationPage.interface'; + +export interface ObservabilityFilterResourceDescriptor { + containerEntities?: string[]; + name?: string; + supportedActions?: EventFilterRule[]; + supportedFilters?: EventFilterRule[]; +} + +export interface UseObservabilityAlertFormOptions { + afterSaveAction?: (fqn: string) => Promise | void; + form?: FormInstance; + fqn?: string; + onCancel?: () => void; +} + +export interface UseObservabilityAlertResourcesReturn { + containerEntities?: string[]; + filterResources: ObservabilityFilterResourceDescriptor[]; + loading: boolean; + shouldShowActionsSection: boolean; + shouldShowFiltersSection: boolean; + supportedFilters?: EventFilterRule[]; + supportedTriggers?: EventFilterRule[]; +} + +export interface UseObservabilityAlertTemplatesReturn { + loading: boolean; + templateResourcePermission: OperationPermission; + templates: NotificationTemplate[]; +} + +export interface UseObservabilityAlertTemplatesOptions { + extraFormWidgets: Record>; + getResourcePermission: ( + resourceEntity: ResourceEntity + ) => Promise; +} export interface ModifiedWebhookConfig extends Webhook { headers?: { key: string; value: string }[]; @@ -49,3 +96,51 @@ export interface ModifiedCreateEventSubscription timeout: number; readTimeout: number; } + +export interface UseObservabilityAlertFormReturn { + alert?: ModifiedEventSubscription; + breadcrumb: { + name: string; + url: string; + }[]; + containerEntities?: string[]; + extraFormButtons: Record>; + extraFormWidgets: Record>; + filterResources: ObservabilityFilterResourceDescriptor[]; + form: FormInstance; + handleCancel: () => void; + handleSave: (data: ModifiedCreateEventSubscription) => Promise; + inlineAlertDetails?: InlineAlertProps; + isEditMode: boolean; + isLoading: boolean; + loadingState: AddAlertPageLoadingState; + saving: boolean; + shouldShowActionsSection: boolean; + shouldShowFiltersSection: boolean; + supportedFilters?: EventFilterRule[]; + supportedTriggers?: EventFilterRule[]; + templateResourcePermission: OperationPermission; + templates: NotificationTemplate[]; +} + +export type ObservabilityAlertFormProps = UseObservabilityAlertFormReturn; + +export type ObservabilityAlertFormFieldsProps = Pick< + ObservabilityAlertFormProps, + | 'alert' + | 'containerEntities' + | 'extraFormWidgets' + | 'filterResources' + | 'form' + | 'isLoading' + | 'shouldShowActionsSection' + | 'shouldShowFiltersSection' + | 'supportedFilters' + | 'supportedTriggers' + | 'templateResourcePermission' + | 'templates' +>; + +export interface AddObservabilityPageProps { + pageTitle: string; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.tsx index 53edba277f0e..f846505992ec 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/AddObservabilityPage.tsx @@ -11,265 +11,20 @@ * limitations under the License. */ -import { Button, Card, Col, Divider, Form, Input, Row, Typography } from 'antd'; -import { useForm } from 'antd/lib/form/Form'; -import { isEmpty, isUndefined } from 'lodash'; -import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; +import { Card } from 'antd'; +import { isEmpty } from 'lodash'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import AlertFormSourceItem from '../../components/Alerts/AlertFormSourceItem/AlertFormSourceItem'; -import DestinationFormItem from '../../components/Alerts/DestinationFormItem/DestinationFormItem.component'; -import ObservabilityFormFiltersItem from '../../components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem'; -import ObservabilityFormTriggerItem from '../../components/Alerts/ObservabilityFormTriggerItem/ObservabilityFormTriggerItem'; -import InlineAlert from '../../components/common/InlineAlert/InlineAlert'; import Loader from '../../components/common/Loader/Loader'; import ResizablePanels from '../../components/common/ResizablePanels/ResizablePanels'; -import RichTextEditor from '../../components/common/RichTextEditor/RichTextEditor'; -import TitleBreadcrumb from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; -import { - PAGE_SIZE_LARGE, - VALIDATION_MESSAGES, -} from '../../constants/constants'; -import { NAME_FIELD_RULES } from '../../constants/Form.constants'; -import { useLimitStore } from '../../context/LimitsProvider/useLimitsStore'; -import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; -import { - OperationPermission, - ResourceEntity, -} from '../../context/PermissionProvider/PermissionProvider.interface'; -import { - NotificationTemplate, - ProviderType, -} from '../../generated/entity/events/notificationTemplate'; -import { Operation } from '../../generated/entity/policies/policy'; -import { CreateEventSubscription } from '../../generated/events/api/createEventSubscription'; -import { - AlertType, - EventSubscription, -} from '../../generated/events/eventSubscription'; -import { FilterResourceDescriptor } from '../../generated/events/filterResourceDescriptor'; import { withPageLayout } from '../../hoc/withPageLayout'; -import { useApplicationStore } from '../../hooks/useApplicationStore'; -import { useFqn } from '../../hooks/useFqn'; -import { getAllNotificationTemplates } from '../../rest/notificationtemplateAPI'; -import { - createObservabilityAlert, - getObservabilityAlertByFQN, - getResourceFunctions, - updateObservabilityAlert, -} from '../../rest/observabilityAPI'; -import alertsClassBase from '../../utils/AlertsClassBase'; -import { getEntityName } from '../../utils/EntityNameUtils'; -import observabilityRouterClassBase from '../../utils/ObservabilityRouterClassBase'; -import { - DEFAULT_ENTITY_PERMISSION, - getPrioritizedViewPermission, -} from '../../utils/PermissionsUtils'; -import { showErrorToast } from '../../utils/ToastUtils'; -import { AddAlertPageLoadingState } from '../AddNotificationPage/AddNotificationPage.interface'; -import { - ModifiedCreateEventSubscription, - ModifiedEventSubscription, -} from './AddObservabilityPage.interface'; +import { AddObservabilityPageProps } from './AddObservabilityPage.interface'; +import ObservabilityAlertForm from './components/ObservabilityAlertForm'; +import { useObservabilityAlertForm } from './hooks/useObservabilityAlertForm'; -function AddObservabilityPage() { - const navigate = useNavigate(); +function AddObservabilityPage(_props: Readonly) { const { t } = useTranslation(); - const [form] = useForm(); - const { getResourcePermission } = usePermissionProvider(); - const { fqn } = useFqn(); - const { setInlineAlertDetails, inlineAlertDetails, currentUser } = - useApplicationStore(); - - const [filterResources, setFilterResources] = useState< - FilterResourceDescriptor[] - >([]); - - const [alert, setAlert] = useState(); - const [initialData, setInitialData] = useState(); - const [loadingState, setLoadingState] = useState({ - alerts: false, - functions: false, - templates: false, - }); - const [saving, setSaving] = useState(false); - const [templates, setTemplates] = useState([]); - const [templateResourcePermission, setTemplateResourcePermission] = - useState(DEFAULT_ENTITY_PERMISSION); - - const isEditMode = useMemo(() => !isEmpty(fqn), [fqn]); - const { getResourceLimit } = useLimitStore(); - - const fetchAlert = async () => { - try { - setLoadingState((state) => ({ ...state, alerts: true })); - - const observabilityAlert = await getObservabilityAlertByFQN(fqn); - const modifiedAlertData = - alertsClassBase.getModifiedAlertDataForForm(observabilityAlert); - - setInitialData(observabilityAlert); - setAlert(modifiedAlertData); - } catch (error) { - // Error handling - } finally { - setLoadingState((state) => ({ ...state, alerts: false })); - } - }; - - const fetchFunctions = async () => { - try { - setLoadingState((state) => ({ ...state, functions: true })); - const filterResources = await getResourceFunctions(); - - setFilterResources(filterResources.data); - } catch (error) { - showErrorToast( - t('server.entity-fetch-error', { entity: t('label.config') }) - ); - } finally { - setLoadingState((state) => ({ ...state, functions: false })); - } - }; - - useEffect(() => { - fetchFunctions(); - if (!fqn) { - return; - } - fetchAlert(); - }, [fqn]); - - const breadcrumb = useMemo( - () => [ - { - name: t('label.observability'), - url: '', - }, - { - name: t('label.alert-plural'), - url: observabilityRouterClassBase.getObservabilityAlertsListPath(), - }, - { - name: fqn - ? t('label.edit-entity', { entity: t('label.alert') }) - : t('label.create-entity', { entity: t('label.alert') }), - url: '', - }, - ], - [fqn] - ); - - const handleSave = useCallback( - async (data: ModifiedCreateEventSubscription) => { - try { - setSaving(true); - - await alertsClassBase.handleAlertSave({ - data, - fqn, - initialData, - currentUser, - createAlertAPI: createObservabilityAlert, - updateAlertAPI: updateObservabilityAlert, - afterSaveAction: async (fqn: string) => { - !fqn && (await getResourceLimit('eventsubscription', true, true)); - navigate( - observabilityRouterClassBase.getObservabilityAlertDetailsPath(fqn) - ); - }, - setInlineAlertDetails, - }); - } catch { - // Error handling done in "handleAlertSave" - } finally { - setSaving(false); - } - }, - [fqn, navigate, initialData, currentUser] - ); - - const [selectedTrigger] = - Form.useWatch(['resources'], form) ?? - []; - - const supportedFilters = useMemo( - () => - filterResources.find((resource) => resource.name === selectedTrigger) - ?.supportedFilters, - [filterResources, selectedTrigger] - ); - - const containerEntities = useMemo( - () => - filterResources.find((resource) => resource.name === selectedTrigger) - ?.containerEntities, - [filterResources, selectedTrigger] - ); - - const supportedTriggers = useMemo( - () => - filterResources.find((resource) => resource.name === selectedTrigger) - ?.supportedActions, - [filterResources, selectedTrigger] - ); - - const shouldShowFiltersSection = useMemo( - () => (selectedTrigger ? !isEmpty(supportedFilters) : true), - [selectedTrigger, supportedFilters] - ); - - const shouldShowActionsSection = useMemo( - () => (selectedTrigger ? !isEmpty(supportedTriggers) : true), - [selectedTrigger, supportedTriggers] - ); - - const extraFormWidgets = useMemo( - () => alertsClassBase.getAddAlertFormExtraWidgets(), - [] - ); - - const extraFormButtons = useMemo( - () => alertsClassBase.getAddAlertFormExtraButtons(), - [] - ); - - const fetchTemplates = useCallback(async () => { - setLoadingState((state) => ({ ...state, templates: true })); - try { - const permission = await getResourcePermission( - ResourceEntity.NOTIFICATION_TEMPLATE - ); - - setTemplateResourcePermission(permission); - - if (getPrioritizedViewPermission(permission, Operation.ViewAll)) { - const { data } = await getAllNotificationTemplates({ - limit: PAGE_SIZE_LARGE, - provider: ProviderType.User, - }); - - setTemplates(data); - } - } catch { - showErrorToast( - t('server.entity-fetch-error', { entity: t('label.template-plural') }) - ); - } finally { - setLoadingState((state) => ({ ...state, templates: false })); - } - }, []); - - useEffect(() => { - if (!isEmpty(extraFormWidgets)) { - fetchTemplates(); - } - }, [extraFormWidgets]); - - const isLoading = useMemo( - () => Object.values(loadingState).some((val) => val), - [loadingState] - ); + const formState = useObservabilityAlertForm(); + const { alert, isEditMode, isLoading } = formState; if (isLoading || (isEditMode && isEmpty(alert))) { return ; @@ -284,171 +39,7 @@ function AddObservabilityPage() { allowScroll: true, children: ( - - - - - - - - {t(`label.${isEditMode ? 'edit' : 'add'}-entity`, { - entity: t('label.alert'), - })} - - - {t('message.alerts-description')} - - - - - - form={form} - initialValues={{ - ...alert, - displayName: getEntityName(alert), - resources: alert?.filteringRules?.resources, - }} - validateMessages={VALIDATION_MESSAGES} - onFinish={handleSave}> - - - - - - - - - - - - - - - - - {shouldShowFiltersSection && ( - <> - - - - - - - - )} - {shouldShowActionsSection && ( - <> - - - - - - - - )} - - - - - - - - {!isEmpty(extraFormWidgets) && ( - <> - {Object.entries(extraFormWidgets).map( - ([name, Widget]) => ( - - - - - - - - - ) - )} - - )} - - - - - - + ), minWidth: 700, diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/ObservabilityAlertForm.utils.ts b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/ObservabilityAlertForm.utils.ts new file mode 100644 index 000000000000..de3037a40ac2 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/ObservabilityAlertForm.utils.ts @@ -0,0 +1,108 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Effect, + EventFilterRule, + InputType, + PrefixCondition, +} from '../../generated/events/eventSubscription'; +import { + Effect as ResourceEffect, + EventFilterRule as ResourceEventFilterRule, + FilterResourceDescriptor, + InputType as ResourceInputType, + PrefixCondition as ResourcePrefixCondition, +} from '../../generated/events/filterResourceDescriptor'; +import { ObservabilityFilterResourceDescriptor } from './AddObservabilityPage.interface'; + +type ObservabilityFilterResourceApiDescriptor = FilterResourceDescriptor & + Pick; + +const getEventFilterRuleEffect = ( + effect: ResourceEventFilterRule['effect'] +): EventFilterRule['effect'] => { + switch (effect) { + case ResourceEffect.Include: + return Effect.Include; + case ResourceEffect.Exclude: + default: + return Effect.Exclude; + } +}; + +const getEventFilterRuleInputType = ( + inputType?: ResourceEventFilterRule['inputType'] +): EventFilterRule['inputType'] | undefined => { + switch (inputType) { + case ResourceInputType.None: + return InputType.None; + case ResourceInputType.Runtime: + return InputType.Runtime; + case ResourceInputType.Static: + return InputType.Static; + default: + return undefined; + } +}; + +const getEventFilterRulePrefixCondition = ( + prefixCondition?: ResourceEventFilterRule['prefixCondition'] +): EventFilterRule['prefixCondition'] | undefined => { + switch (prefixCondition) { + case ResourcePrefixCondition.And: + return PrefixCondition.And; + case ResourcePrefixCondition.Or: + return PrefixCondition.Or; + default: + return undefined; + } +}; + +const toEventFilterRule = (rule: ResourceEventFilterRule): EventFilterRule => { + const inputType = getEventFilterRuleInputType(rule.inputType); + const prefixCondition = getEventFilterRulePrefixCondition( + rule.prefixCondition + ); + + return { + condition: rule.condition, + effect: getEventFilterRuleEffect(rule.effect), + ...(rule.arguments ? { arguments: rule.arguments } : {}), + ...(rule.description ? { description: rule.description } : {}), + ...(rule.displayName ? { displayName: rule.displayName } : {}), + ...(rule.fullyQualifiedName + ? { fullyQualifiedName: rule.fullyQualifiedName } + : {}), + ...(inputType ? { inputType } : {}), + ...(rule.name ? { name: rule.name } : {}), + ...(prefixCondition ? { prefixCondition } : {}), + }; +}; + +const toEventFilterRules = (rules?: ResourceEventFilterRule[]) => + rules?.map(toEventFilterRule); + +export const toObservabilityFilterResourceDescriptor = ( + resource: FilterResourceDescriptor +): ObservabilityFilterResourceDescriptor => { + const observabilityResource: ObservabilityFilterResourceApiDescriptor = + resource; + + return { + containerEntities: observabilityResource.containerEntities, + name: resource.name, + supportedActions: toEventFilterRules(resource.supportedActions), + supportedFilters: toEventFilterRules(resource.supportedFilters), + }; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/components/ObservabilityAlertForm.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/components/ObservabilityAlertForm.tsx new file mode 100644 index 000000000000..4c722e180bb1 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/components/ObservabilityAlertForm.tsx @@ -0,0 +1,133 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Button, Col, Form, Row, Typography } from 'antd'; +import { isUndefined } from 'lodash'; +import { useTranslation } from 'react-i18next'; +import InlineAlert from '../../../components/common/InlineAlert/InlineAlert'; +import TitleBreadcrumb from '../../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; +import { VALIDATION_MESSAGES } from '../../../constants/constants'; +import { getEntityName } from '../../../utils/EntityNameUtils'; +import { + ModifiedCreateEventSubscription, + ObservabilityAlertFormProps, +} from '../AddObservabilityPage.interface'; +import ObservabilityAlertFormFields from './ObservabilityAlertFormFields'; + +function ObservabilityAlertForm({ + alert, + breadcrumb, + containerEntities, + extraFormButtons, + extraFormWidgets, + filterResources, + form, + handleCancel, + handleSave, + inlineAlertDetails, + isEditMode, + isLoading, + saving, + shouldShowActionsSection, + shouldShowFiltersSection, + supportedFilters, + supportedTriggers, + templateResourcePermission, + templates, +}: Readonly) { + const { t } = useTranslation(); + + return ( + + + + + + + + {t(`label.${isEditMode ? 'edit' : 'add'}-entity`, { + entity: t('label.alert'), + })} + + {t('message.alerts-description')} + + + + + form={form} + initialValues={{ + ...alert, + displayName: getEntityName(alert), + resources: alert?.filteringRules?.resources, + }} + validateMessages={VALIDATION_MESSAGES} + onFinish={handleSave}> + + + + {!isUndefined(inlineAlertDetails) && ( + + + + )} + + +
+ + + {Object.entries(extraFormButtons).map( + ([name, ButtonComponent]) => ( + + ) + )} + +
+ +
+ + +
+ ); +} + +export default ObservabilityAlertForm; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/components/ObservabilityAlertFormFields.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/components/ObservabilityAlertFormFields.tsx new file mode 100644 index 000000000000..d8f593174029 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/components/ObservabilityAlertFormFields.tsx @@ -0,0 +1,143 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Col, Divider, Form, Input, Row } from 'antd'; +import { isEmpty } from 'lodash'; +import { Fragment } from 'react'; +import { useTranslation } from 'react-i18next'; +import AlertFormSourceItem from '../../../components/Alerts/AlertFormSourceItem/AlertFormSourceItem'; +import DestinationFormItem from '../../../components/Alerts/DestinationFormItem/DestinationFormItem.component'; +import ObservabilityFormFiltersItem from '../../../components/Alerts/ObservabilityFormFiltersItem/ObservabilityFormFiltersItem'; +import ObservabilityFormTriggerItem from '../../../components/Alerts/ObservabilityFormTriggerItem/ObservabilityFormTriggerItem'; +import RichTextEditor from '../../../components/common/RichTextEditor/RichTextEditor'; +import { NAME_FIELD_RULES } from '../../../constants/Form.constants'; +import { ProviderType } from '../../../generated/entity/events/notificationTemplate'; +import { AlertType } from '../../../generated/events/eventSubscription'; +import { + ModifiedCreateEventSubscription, + ObservabilityAlertFormFieldsProps, +} from '../AddObservabilityPage.interface'; + +function ObservabilityAlertFormFields({ + alert, + containerEntities, + extraFormWidgets, + filterResources, + form, + isLoading, + shouldShowActionsSection, + shouldShowFiltersSection, + supportedFilters, + supportedTriggers, + templateResourcePermission, + templates, +}: Readonly) { + const { t } = useTranslation(); + + return ( + <> + + + + + + + + + + + + + + + + {shouldShowFiltersSection && ( + <> + + + + + + + + )} + {shouldShowActionsSection && ( + <> + + + + + + + + )} + + + + + + + + {!isEmpty(extraFormWidgets) && ( + <> + {Object.entries(extraFormWidgets).map(([name, Widget]) => ( + + + + + + + + + ))} + + )} + + + + hidden + initialValue={AlertType.Observability} + name="alertType" + /> + + hidden + initialValue={ProviderType.User} + name="provider" + /> + + ); +} + +export default ObservabilityAlertFormFields; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertForm.ts b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertForm.ts new file mode 100644 index 000000000000..311d9d418b01 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertForm.ts @@ -0,0 +1,219 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useForm } from 'antd/lib/form/Form'; +import { isEmpty } from 'lodash'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { useLimitStore } from '../../../context/LimitsProvider/useLimitsStore'; +import { usePermissionProvider } from '../../../context/PermissionProvider/PermissionProvider'; +import { EventSubscription } from '../../../generated/events/eventSubscription'; +import { useApplicationStore } from '../../../hooks/useApplicationStore'; +import { useFqn } from '../../../hooks/useFqn'; +import { + createObservabilityAlert, + getObservabilityAlertByFQN, + updateObservabilityAlert, +} from '../../../rest/observabilityAPI'; +import alertsClassBase from '../../../utils/AlertsClassBase'; +import observabilityRouterClassBase from '../../../utils/ObservabilityRouterClassBase'; +import { AddAlertPageLoadingState } from '../../AddNotificationPage/AddNotificationPage.interface'; +import { + ModifiedCreateEventSubscription, + ModifiedEventSubscription, + UseObservabilityAlertFormOptions, + UseObservabilityAlertFormReturn, +} from '../AddObservabilityPage.interface'; +import { useObservabilityAlertResources } from './useObservabilityAlertResources'; +import { useObservabilityAlertTemplates } from './useObservabilityAlertTemplates'; + +export function useObservabilityAlertForm({ + afterSaveAction, + form: providedForm, + fqn: fqnProp, + onCancel, +}: UseObservabilityAlertFormOptions = {}): UseObservabilityAlertFormReturn { + const navigate = useNavigate(); + const { t } = useTranslation(); + const [internalForm] = useForm(); + const form = providedForm ?? internalForm; + const { getResourcePermission } = usePermissionProvider(); + const { fqn: routeFqn } = useFqn(); + const fqn = fqnProp ?? routeFqn; + const { setInlineAlertDetails, inlineAlertDetails, currentUser } = + useApplicationStore(); + const { getResourceLimit } = useLimitStore(); + + const [alert, setAlert] = useState(); + const [initialData, setInitialData] = useState(); + const [loadingState, setLoadingState] = useState({ + alerts: false, + functions: false, + templates: false, + }); + const [saving, setSaving] = useState(false); + + const isEditMode = useMemo(() => !isEmpty(fqn), [fqn]); + const extraFormWidgets = useMemo( + () => alertsClassBase.getAddAlertFormExtraWidgets(), + [] + ); + const extraFormButtons = useMemo( + () => alertsClassBase.getAddAlertFormExtraButtons(), + [] + ); + const alertResources = useObservabilityAlertResources(form); + const alertTemplates = useObservabilityAlertTemplates({ + extraFormWidgets, + getResourcePermission, + }); + + const fetchAlert = useCallback(async () => { + try { + setLoadingState((state) => ({ ...state, alerts: true })); + + const observabilityAlert = await getObservabilityAlertByFQN(fqn); + const modifiedAlertData = + alertsClassBase.getModifiedAlertDataForForm(observabilityAlert); + + setInitialData(observabilityAlert); + setAlert(modifiedAlertData); + } catch { + // Error handling + } finally { + setLoadingState((state) => ({ ...state, alerts: false })); + } + }, [fqn]); + + useEffect(() => { + if (!fqn) { + return; + } + fetchAlert(); + }, [fetchAlert, fqn]); + + const breadcrumb = useMemo( + () => [ + { + name: t('label.observability'), + url: '', + }, + { + name: t('label.alert-plural'), + url: observabilityRouterClassBase.getObservabilityAlertsListPath(), + }, + { + name: fqn + ? t('label.edit-entity', { entity: t('label.alert') }) + : t('label.create-entity', { entity: t('label.alert') }), + url: '', + }, + ], + [fqn, t] + ); + + const handleSave = useCallback( + async (data: ModifiedCreateEventSubscription) => { + try { + setSaving(true); + + await alertsClassBase.handleAlertSave({ + data, + fqn, + initialData, + currentUser, + createAlertAPI: createObservabilityAlert, + updateAlertAPI: updateObservabilityAlert, + afterSaveAction: async (savedFqn: string) => { + if (afterSaveAction) { + await afterSaveAction(savedFqn); + + return; + } + + if (!isEditMode) { + await getResourceLimit('eventsubscription', true, true); + } + + navigate( + observabilityRouterClassBase.getObservabilityAlertDetailsPath( + savedFqn + ) + ); + }, + setInlineAlertDetails, + }); + } catch { + // Error handling done in "handleAlertSave" + } finally { + setSaving(false); + } + }, + [ + afterSaveAction, + currentUser, + fqn, + getResourceLimit, + initialData, + isEditMode, + navigate, + setInlineAlertDetails, + ] + ); + + const isLoading = useMemo( + () => + Object.values(loadingState).some((val) => val) || + alertResources.loading || + alertTemplates.loading, + [alertResources.loading, alertTemplates.loading, loadingState] + ); + + const handleCancel = useCallback(() => { + if (onCancel) { + onCancel(); + + return; + } + + navigate(-1); + }, [navigate, onCancel]); + + return { + alert, + breadcrumb, + containerEntities: alertResources.containerEntities, + extraFormButtons, + extraFormWidgets, + filterResources: alertResources.filterResources, + form, + handleCancel, + handleSave, + inlineAlertDetails, + isEditMode, + isLoading, + loadingState: { + ...loadingState, + functions: alertResources.loading, + templates: alertTemplates.loading, + }, + saving, + shouldShowActionsSection: alertResources.shouldShowActionsSection, + shouldShowFiltersSection: alertResources.shouldShowFiltersSection, + supportedFilters: alertResources.supportedFilters, + supportedTriggers: alertResources.supportedTriggers, + templateResourcePermission: alertTemplates.templateResourcePermission, + templates: alertTemplates.templates, + }; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertResources.ts b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertResources.ts new file mode 100644 index 000000000000..dbc5c7255892 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertResources.ts @@ -0,0 +1,101 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { FormInstance } from 'antd'; +import { Form } from 'antd'; +import { isEmpty } from 'lodash'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CreateEventSubscription } from '../../../generated/events/api/createEventSubscription'; +import { getResourceFunctions } from '../../../rest/observabilityAPI'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import { + ModifiedCreateEventSubscription, + ObservabilityFilterResourceDescriptor, + UseObservabilityAlertResourcesReturn, +} from '../AddObservabilityPage.interface'; +import { toObservabilityFilterResourceDescriptor } from '../ObservabilityAlertForm.utils'; + +export function useObservabilityAlertResources( + form: FormInstance +): UseObservabilityAlertResourcesReturn { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [filterResources, setFilterResources] = useState< + ObservabilityFilterResourceDescriptor[] + >([]); + + const [selectedTrigger] = + Form.useWatch(['resources'], form) ?? + []; + + const fetchFunctions = async () => { + try { + setLoading(true); + const filterResources = await getResourceFunctions(); + + setFilterResources( + filterResources.data.map(toObservabilityFilterResourceDescriptor) + ); + } catch { + showErrorToast( + t('server.entity-fetch-error', { entity: t('label.config') }) + ); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchFunctions(); + }, []); + + const selectedResource = useMemo( + () => filterResources.find((resource) => resource.name === selectedTrigger), + [filterResources, selectedTrigger] + ); + + const supportedFilters = useMemo( + () => selectedResource?.supportedFilters, + [selectedResource] + ); + + const containerEntities = useMemo< + UseObservabilityAlertResourcesReturn['containerEntities'] + >(() => selectedResource?.containerEntities, [selectedResource]); + + const supportedTriggers = useMemo( + () => selectedResource?.supportedActions, + [selectedResource] + ); + + const shouldShowFiltersSection = useMemo( + () => (selectedTrigger ? !isEmpty(supportedFilters) : true), + [selectedTrigger, supportedFilters] + ); + + const shouldShowActionsSection = useMemo( + () => (selectedTrigger ? !isEmpty(supportedTriggers) : true), + [selectedTrigger, supportedTriggers] + ); + + return { + containerEntities, + filterResources, + loading, + shouldShowActionsSection, + shouldShowFiltersSection, + supportedFilters, + supportedTriggers, + }; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertTemplates.ts b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertTemplates.ts new file mode 100644 index 000000000000..41c57b78cdbf --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddObservabilityPage/hooks/useObservabilityAlertTemplates.ts @@ -0,0 +1,87 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { isEmpty } from 'lodash'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PAGE_SIZE_LARGE } from '../../../constants/constants'; +import { ResourceEntity } from '../../../context/PermissionProvider/PermissionProvider.interface'; +import { ProviderType } from '../../../generated/entity/events/notificationTemplate'; +import { Operation } from '../../../generated/entity/policies/policy'; +import { getAllNotificationTemplates } from '../../../rest/notificationtemplateAPI'; +import { + DEFAULT_ENTITY_PERMISSION, + getPrioritizedViewPermission, +} from '../../../utils/PermissionsUtils'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import { + UseObservabilityAlertTemplatesOptions, + UseObservabilityAlertTemplatesReturn, +} from '../AddObservabilityPage.interface'; + +export function useObservabilityAlertTemplates({ + extraFormWidgets, + getResourcePermission, +}: UseObservabilityAlertTemplatesOptions): UseObservabilityAlertTemplatesReturn { + const { t } = useTranslation(); + const getResourcePermissionRef = useRef(getResourcePermission); + const [loading, setLoading] = useState(false); + const [templates, setTemplates] = useState< + UseObservabilityAlertTemplatesReturn['templates'] + >([]); + const [templateResourcePermission, setTemplateResourcePermission] = useState< + UseObservabilityAlertTemplatesReturn['templateResourcePermission'] + >(DEFAULT_ENTITY_PERMISSION); + + useEffect(() => { + getResourcePermissionRef.current = getResourcePermission; + }, [getResourcePermission]); + + const fetchTemplates = useCallback(async () => { + setLoading(true); + try { + const permission = await getResourcePermissionRef.current( + ResourceEntity.NOTIFICATION_TEMPLATE + ); + + setTemplateResourcePermission(permission); + + if (getPrioritizedViewPermission(permission, Operation.ViewAll)) { + const { data } = await getAllNotificationTemplates({ + limit: PAGE_SIZE_LARGE, + provider: ProviderType.User, + }); + + setTemplates(data); + } + } catch { + showErrorToast( + t('server.entity-fetch-error', { entity: t('label.template-plural') }) + ); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + if (!isEmpty(extraFormWidgets)) { + fetchTemplates(); + } + }, [extraFormWidgets, fetchTemplates]); + + return { + loading, + templateResourcePermission, + templates, + }; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/AlertDetailsPage.interface.ts b/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/AlertDetailsPage.interface.ts index ab62564d7b3e..d965696b4651 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/AlertDetailsPage.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/AlertDetailsPage.interface.ts @@ -11,6 +11,53 @@ * limitations under the License. */ +import type { TabsProps } from 'antd'; +import type { ReactNode } from 'react'; +import { AlertDetailTabs } from '../../enums/Alerts.enum'; +import { + EntityReference, + EventSubscription, +} from '../../generated/events/eventSubscription'; + export interface AlertDetailsPageProps { - isNotificationAlert: boolean; + afterDeleteAction?: () => Promise | void; + fqn?: string; + isNotificationAlert?: boolean; + onEditAlert?: (fqn: string) => void; + onTabChange?: (tab: AlertDetailTabs) => void; + tab?: AlertDetailTabs; +} + +export interface AlertDetailsPermissions { + deletePermission: boolean; + editDescriptionPermission: boolean; + editOwnersPermission: boolean; + editPermission: boolean; + viewPermission: boolean; } + +export interface UseAlertDetailsPageReturn extends AlertDetailsPermissions { + alertDetails?: EventSubscription; + alertIcon: ReactNode; + breadcrumb: { + name: string; + url: string; + }[]; + extraInfo: ReactNode; + handleAlertDelete: () => Promise; + handleAlertEdit: () => Promise; + handleAlertSync: () => Promise; + handleTabChange: (activeKey: string) => void; + hideDeleteModal: () => void; + isSyncing: boolean; + loadingCount: number; + onDescriptionUpdate: (description: string) => Promise; + onOwnerUpdate: (owners?: EntityReference[]) => Promise; + ownerLoading: boolean; + setShowDeleteModal: (show: boolean) => void; + showDeleteModal: boolean; + tab: AlertDetailTabs; + tabItems: TabsProps['items']; +} + +export type AlertDetailsContentProps = UseAlertDetailsPageReturn; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/AlertDetailsPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/AlertDetailsPage.test.tsx index 879273637de5..678dc5185567 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/AlertDetailsPage.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/AlertDetailsPage.test.tsx @@ -10,7 +10,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; @@ -276,4 +282,51 @@ describe('AlertDetailsPage', () => { expect.anything() ); }); + + it('should refetch alert details when fqn prop changes', async () => { + const getObservabilityAlertByFQNSpy = jest.spyOn( + ObservabilityAPIs, + 'getObservabilityAlertByFQN' + ); + const getAlertEventsDiagnosticsInfoSpy = jest.spyOn( + ObservabilityAPIs, + 'getAlertEventsDiagnosticsInfo' + ); + + const { rerender } = render( + , + { + wrapper: MemoryRouter, + } + ); + + await waitFor(() => { + expect(getObservabilityAlertByFQNSpy).toHaveBeenCalledWith('firstAlert', { + fields: 'owners', + }); + }); + + expect(getAlertEventsDiagnosticsInfoSpy).toHaveBeenCalledWith({ + fqn: 'firstAlert', + listCountOnly: true, + }); + + rerender( + + ); + + await waitFor(() => { + expect(getObservabilityAlertByFQNSpy).toHaveBeenCalledWith( + 'secondAlert', + { + fields: 'owners', + } + ); + }); + + expect(getAlertEventsDiagnosticsInfoSpy).toHaveBeenCalledWith({ + fqn: 'secondAlert', + listCountOnly: true, + }); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/AlertDetailsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/AlertDetailsPage.tsx index d5a0315d6d68..e4f347d4f5a4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/AlertDetailsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/AlertDetailsPage/AlertDetailsPage.tsx @@ -11,333 +11,35 @@ * limitations under the License. */ -import { SyncOutlined } from '@ant-design/icons'; -import { Button, Card, Col, Row, Skeleton, Space, Tabs, Tooltip } from 'antd'; -import { AxiosError } from 'axios'; -import { compare } from 'fast-json-patch'; -import { isUndefined, omitBy } from 'lodash'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { isUndefined } from 'lodash'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; -import { ReactComponent as EditIcon } from '../../assets/svg/edit-new.svg'; -import { ReactComponent as DeleteIcon } from '../../assets/svg/ic-delete.svg'; -import AlertConfigDetails from '../../components/Alerts/AlertDetails/AlertConfigDetails/AlertConfigDetails'; -import AlertDiagnosticInfoTab from '../../components/Alerts/AlertDetails/AlertDiagnosticInfo/AlertDiagnosticInfoTab'; -import AlertRecentEventsTab from '../../components/Alerts/AlertDetails/AlertRecentEventsTab/AlertRecentEventsTab'; -import DeleteWidgetModal from '../../components/common/DeleteWidget/DeleteWidgetModal'; -import DescriptionV1 from '../../components/common/EntityDescription/DescriptionV1'; import ErrorPlaceHolder from '../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; import Loader from '../../components/common/Loader/Loader'; -import { OwnerLabel } from '../../components/common/OwnerLabel/OwnerLabel.component'; -import TitleBreadcrumb from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.component'; -import EntityHeaderTitle from '../../components/Entity/EntityHeaderTitle/EntityHeaderTitle.component'; import PageLayoutV1 from '../../components/PageLayoutV1/PageLayoutV1'; -import { DE_ACTIVE_COLOR, ROUTES } from '../../constants/constants'; -import { - GlobalSettingOptions, - GlobalSettingsMenuCategory, -} from '../../constants/GlobalSettings.constants'; -import { usePermissionProvider } from '../../context/PermissionProvider/PermissionProvider'; -import { - OperationPermission, - ResourceEntity, -} from '../../context/PermissionProvider/PermissionProvider.interface'; -import { AlertDetailTabs } from '../../enums/Alerts.enum'; import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum'; -import { EntityType } from '../../enums/entity.enum'; -import { EventsRecord } from '../../generated/events/api/eventsRecord'; -import { - EntityReference, - EventSubscription, - ProviderType, -} from '../../generated/events/eventSubscription'; -import { useFqn } from '../../hooks/useFqn'; -import { updateNotificationAlert } from '../../rest/alertsAPI'; -import { - getAlertEventsDiagnosticsInfo, - getObservabilityAlertByFQN, - syncOffset, - updateObservabilityAlert, -} from '../../rest/observabilityAPI'; -import { getAlertExtraInfo } from '../../utils/Alerts/AlertsUtil'; import { getEntityName } from '../../utils/EntityNameUtils'; -import observabilityRouterClassBase from '../../utils/ObservabilityRouterClassBase'; -import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils'; -import { - getNotificationAlertDetailsPath, - getNotificationAlertsEditPath, - getSettingPath, -} from '../../utils/RouterUtils'; -import searchClassBase from '../../utils/SearchClassBase'; -import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils'; -import { useRequiredParams } from '../../utils/useRequiredParams'; import { AlertDetailsPageProps } from './AlertDetailsPage.interface'; +import AlertDetailsContent from './components/AlertDetailsContent'; +import { useAlertDetailsPage } from './hooks/useAlertDetailsPage'; function AlertDetailsPage({ + afterDeleteAction, + fqn, isNotificationAlert = false, + onEditAlert, + onTabChange, + tab, }: Readonly) { - const { getEntityPermissionByFqn } = usePermissionProvider(); - const { tab } = useRequiredParams<{ tab: AlertDetailTabs }>(); - const { fqn } = useFqn(); - const navigate = useNavigate(); const { t } = useTranslation(); - const [alertDetails, setAlertDetails] = useState(); - const [alertEventCounts, setAlertEventCounts] = useState(); - const [loadingCount, setLoadingCount] = useState(0); - const [ownerLoading, setOwnerLoading] = useState(false); - const [alertEventCountsLoading, setAlertEventCountsLoading] = useState(false); - const [showDeleteModal, setShowDeleteModal] = useState(false); - - const [alertPermission, setAlertPermission] = useState( - DEFAULT_ENTITY_PERMISSION - ); - const [isSyncing, setIsSyncing] = useState(false); - const { - viewPermission, - editOwnersPermission, - editDescriptionPermission, - editPermission, - deletePermission, - } = useMemo( - () => ({ - viewPermission: alertPermission.ViewAll || alertPermission.ViewBasic, - editPermission: alertPermission.EditAll, - editOwnersPermission: - alertPermission.EditAll || alertPermission.EditOwners, - editDescriptionPermission: - alertPermission.EditAll || alertPermission.EditDescription, - deletePermission: alertPermission.Delete, - }), - [alertPermission] - ); - - const fetchResourcePermission = useCallback(async () => { - try { - setLoadingCount((count) => count + 1); - if (fqn) { - const searchIndexPermission = await getEntityPermissionByFqn( - ResourceEntity.EVENT_SUBSCRIPTION, - fqn - ); - - setAlertPermission(searchIndexPermission); - } - } finally { - setLoadingCount((count) => count - 1); - } - }, [fqn, getEntityPermissionByFqn]); - - const alertIcon = useMemo( - () => searchClassBase.getEntityIcon(EntityType.ALERT, 'h-9'), - [] - ); - - const fetchAlertDetails = async () => { - try { - setLoadingCount((count) => count + 1); - const observabilityAlert = await getObservabilityAlertByFQN(fqn, { - fields: 'owners', - }); - - setAlertDetails(observabilityAlert); - } catch { - // Error handling - } finally { - setLoadingCount((count) => count - 1); - } - }; - - const fetchAlertEventDiagnosticCounts = async () => { - try { - setAlertEventCountsLoading(true); - const alertCounts = await getAlertEventsDiagnosticsInfo({ - fqn, - listCountOnly: true, - }); - - setAlertEventCounts(alertCounts); - } catch { - // Error handling - } finally { - setAlertEventCountsLoading(false); - } - }; - - const breadcrumb = useMemo( - () => - isNotificationAlert - ? [ - { - name: t('label.setting-plural'), - url: ROUTES.SETTINGS, - }, - { - name: t('label.notification-plural'), - url: getSettingPath(GlobalSettingsMenuCategory.NOTIFICATIONS), - }, - { - name: t('label.alert-plural'), - url: getSettingPath( - GlobalSettingsMenuCategory.NOTIFICATIONS, - GlobalSettingOptions.ALERTS - ), - }, - { - name: getEntityName(alertDetails), - url: '', - }, - ] - : [ - { - name: t('label.observability'), - url: '', - }, - { - name: t('label.alert-plural'), - url: observabilityRouterClassBase.getObservabilityAlertsListPath(), - }, - { - name: getEntityName(alertDetails), - url: '', - }, - ], - [alertDetails] - ); - - const handleAlertDelete = useCallback(async () => { - isNotificationAlert - ? navigate(ROUTES.NOTIFICATION_ALERT_LIST) - : navigate(observabilityRouterClassBase.getObservabilityAlertsListPath()); - }, [navigate, isNotificationAlert]); - - const handleAlertEdit = useCallback(async () => { - navigate( - isNotificationAlert - ? getNotificationAlertsEditPath(fqn) - : observabilityRouterClassBase.getObservabilityAlertsEditPath(fqn) - ); - }, [navigate, fqn, isNotificationAlert]); - - const handleAlertSync = useCallback(async () => { - try { - setIsSyncing(true); - await syncOffset(fqn); - showSuccessToast(t('message.alert-synced-successfully')); - } catch (error) { - showErrorToast(error as AxiosError); - } finally { - setIsSyncing(false); - } - }, [fqn]); - - const onOwnerUpdate = useCallback( - async (owners?: EntityReference[]) => { - try { - setOwnerLoading(true); - const jsonPatch = compare(omitBy(alertDetails, isUndefined), { - ...alertDetails, - owners, - }); - - const updatedAlert = await (isNotificationAlert - ? updateNotificationAlert(alertDetails?.id ?? '', jsonPatch) - : updateObservabilityAlert(alertDetails?.id ?? '', jsonPatch)); - - setAlertDetails(updatedAlert); - } catch (error) { - showErrorToast(error as AxiosError); - } finally { - setOwnerLoading(false); - } - }, - [fqn, history, alertDetails] - ); - - const onDescriptionUpdate = useCallback( - async (description: string) => { - try { - const jsonPatch = compare(omitBy(alertDetails, isUndefined), { - ...alertDetails, - description, - }); - - const updatedAlert = await (isNotificationAlert - ? updateNotificationAlert(alertDetails?.id ?? '', jsonPatch) - : updateObservabilityAlert(alertDetails?.id ?? '', jsonPatch)); - - setAlertDetails(updatedAlert); - } catch (error) { - showErrorToast(error as AxiosError); - } - }, - [fqn, history, alertDetails] - ); - - const tabItems = useMemo( - () => [ - { - label: t('label.configuration'), - key: AlertDetailTabs.CONFIGURATION, - children: isUndefined(alertDetails) ? ( - - ) : ( - - ), - }, - { - label: t('label.recent-event-plural'), - key: AlertDetailTabs.RECENT_EVENTS, - children: isUndefined(alertDetails) ? null : ( - - ), - }, - { - label: t('label.diagnostic-info'), - key: AlertDetailTabs.DIAGNOSTIC_INFO, - children: , - }, - ], - [alertDetails, viewPermission] - ); - - const handleTabChange = useCallback( - (activeKey: string) => { - navigate( - isNotificationAlert - ? getNotificationAlertDetailsPath(fqn, activeKey) - : observabilityRouterClassBase.getObservabilityAlertDetailsPath( - fqn, - activeKey - ), - { replace: true } - ); - }, - [navigate, fqn, isNotificationAlert] - ); - - const hideDeleteModal = useCallback(() => { - setShowDeleteModal(false); - }, []); - - // Always keep this useEffect at first - useEffect(() => { - fetchResourcePermission(); - }, []); - - useEffect(() => { - if (viewPermission) { - fetchAlertDetails(); - fetchAlertEventDiagnosticCounts(); - } - }, [viewPermission]); - - const extraInfo = useMemo( - () => getAlertExtraInfo(alertEventCountsLoading, alertEventCounts), - [alertEventCounts, alertEventCountsLoading] - ); + const detailsState = useAlertDetailsPage({ + afterDeleteAction, + fqn, + isNotificationAlert, + onEditAlert, + onTabChange, + tab, + }); + const { alertDetails, loadingCount, viewPermission } = detailsState; if (!loadingCount && !isUndefined(viewPermission) && !viewPermission) { return ( @@ -356,134 +58,7 @@ function AlertDetailsPage({ return ( - {loadingCount ? ( - - ) : ( - - - - - - - - - - - - - - -
- {ownerLoading ? ( - - ) : ( - - )} - {extraInfo} -
- -
- - - - - - - )} - -
-
+ - -
- - - {(col) => ( - - )} - - - loading ? ( - <> - ) : ( - - navigate( - observabilityRouterClassBase.getAddObservabilityAlertsPath() - ) - } - /> - ) - }> - {(record) => renderRow(record as EventSubscription)} - -
-
- {showPagination && ( -
- -
- )} -
+ { entityName={getEntityName(selectedAlert)} entityType={EntityType.SUBSCRIPTION} visible={Boolean(selectedAlert)} - onCancel={() => { - setSelectedAlert(undefined); - }} + onCancel={() => handleSelectAlert(undefined)} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertActions.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertActions.tsx new file mode 100644 index 000000000000..cb233396b875 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertActions.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Button, Skeleton, Tooltip, Typography } from 'antd'; +import { isUndefined } from 'lodash'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { ReactComponent as EditIcon } from '../../../assets/svg/edit-new.svg'; +import { ReactComponent as DeleteIcon } from '../../../assets/svg/ic-delete.svg'; +import { + DE_ACTIVE_COLOR, + NO_DATA_PLACEHOLDER, +} from '../../../constants/constants'; +import { ProviderType } from '../../../generated/events/eventSubscription'; +import observabilityRouterClassBase from '../../../utils/ObservabilityRouterClassBase'; +import { ObservabilityAlertActionsProps } from '../ObservabilityAlertsPage.interface'; + +function ObservabilityAlertActions({ + alertPermission, + loading, + record, + onSelectAlert, +}: Readonly) { + const { t } = useTranslation(); + + if (loading) { + return ; + } + + if ( + isUndefined(alertPermission) || + (!alertPermission.edit && !alertPermission.delete) + ) { + return ( + + {NO_DATA_PLACEHOLDER} + + ); + } + + return ( +
+ {alertPermission.edit && ( + + +
+ ); +} + +export default ObservabilityAlertActions; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertsHeader.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertsHeader.tsx new file mode 100644 index 000000000000..3da9c72e41e4 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertsHeader.tsx @@ -0,0 +1,63 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Button, Card, Col, Row } from 'antd'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import PageHeader from '../../../components/PageHeader/PageHeader.component'; +import { LEARNING_PAGE_IDS } from '../../../constants/Learning.constants'; +import LimitWrapper from '../../../hoc/LimitWrapper'; +import { ObservabilityAlertsHeaderProps } from '../ObservabilityAlertsPage.interface'; + +function ObservabilityAlertsHeader({ + canCreate, + onAddAlert, +}: Readonly) { + const { t } = useTranslation(); + + const pageHeaderData = useMemo( + () => ({ + header: t('label.observability-alert'), + subHeader: t('message.alerts-description'), + }), + [t] + ); + + return ( + + + + + + + {canCreate && ( + + + + )} + + + + ); +} + +export default ObservabilityAlertsHeader; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertsTable.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertsTable.tsx new file mode 100644 index 000000000000..a6cbac470598 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/components/ObservabilityAlertsTable.tsx @@ -0,0 +1,164 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Table, TableCard } from '@openmetadata/ui-core-components'; +import { Button } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import ErrorPlaceHolder from '../../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder'; +import NextPrevious from '../../../components/common/NextPrevious/NextPrevious'; +import RichTextEditorPreviewerNew from '../../../components/common/RichTextEditor/RichTextEditorPreviewNew'; +import { ALERTS_DOCS } from '../../../constants/docs.constants'; +import { ERROR_PLACEHOLDER_TYPE } from '../../../enums/common.enum'; +import { EventSubscription } from '../../../generated/events/eventSubscription'; +import { getEntityName } from '../../../utils/EntityNameUtils'; +import { ALERT_TABLE_COLUMN_IDS } from '../ObservabilityAlertsPage.constants'; +import { ObservabilityAlertsTableProps } from '../ObservabilityAlertsPage.interface'; +import { + getAlertTableCellLayoutClassName, + getAlertTableHeaderLayoutClassName, +} from '../ObservabilityAlertsPage.utils'; +import ObservabilityAlertActions from './ObservabilityAlertActions'; + +function ObservabilityAlertsTable({ + alertPermissions, + alerts, + columnList, + currentPage, + getAlertDetailsPath, + loading, + loadingCount, + onAddAlert, + onPageChange, + onPageSizeChange, + onSelectAlert, + onViewAlert, + paging, + pageSize, + showPagination, +}: Readonly) { + const { t } = useTranslation(); + + const renderRow = (record: EventSubscription) => { + const alertPermission = alertPermissions?.find( + (alert) => alert.id === record.id + ); + const alertName = getEntityName(record); + const alertFqn = record.fullyQualifiedName ?? ''; + + return ( + + + {onViewAlert ? ( + + ) : ( + + {alertName} + + )} + + + {record.filteringRules?.resources?.join(', ') || '--'} + + + + + +
+ 0} + record={record} + onSelectAlert={onSelectAlert} + /> +
+
+
+ ); + }; + + return ( + +
+ + + {(col) => ( + + )} + + + loading ? ( + <> + ) : ( + + ) + }> + {(record) => renderRow(record as EventSubscription)} + +
+
+ {showPagination && ( +
+ +
+ )} +
+ ); +} + +export default ObservabilityAlertsTable; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/hooks/useObservabilityAlerts.ts b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/hooks/useObservabilityAlerts.ts new file mode 100644 index 000000000000..abbb6bec967c --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ObservabilityAlertsPage/hooks/useObservabilityAlerts.ts @@ -0,0 +1,244 @@ +/* + * Copyright 2026 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AxiosError } from 'axios'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { PagingHandlerParams } from '../../../components/common/NextPrevious/NextPrevious.interface'; +import { useLimitStore } from '../../../context/LimitsProvider/useLimitsStore'; +import { usePermissionProvider } from '../../../context/PermissionProvider/PermissionProvider'; +import { + OperationPermission, + ResourceEntity, +} from '../../../context/PermissionProvider/PermissionProvider.interface'; +import { + AlertType, + EventSubscription, + ProviderType, +} from '../../../generated/events/eventSubscription'; +import { Paging } from '../../../generated/type/paging'; +import { usePaging } from '../../../hooks/paging/usePaging'; +import { getAllAlerts } from '../../../rest/alertsAPI'; +import observabilityRouterClassBase from '../../../utils/ObservabilityRouterClassBase'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import { + AlertTableColumn, + ALERT_TABLE_COLUMN_IDS, +} from '../ObservabilityAlertsPage.constants'; +import { + AlertPermission, + UseObservabilityAlertsOptions, + UseObservabilityAlertsReturn, +} from '../ObservabilityAlertsPage.interface'; + +export function useObservabilityAlerts({ + getAlertDetailsPath = (fqn: string) => + observabilityRouterClassBase.getObservabilityAlertDetailsPath(fqn), + onAddAlert, + onViewAlert, +}: UseObservabilityAlertsOptions = {}): UseObservabilityAlertsReturn { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [loading, setLoading] = useState(true); + const [loadingCount, setLoadingCount] = useState(0); + const [alerts, setAlerts] = useState([]); + const [selectedAlert, setSelectedAlert] = useState(); + const [alertPermissions, setAlertPermissions] = useState(); + const [alertResourcePermission, setAlertResourcePermission] = + useState(); + const { + pageSize, + currentPage, + handlePageChange, + handlePageSizeChange, + handlePagingChange, + showPagination, + paging, + pagingCursor, + } = usePaging(); + const { getResourceLimit } = useLimitStore(); + const { getEntityPermissionByFqn, getResourcePermission } = + usePermissionProvider(); + + const fetchAlertResourcePermission = useCallback(async () => { + try { + setLoadingCount((count) => count + 1); + const permission = await getResourcePermission( + ResourceEntity.EVENT_SUBSCRIPTION + ); + + setAlertResourcePermission(permission); + } catch { + // Error + } finally { + setLoadingCount((count) => count - 1); + } + }, [getResourcePermission]); + + const fetchAlertPermissionByFqn = useCallback( + async (alertDetails: EventSubscription) => { + const permission = await getEntityPermissionByFqn( + ResourceEntity.EVENT_SUBSCRIPTION, + alertDetails.fullyQualifiedName ?? '' + ); + + return { + id: alertDetails.id, + edit: permission.EditAll, + delete: permission.Delete, + }; + }, + [getEntityPermissionByFqn] + ); + + const fetchAllAlertsPermission = useCallback( + async (alerts: EventSubscription[]) => { + try { + setLoadingCount((count) => count + 1); + const response = alerts.map((alert) => + fetchAlertPermissionByFqn(alert) + ); + + setAlertPermissions(await Promise.all(response)); + } catch { + // Error + } finally { + setLoadingCount((count) => count - 1); + } + }, + [fetchAlertPermissionByFqn] + ); + + const fetchAlerts = useCallback( + async (params?: Partial) => { + setLoading(true); + try { + const { data, paging } = await getAllAlerts({ + after: params?.after, + before: params?.before, + limit: pageSize, + alertType: AlertType.Observability, + }); + const alertsList = data.filter( + (d) => d.provider !== ProviderType.System + ); + + setAlerts(alertsList); + handlePagingChange(paging); + fetchAllAlertsPermission(alertsList); + } catch { + showErrorToast( + t('server.entity-fetch-error', { entity: t('label.alert-plural') }) + ); + } finally { + setLoading(false); + } + }, + [fetchAllAlertsPermission, handlePagingChange, pageSize, t] + ); + + useEffect(() => { + fetchAlertResourcePermission(); + }, []); + + useEffect(() => { + const { cursorType, cursorValue } = pagingCursor ?? {}; + + if (cursorType && cursorValue) { + fetchAlerts({ [cursorType]: cursorValue }); + } else { + fetchAlerts(); + } + }, [pageSize, pagingCursor]); + + const handleAlertDelete = useCallback(async () => { + try { + setSelectedAlert(undefined); + await getResourceLimit('eventsubscription', true, true); + fetchAlerts(); + } catch (error) { + showErrorToast(error as AxiosError); + } + }, [fetchAlerts, getResourceLimit]); + + const onPageChange = useCallback( + ({ cursorType, currentPage }: PagingHandlerParams) => { + if (cursorType) { + handlePageChange( + currentPage, + { cursorType, cursorValue: paging[cursorType] }, + pageSize + ); + } + }, + [handlePageChange, paging, pageSize] + ); + + const columnList = useMemo( + () => [ + { + id: ALERT_TABLE_COLUMN_IDS.NAME, + name: t('label.name'), + }, + { + id: ALERT_TABLE_COLUMN_IDS.TRIGGER, + name: t('label.trigger'), + }, + { + id: ALERT_TABLE_COLUMN_IDS.DESCRIPTION, + name: t('label.description'), + }, + { + id: ALERT_TABLE_COLUMN_IDS.ACTIONS, + name: t('label.action-plural'), + }, + ], + [t] + ); + + const handleAddAlert = useCallback(() => { + if (onAddAlert) { + onAddAlert(); + + return; + } + + navigate(observabilityRouterClassBase.getAddObservabilityAlertsPath()); + }, [navigate, onAddAlert]); + + const handleSelectAlert = useCallback((alert?: EventSubscription) => { + setSelectedAlert(alert); + }, []); + + return { + alertPermissions, + alertResourcePermission, + alerts, + columnList, + currentPage, + getAlertDetailsPath, + handleAddAlert, + handleAlertDelete, + handlePageSizeChange, + handleSelectAlert, + loading, + loadingCount, + onPageChange, + onViewAlert, + paging, + pageSize, + selectedAlert, + showPagination, + }; +}