Feat #10757: Data observability UI changes (#14749)

* Centralize EventType

* Format better for readability

* Add Handling for Thread as Change Event

* Centralize logic for Entity Message Creation

* Add Thread Parent Message in Conversation

* Add Task and Announcement

* Support for multiple destinations

* Delete existing alerts for schema changes

* Delete all tables data for event subscription

* Centralize logic for Notifications and Observability

* Change Field Name and Remove redundant code

* Test Fixes

* Add more fields

* Add Optional

* Logical Test Case Addition Chnage Event

* Add Filter By Owner

* Fix Email Entity Url

* added data observability settings page

* localization changes for other languages

* Handler closure and cleanup

* complete create observibility flow

* fix searchIndex method error

---------

Co-authored-by: mohitdeuex <mohit.y@deuexsolutions.com>
Co-authored-by: Mohit Yadav <105265192+mohityadav766@users.noreply.github.com>
Co-authored-by: Chirag Madlani <12962843+chirag-madlani@users.noreply.github.com>
This commit is contained in:
Aniket Katkar 2024-01-19 14:30:47 +05:30 committed by GitHub
parent 72fc0cf685
commit f3f73a3f01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1993 additions and 748 deletions

View File

@ -11,9 +11,8 @@
* limitations under the License.
*/
import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import Icon from '@ant-design/icons/lib/components/Icon';
import { Button, Card, Col, Divider, Row, Space, Tag, Typography } from 'antd';
import { Button, Card, Col, Divider, Row, Space, Typography } from 'antd';
import { isArray } from 'lodash';
import React, { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
@ -23,16 +22,12 @@ import { ReactComponent as IconDelete } from '../../../assets/svg/ic-delete.svg'
import {
Effect,
EventSubscription,
SubscriptionType,
} from '../../../generated/events/eventSubscription';
import {
EDIT_LINK_PATH,
getAlertActionTypeDisplayName,
getAlertsActionTypeIcon,
getDisplayNameForEntities,
getFunctionDisplayName,
} from '../../../utils/Alerts/AlertsUtil';
import { getHostNameFromURL } from '../../../utils/CommonUtils';
import TitleBreadcrumb from '../../common/TitleBreadcrumb/TitleBreadcrumb.component';
import { TitleBreadcrumbProps } from '../../common/TitleBreadcrumb/TitleBreadcrumb.interface';
import PageHeader from '../../PageHeader/PageHeader.component';
@ -122,108 +117,7 @@ export const AlertDetailsComponent = ({
<Typography.Title level={5}>
{t('label.destination')}
</Typography.Title>
<Row gutter={[16, 16]}>
<Col key={alerts.name} span={8}>
{alerts.subscriptionType === SubscriptionType.ActivityFeed ? (
<Space size={16}>
{getAlertsActionTypeIcon(alerts.subscriptionType)}
{getAlertActionTypeDisplayName(
alerts.subscriptionType ?? SubscriptionType.GenericWebhook
)}
</Space>
) : (
<Card
className="h-full"
title={
<Space size={16}>
{getAlertsActionTypeIcon(alerts.subscriptionType)}
{getAlertActionTypeDisplayName(
alerts.subscriptionType ??
SubscriptionType.GenericWebhook
)}
</Space>
}>
<Space direction="vertical" size={8}>
{alerts.subscriptionType === SubscriptionType.Email && (
<>
<Typography.Text>
{t('label.send-to')}:{' '}
<div>
{alerts.subscriptionConfig?.receivers?.map(
(rec) => (
<Tag key={rec}>{rec}</Tag>
)
)}
</div>
</Typography.Text>
<Typography.Text>
<Space size={16}>
<span>
{alerts?.subscriptionConfig?.sendToAdmins ? (
<CheckCircleOutlined />
) : (
<CloseCircleOutlined />
)}{' '}
{t('label.admin-plural')}
</span>
<span>
{alerts?.subscriptionConfig?.sendToOwners ? (
<CheckCircleOutlined />
) : (
<CloseCircleOutlined />
)}{' '}
{t('label.owner-plural')}
</span>
<span>
{alerts?.subscriptionConfig?.sendToFollowers ? (
<CheckCircleOutlined />
) : (
<CloseCircleOutlined />
)}{' '}
{t('label.follower-plural')}
</span>
</Space>
</Typography.Text>
</>
)}
{alerts.subscriptionType !== SubscriptionType.Email && (
<>
<Typography.Text>
<Typography.Text type="secondary">
{t('label.webhook')}:{' '}
</Typography.Text>
{getHostNameFromURL(
alerts.subscriptionConfig?.endpoint ?? '-'
)}
</Typography.Text>
<Typography.Text>
<Typography.Text type="secondary">
{t('label.batch-size')}:{' '}
</Typography.Text>
{alerts.batchSize}
</Typography.Text>
<Typography.Text>
<Typography.Text type="secondary">
{t('message.field-timeout-description')}:{' '}
</Typography.Text>
{alerts.timeout}
</Typography.Text>
<Typography.Text>
<Typography.Text type="secondary">
{t('label.secret-key')}:{' '}
</Typography.Text>
{alerts.subscriptionConfig?.secretKey ? '****' : '-'}
</Typography.Text>
</>
)}
</Space>
</Card>
)}
</Col>
</Row>
<Row gutter={[16, 16]} />
</Card>
</Col>
</Row>

View File

@ -36,6 +36,12 @@ const AddAlertPage = withSuspenseFallback(
React.lazy(() => import('../../pages/AddAlertPage/AddAlertPage'))
);
const AddObservabilityPage = withSuspenseFallback(
React.lazy(
() => import('../../pages/AddObservabilityPage/AddObservabilityPage')
)
);
const ImportTeamsPage = withSuspenseFallback(
React.lazy(
() => import('../../pages/TeamsPage/ImportTeamsPage/ImportTeamsPage')
@ -51,19 +57,15 @@ const AlertsActivityFeedPage = withSuspenseFallback(
() => import('../../pages/AlertsActivityFeedPage/AlertsActivityFeedPage')
)
);
const AlertDataInsightReportPage = withSuspenseFallback(
React.lazy(
() =>
import(
'../../pages/AlertDataInsightReportPage/AlertDataInsightReportPage'
)
)
);
const AlertsPage = withSuspenseFallback(
React.lazy(() => import('../../pages/AlertsPage/AlertsPage'))
);
const ObservabilityPage = withSuspenseFallback(
React.lazy(() => import('../../pages/ObservabilityPage/ObservabilityPage'))
);
const TeamsPage = withSuspenseFallback(
React.lazy(() => import('../../pages/TeamsPage/TeamsPage'))
);
@ -315,6 +317,16 @@ const GlobalSettingRouter = () => {
)}
/>
<AdminProtectedRoute
exact
component={ObservabilityPage}
hasPermission={false}
path={getSettingPath(
GlobalSettingsMenuCategory.NOTIFICATIONS,
GlobalSettingOptions.OBSERVABILITY
)}
/>
<AdminProtectedRoute
exact
component={AddAlertPage}
@ -334,6 +346,15 @@ const GlobalSettingRouter = () => {
GlobalSettingOptions.ADD_ALERTS
)}
/>
<AdminProtectedRoute
exact
component={AddObservabilityPage}
hasPermission={false}
path={getSettingPath(
GlobalSettingsMenuCategory.NOTIFICATIONS,
GlobalSettingOptions.ADD_OBSERVABILITY
)}
/>
<AdminProtectedRoute
exact
@ -356,16 +377,6 @@ const GlobalSettingRouter = () => {
)}
/>
<AdminProtectedRoute
exact
component={AlertDataInsightReportPage}
hasPermission={false}
path={getSettingPath(
GlobalSettingsMenuCategory.NOTIFICATIONS,
GlobalSettingOptions.DATA_INSIGHT_REPORT_ALERT
)}
/>
<AdminProtectedRoute
exact
component={CustomPropertiesPageV1}

View File

@ -50,9 +50,11 @@ export enum GlobalSettingOptions {
EMAIL = 'email',
ALERTS = 'alerts',
ALERT = 'alert',
OBSERVABILITY = 'observability',
GLOSSARY_TERM = 'glossaryTerm',
ADD_ALERTS = 'add-alerts',
EDIT_ALERTS = 'edit-alert',
ADD_OBSERVABILITY = 'add-observability',
STORAGES = 'storages',
DATA_INSIGHT_REPORT_ALERT = 'dataInsightReport',
ADD_DATA_INSIGHT_REPORT_ALERT = 'add-data-insight-report',

View File

@ -26,6 +26,7 @@ export enum SearchIndex {
CONTAINER = 'container_search_index',
QUERY = 'query_search_index',
TEST_CASE = 'test_case_search_index',
TEST_SUITE = 'test_suite_search_index',
DATABASE_SCHEMA = 'database_schema_search_index',
DATABASE = 'database_search_index',
DATABASE_SERVICE = 'database_service_search_index',

View File

@ -120,6 +120,7 @@ export interface TestCaseSearchSource
Exclude<TestCase, 'testSuite'> {
testSuites: TestSuite[];
} // extends EntityInterface
export interface TestSuiteSearchSource extends SearchSourceBase, TestSuite {}
export interface DatabaseServiceSearchSource
extends SearchSourceBase,
@ -201,6 +202,7 @@ export type SearchIndexSearchSourceMapping = {
[SearchIndex.STORED_PROCEDURE]: StoredProcedureSearchSource;
[SearchIndex.DASHBOARD_DATA_MODEL]: DashboardDataModelSearchSource;
[SearchIndex.DATA_PRODUCT]: DataProductSearchSource;
[SearchIndex.TEST_SUITE]: TestSuiteSearchSource;
};
export type SearchRequest<

View File

@ -11,6 +11,7 @@
"account-email": "Konto-E-Mail",
"account-name": "Kontoname",
"acknowledged": "Acknowledged",
"action": "Action",
"action-plural": "Aktionen",
"active": "Aktiv",
"active-user": "Aktiver Benutzer",
@ -706,6 +707,7 @@
"number-of-retries": "Anzahl der Wiederholungen",
"number-of-rows": "Anzahl der Zeilen",
"object-plural": "Objekte",
"observability": "Observability",
"october": "Oktober",
"of-lowercase": "von",
"ok": "Ok",

View File

@ -11,6 +11,7 @@
"account-email": "Account email",
"account-name": "Account Name",
"acknowledged": "Acknowledged",
"action": "Action",
"action-plural": "Actions",
"active": "Active",
"active-user": "Active User",
@ -706,6 +707,7 @@
"number-of-retries": "Number of Retries",
"number-of-rows": "Number of rows",
"object-plural": "Objects",
"observability": "Observability",
"october": "October",
"of-lowercase": "of",
"ok": "Ok",

View File

@ -11,6 +11,7 @@
"account-email": "Correo electrónico de la cuenta",
"account-name": "Account Name",
"acknowledged": "Acknowledged",
"action": "Action",
"action-plural": "Acciones",
"active": "Activo",
"active-user": "Usuario activo",
@ -706,6 +707,7 @@
"number-of-retries": "Number of Retries",
"number-of-rows": "Número de filas",
"object-plural": "Objects",
"observability": "Observability",
"october": "Octubre",
"of-lowercase": "de",
"ok": "Ok",

View File

@ -11,6 +11,7 @@
"account-email": "Compte email",
"account-name": "Nom du Compte",
"acknowledged": "Acknowledged",
"action": "Action",
"action-plural": "Actions",
"active": "Actif",
"active-user": "Utilisateur Actif",
@ -706,6 +707,7 @@
"number-of-retries": "Nombre de Tentatives",
"number-of-rows": "Nombre de lignes",
"object-plural": "Objets",
"observability": "Observability",
"october": "Octobre",
"of-lowercase": "de",
"ok": "Ok",

View File

@ -11,6 +11,7 @@
"account-email": "אימייל חשבון",
"account-name": "שם החשבון",
"acknowledged": "Acknowledged",
"action": "Action",
"action-plural": "פעולות",
"active": "פעיל",
"active-user": "משתמש פעיל",
@ -706,6 +707,7 @@
"number-of-retries": "מספר ניסיונות מחדש",
"number-of-rows": "מספר השורות",
"object-plural": "אובייקטים",
"observability": "Observability",
"october": "אוקטובר",
"of-lowercase": "של",
"ok": "אישור",

View File

@ -11,6 +11,7 @@
"account-email": "アカウントのEmail",
"account-name": "Account Name",
"acknowledged": "Acknowledged",
"action": "Action",
"action-plural": "アクション",
"active": "アクティブ",
"active-user": "アクティブなユーザ",
@ -706,6 +707,7 @@
"number-of-retries": "Number of Retries",
"number-of-rows": "行数",
"object-plural": "Objects",
"observability": "Observability",
"october": "10月",
"of-lowercase": "の",
"ok": "Ok",

View File

@ -11,6 +11,7 @@
"account-email": "Account e-mail",
"account-name": "Accountnaam",
"acknowledged": "Acknowledged",
"action": "Action",
"action-plural": "Acties",
"active": "Actief",
"active-user": "Actieve gebruiker",
@ -706,6 +707,7 @@
"number-of-retries": "Aantal herhalingen",
"number-of-rows": "Aantal rijen",
"object-plural": "Objecten",
"observability": "Observability",
"october": "Oktober",
"of-lowercase": "van",
"ok": "Oké",

View File

@ -11,6 +11,7 @@
"account-email": "E-mail da conta",
"account-name": "Nome da Conta",
"acknowledged": "Acknowledged",
"action": "Action",
"action-plural": "Ações",
"active": "Ativo",
"active-user": "Usuário Ativo",
@ -706,6 +707,7 @@
"number-of-retries": "Número de Tentativas",
"number-of-rows": "Número de linhas",
"object-plural": "Objetos",
"observability": "Observability",
"october": "Outubro",
"of-lowercase": "de",
"ok": "Ok",

View File

@ -11,6 +11,7 @@
"account-email": "Адрес электронной почты",
"account-name": "Наименование аккаунта",
"acknowledged": "Acknowledged",
"action": "Action",
"action-plural": "Действия",
"active": "Активный",
"active-user": "Активный пользователь",
@ -706,6 +707,7 @@
"number-of-retries": "Number of Retries",
"number-of-rows": "Количество строк",
"object-plural": "Объекты",
"observability": "Observability",
"october": "Октябрь",
"of-lowercase": "из",
"ok": "Ок",

View File

@ -11,6 +11,7 @@
"account-email": "帐号邮箱",
"account-name": "帐号名称",
"acknowledged": "Acknowledged",
"action": "Action",
"action-plural": "操作",
"active": "活跃的",
"active-user": "活跃用户",
@ -706,6 +707,7 @@
"number-of-retries": "重试次数",
"number-of-rows": "行数",
"object-plural": "对象",
"observability": "Observability",
"october": "十月",
"of-lowercase": "of",
"ok": "Ok",

View File

@ -66,6 +66,7 @@ import {
getAlertActionTypeDisplayName,
getAlertsActionTypeIcon,
getFunctionDisplayName,
listLengthValidator,
StyledCard,
} from '../../utils/Alerts/AlertsUtil';
import { getEntityName } from '../../utils/EntityUtils';
@ -196,7 +197,7 @@ const AddAlertPage = () => {
...(filteringRules as FilteringRules),
rules: requestFilteringRules,
},
alertType: AlertType.ChangeEvent,
alertType: AlertType.Notification,
provider,
});
@ -423,10 +424,10 @@ const AddAlertPage = () => {
{sendToCommonFields}
</>
);
case SubscriptionType.GenericWebhook:
case SubscriptionType.SlackWebhook:
case SubscriptionType.MSTeamsWebhook:
case SubscriptionType.GChatWebhook:
case SubscriptionType.Generic:
case SubscriptionType.Slack:
case SubscriptionType.MSTeams:
case SubscriptionType.GChat:
return (
<>
<Form.Item required name={['subscriptionConfig', 'endpoint']}>
@ -481,237 +482,240 @@ const AddAlertPage = () => {
}, [subscriptionType]);
return (
<>
<Row gutter={[16, 16]}>
<Col span={24}>
<Typography.Title level={5}>
{!isEmpty(fqn)
? t('label.edit-entity', { entity: t('label.alert-plural') })
: t('label.create-entity', { entity: t('label.alert-plural') })}
</Typography.Title>
<Typography.Text>{t('message.alerts-description')}</Typography.Text>
</Col>
<Col span={24}>
<Form<EventSubscription>
className="alerts-notification-form"
form={form}
onFinish={handleSave}
onValuesChange={handleChange}>
{loadingCount > 0 ? (
<Skeleton title paragraph={{ rows: 8 }} />
) : (
<>
<Form.Item
label={t('label.name')}
labelCol={{ span: 24 }}
name="name"
rules={[
{ required: true },
{
pattern: ENTITY_NAME_REGEX,
message: t('message.entity-name-validation'),
},
]}>
<Input disabled={isEditMode} placeholder={t('label.name')} />
</Form.Item>
<Form.Item
label={t('label.description')}
labelCol={{ span: 24 }}
name="description"
trigger="onTextChange"
valuePropName="initialValue">
<RichTextEditor
data-testid="description"
height="200px"
initialValue=""
/>
</Form.Item>
<Row gutter={[16, 16]}>
<Col span={8}>
<Space className="w-full" direction="vertical" size={16}>
<StyledCard
heading={t('label.trigger')}
subHeading={t('message.alerts-trigger-description')}
/>
<div>
<Form.Item
required
initialValue={['all']}
messageVariables={{
fieldName: t('label.data-asset-plural'),
}}
name={['filteringRules', 'resources']}>
<TreeSelect
treeCheckable
className="w-full"
data-testid="triggerConfig-type"
placeholder={t('label.select-field', {
field: t('label.data-asset-plural'),
})}
showCheckedStrategy={TreeSelect.SHOW_PARENT}
treeData={resourcesOptions}
/>
</Form.Item>
</div>
</Space>
</Col>
<Col span={8}>
<Space className="w-full" direction="vertical" size={16}>
<StyledCard
heading={t('label.filter-plural')}
subHeading={t('message.alerts-filter-description')}
/>
<Form.List name={['filteringRules', 'rules']}>
{(fields, { add, remove }, { errors }) => (
<>
<Button
block
data-testid="add-filters"
icon={<PlusOutlined />}
type="default"
onClick={() => add({}, 0)}>
{t('label.add-entity', {
entity: t('label.filter-plural'),
})}
</Button>
{fields.map(({ key, name }) => (
<div key={`filteringRules-${key}`}>
{name > 0 && (
<Divider
style={{
margin: 0,
marginBottom: '16px',
}}
/>
)}
<Row>
<Col span={22}>
<div className="flex-1">
<Form.Item
key={key}
name={[name, 'name']}>
<Select
options={functions}
placeholder={t('label.select-field', {
field: t('label.condition'),
})}
/>
</Form.Item>
{filters &&
filters[name] &&
getConditionField(
filters[name].name ?? '',
name
)}
<Form.Item
initialValue={Effect.Include}
key={key}
name={[name, 'effect']}>
<Select
options={map(
Effect,
(func: string) => ({
label: startCase(func),
value: func,
})
)}
placeholder={t('label.select-field', {
field: t('label.effect'),
})}
/>
</Form.Item>
</div>
</Col>
<Col span={2}>
<Button
data-testid={`remove-filter-rule-${name}`}
icon={
<SVGIcons
alt={t('label.delete')}
className="w-4"
icon={Icons.DELETE}
/>
}
type="text"
onClick={() => remove(name)}
/>
</Col>
</Row>
</div>
))}
<Form.ErrorList errors={errors} />
</>
)}
</Form.List>
</Space>
</Col>
<Col span={8}>
<Space className="w-full" direction="vertical" size={16}>
<StyledCard
heading={t('label.destination')}
subHeading={t('message.alerts-destination-description')}
/>
<Row gutter={[16, 16]}>
<Col span={24}>
<Typography.Title level={5}>
{!isEmpty(fqn)
? t('label.edit-entity', { entity: t('label.alert-plural') })
: t('label.create-entity', { entity: t('label.alert-plural') })}
</Typography.Title>
<Typography.Text>{t('message.alerts-description')}</Typography.Text>
</Col>
<Col span={24}>
<Form<EventSubscription>
className="alerts-notification-form"
form={form}
onFinish={handleSave}
onValuesChange={handleChange}>
{loadingCount > 0 ? (
<Skeleton title paragraph={{ rows: 8 }} />
) : (
<>
<Form.Item
label={t('label.name')}
labelCol={{ span: 24 }}
name="name"
rules={[
{ required: true },
{
pattern: ENTITY_NAME_REGEX,
message: t('message.entity-name-validation'),
},
]}>
<Input disabled={isEditMode} placeholder={t('label.name')} />
</Form.Item>
<Form.Item
label={t('label.description')}
labelCol={{ span: 24 }}
name="description"
trigger="onTextChange"
valuePropName="initialValue">
<RichTextEditor
data-testid="description"
height="200px"
initialValue=""
/>
</Form.Item>
<Row gutter={[16, 16]}>
<Col span={8}>
<Space className="w-full" direction="vertical" size={16}>
<StyledCard
heading={t('label.trigger')}
subHeading={t('message.alerts-trigger-description')}
/>
<div>
<Form.Item
required
name="subscriptionType"
rules={[
{
required: true,
message: t('label.field-required', {
field: t('label.destination'),
}),
},
]}>
<Select
data-testid="alert-action-type"
disabled={provider === ProviderType.System}
initialValue={['all']}
messageVariables={{
fieldName: t('label.data-asset-plural'),
}}
name={['filteringRules', 'resources']}>
<TreeSelect
treeCheckable
className="w-full"
data-testid="triggerConfig-type"
placeholder={t('label.select-field', {
field: t('label.source'),
field: t('label.data-asset-plural'),
})}
showSearch={false}>
{map(SubscriptionType, (value) => {
return [
SubscriptionType.ActivityFeed,
SubscriptionType.DataInsight,
].includes(value) ? null : (
<Select.Option key={value} value={value}>
<Space size={16}>
{getAlertsActionTypeIcon(
value as SubscriptionType
)}
{getAlertActionTypeDisplayName(value)}
</Space>
</Select.Option>
);
})}
</Select>
showCheckedStrategy={TreeSelect.SHOW_PARENT}
treeData={resourcesOptions}
/>
</Form.Item>
{getDestinationConfigFields()}
</Space>
</Col>
<Col className="footer" span={24}>
<Button onClick={() => history.goBack()}>
{t('label.cancel')}
</Button>
<Button
data-testid="save"
htmlType="submit"
loading={isButtonLoading}
type="primary">
{t('label.save')}
</Button>
</Col>
</Row>
</>
)}
</Form>
</Col>
<Col span={24} />
<Col span={24} />
</Row>
</>
</div>
</Space>
</Col>
<Col span={8}>
<Space className="w-full" direction="vertical" size={16}>
<StyledCard
heading={t('label.filter-plural')}
subHeading={t('message.alerts-filter-description')}
/>
<Form.List
name={['filteringRules', 'rules']}
rules={[
{
validator: listLengthValidator(
t('label.filter-plural')
),
},
]}>
{(fields, { add, remove }, { errors }) => (
<>
<Button
block
data-testid="add-filters"
icon={<PlusOutlined />}
type="default"
onClick={() => add({}, 0)}>
{t('label.add-entity', {
entity: t('label.filter-plural'),
})}
</Button>
{fields.map(({ key, name }) => (
<div key={`filteringRules-${key}`}>
{name > 0 && (
<Divider
style={{
margin: 0,
marginBottom: '16px',
}}
/>
)}
<Row>
<Col span={22}>
<div className="flex-1">
<Form.Item key={key} name={[name, 'name']}>
<Select
options={functions}
placeholder={t('label.select-field', {
field: t('label.condition'),
})}
/>
</Form.Item>
{filters &&
filters[name] &&
getConditionField(
filters[name].name ?? '',
name
)}
<Form.Item
initialValue={Effect.Include}
key={key}
name={[name, 'effect']}>
<Select
options={map(
Effect,
(func: string) => ({
label: startCase(func),
value: func,
})
)}
placeholder={t('label.select-field', {
field: t('label.effect'),
})}
/>
</Form.Item>
</div>
</Col>
<Col span={2}>
<Button
data-testid={`remove-filter-rule-${name}`}
icon={
<SVGIcons
alt={t('label.delete')}
className="w-4"
icon={Icons.DELETE}
/>
}
type="text"
onClick={() => remove(name)}
/>
</Col>
</Row>
</div>
))}
<Form.ErrorList errors={errors} />
</>
)}
</Form.List>
</Space>
</Col>
<Col span={8}>
<Space className="w-full" direction="vertical" size={16}>
<StyledCard
heading={t('label.destination')}
subHeading={t('message.alerts-destination-description')}
/>
<Form.Item
required
name="subscriptionType"
rules={[
{
required: true,
message: t('label.field-required', {
field: t('label.destination'),
}),
},
]}>
<Select
data-testid="alert-action-type"
disabled={provider === ProviderType.System}
placeholder={t('label.select-field', {
field: t('label.source'),
})}
showSearch={false}>
{map(SubscriptionType, (value) => {
return [SubscriptionType.ActivityFeed].includes(
value
) ? null : (
<Select.Option key={value} value={value}>
<Space size={16}>
{getAlertsActionTypeIcon(
value as SubscriptionType
)}
{getAlertActionTypeDisplayName(value)}
</Space>
</Select.Option>
);
})}
</Select>
</Form.Item>
{getDestinationConfigFields()}
</Space>
</Col>
<Col className="footer" span={24}>
<Button onClick={() => history.goBack()}>
{t('label.cancel')}
</Button>
<Button
data-testid="save"
htmlType="submit"
loading={isButtonLoading}
type="primary">
{t('label.save')}
</Button>
</Col>
</Row>
</>
)}
</Form>
</Col>
<Col span={24} />
<Col span={24} />
</Row>
);
};

View File

@ -0,0 +1,241 @@
/*
* Copyright 2024 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, Input, Row, Typography } from 'antd';
import { useForm } from 'antd/lib/form/Form';
import { AxiosError } from 'axios';
import { map, startCase } from 'lodash';
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import RichTextEditor from '../../components/common/RichTextEditor/RichTextEditor';
import TitleBreadcrumb from '../../components/common/TitleBreadcrumb/TitleBreadcrumb.component';
import { HTTP_STATUS_CODE } from '../../constants/Auth.constants';
import {
GlobalSettingOptions,
GlobalSettingsMenuCategory,
} from '../../constants/GlobalSettings.constants';
import { ENTITY_NAME_REGEX } from '../../constants/regex.constants';
import { CreateEventSubscription } from '../../generated/events/api/createEventSubscription';
import {
AlertType,
ProviderType,
SubscriptionType,
} from '../../generated/events/eventSubscription';
import { FilterResourceDescriptor } from '../../generated/events/filterResourceDescriptor';
import {
createObservabilityAlert,
getResourceFunctions,
} from '../../rest/observabilityAPI';
import { getSettingPath } from '../../utils/RouterUtils';
import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils';
import './add-observability-page.less';
import DestinationFormItem from './DestinationFormItem/DestinationFormItem.component';
import ObservabilityFormActionItem from './ObservabilityFormActionItem/ObservabilityFormActionItem';
import ObservabilityFormFiltersItem from './ObservabilityFormFiltersItem/ObservabilityFormFiltersItem';
import { default as ObservabilityFormTriggerItem } from './ObservabilityFormTriggerItem/ObservabilityFormTriggerItem';
function AddObservabilityPage() {
const { t } = useTranslation();
const history = useHistory();
const [form] = useForm<CreateEventSubscription>();
const [filterResources, setFilterResources] = useState<
FilterResourceDescriptor[]
>([]);
const notificationsPath = getSettingPath(
GlobalSettingsMenuCategory.NOTIFICATIONS,
GlobalSettingOptions.ALERT
);
const breadcrumb = useMemo(
() => [
{
name: t('label.setting-plural'),
url: notificationsPath,
},
{
name: t('label.observability'),
url: '',
},
],
[notificationsPath]
);
const fetchFunctions = async () => {
try {
const filterResources = await getResourceFunctions();
setFilterResources(filterResources.data);
} catch (error) {
// TODO: Handle error
}
};
useEffect(() => {
fetchFunctions();
}, []);
const handleSave = async (data: CreateEventSubscription) => {
try {
const resources = [data.resources as unknown as string];
await createObservabilityAlert({
...data,
resources,
});
showSuccessToast(
t(`server.${'create'}-entity-success`, {
entity: t('label.alert-plural'),
})
);
history.push(
getSettingPath(
GlobalSettingsMenuCategory.NOTIFICATIONS,
// We need this check to have correct redirection after updating the subscription
alert?.name === 'ActivityFeedAlert'
? GlobalSettingOptions.ACTIVITY_FEED
: GlobalSettingOptions.ALERTS
)
);
} catch (error) {
if (
(error as AxiosError).response?.status === HTTP_STATUS_CODE.CONFLICT
) {
showErrorToast(
t('server.entity-already-exist', {
entity: t('label.alert'),
entityPlural: t('label.alert-lowercase-plural'),
name: data.name,
})
);
} else {
showErrorToast(
error as AxiosError,
t(`server.${'entity-creation-error'}`, {
entity: t('label.alert-lowercase'),
})
);
}
}
};
return (
<Row className="add-notification-container" gutter={[24, 24]}>
<Col span={24}>
<TitleBreadcrumb titleLinks={breadcrumb} />
</Col>
<Col span={24}>
<Typography.Title level={5}>
{t('label.create-entity', { entity: t('label.observability') })}
</Typography.Title>
<Typography.Text>{t('message.alerts-description')}</Typography.Text>
</Col>
<Col span={24}>
<Form<CreateEventSubscription> form={form} onFinish={handleSave}>
<Row gutter={[20, 20]}>
<Col span={24}>
<Form.Item
label={t('label.name')}
labelCol={{ span: 24 }}
name="name"
rules={[
{ required: true },
{
pattern: ENTITY_NAME_REGEX,
message: t('message.entity-name-validation'),
},
]}>
<Input placeholder={t('label.name')} />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
label={t('label.description')}
labelCol={{ span: 24 }}
name="description"
trigger="onTextChange"
valuePropName="initialValue">
<RichTextEditor
data-testid="description"
height="200px"
initialValue=""
/>
</Form.Item>
</Col>
<Col span={24}>
<ObservabilityFormTriggerItem
buttonLabel={t('label.add-entity', {
entity: t('label.trigger'),
})}
filterResources={filterResources}
heading={t('label.trigger')}
subHeading={t('message.alerts-trigger-description')}
/>
</Col>
<Col span={24}>
<ObservabilityFormFiltersItem
filterResources={filterResources}
heading={t('label.filter-plural')}
subHeading={t('message.alerts-filter-description')}
/>
</Col>
<Col span={24}>
<ObservabilityFormActionItem
filterResources={filterResources}
heading={t('label.action-plural')}
subHeading={t('message.alerts-filter-description')}
/>
</Col>
<Form.Item
hidden
initialValue={AlertType.Observability}
name="alertType"
/>
<Form.Item
hidden
initialValue={ProviderType.User}
name="provider"
/>
<Col span={24}>
<DestinationFormItem
buttonLabel={t('label.add-entity', {
entity: t('label.destination'),
})}
filterResources={map(SubscriptionType, (type) => ({
label: startCase(type),
value: type,
}))}
heading={t('label.destination')}
subHeading={t('message.alerts-destination-description')}
/>
</Col>
<Col span={24}>
<Button className="m-r-sm" htmlType="submit">
{t('label.save')}
</Button>
<Button onClick={() => history.goBack()}>
{t('label.cancel')}
</Button>
</Col>
</Row>
</Form>
</Col>
</Row>
);
}
export default AddObservabilityPage;

View File

@ -0,0 +1,107 @@
/*
* Copyright 2024 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 { CloseOutlined } from '@ant-design/icons';
import { Button, Card, Col, Form, Row, Select, Typography } from 'antd';
import Input from 'antd/lib/input/Input';
import { DefaultOptionType } from 'antd/lib/select';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { SubscriptionCategory } from '../../../generated/events/eventSubscription';
function DestinationFormItem({
heading,
subHeading,
buttonLabel,
filterResources,
}: Readonly<{
heading: string;
subHeading: string;
buttonLabel: string;
filterResources: DefaultOptionType[];
}>) {
const { t } = useTranslation();
return (
<Card className="trigger-item-container">
<Row gutter={[8, 8]}>
<Col span={24}>
<Typography.Text>{heading}</Typography.Text>
</Col>
<Col span={24}>
<Typography.Text className="text-xs text-grey-muted">
{subHeading}
</Typography.Text>
</Col>
<Col span={12}>
<Form.List name="destinations">
{(fields, { add, remove }) => {
return (
<Row gutter={[16, 16]}>
{fields.map((field, index) => (
<>
<Col span={11}>
<Form.Item
required
messageVariables={{
fieldName: t('label.data-asset-plural'),
}}
name={[index, 'type']}>
<Select
className="w-full"
data-testid="triggerConfig-type"
options={filterResources}
placeholder={t('label.select-field', {
field: t('label.data-asset-plural'),
})}
/>
</Form.Item>
</Col>
<Col span={11}>
<Form.Item
hidden
initialValue={SubscriptionCategory.External}
name={[index, 'category']}
/>
<Form.Item
label=""
name={[index, 'config', 'receivers']}>
<Input placeholder="EndPoint URL" />
</Form.Item>
</Col>
<Col span={2}>
<Button
data-testid={`remove-action-rule-${name}`}
icon={<CloseOutlined />}
onClick={() => remove(field.name)}
/>
</Col>
</>
))}
<Form.Item>
<Button type="primary" onClick={add}>
{buttonLabel}
</Button>
</Form.Item>
</Row>
);
}}
</Form.List>
</Col>
</Row>
</Card>
);
}
export default DestinationFormItem;

View File

@ -0,0 +1,20 @@
/*
* Copyright 2024 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 { FilterResourceDescriptor } from '../../../generated/events/filterResourceDescriptor';
export interface ObservabilityFormActionItemProps {
heading: string;
subHeading: string;
filterResources: FilterResourceDescriptor[];
}

View File

@ -0,0 +1,311 @@
/*
* Copyright 2024 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 { CloseOutlined } from '@ant-design/icons';
import { Button, Card, Col, Form, Row, Select, Switch, Typography } from 'antd';
import { isEmpty, isNil } from 'lodash';
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { AsyncSelect } from '../../../components/AsyncSelect/AsyncSelect';
import { PAGE_SIZE_LARGE } from '../../../constants/constants';
import { SearchIndex } from '../../../enums/search.enum';
import { Effect } from '../../../generated/events/eventSubscription';
import { InputType } from '../../../generated/events/filterResourceDescriptor';
import { searchData } from '../../../rest/miscAPI';
import { listLengthValidator } from '../../../utils/Alerts/AlertsUtil';
import { getEntityName } from '../../../utils/EntityUtils';
import { getSearchIndexFromEntityType } from '../ObservabilityFormFiltersItem/ObservabilityFormFiltersItem';
import { ObservabilityFormActionItemProps } from './ObservabilityFormActionItem.interface';
function ObservabilityFormActionItem({
heading,
subHeading,
filterResources,
}: Readonly<ObservabilityFormActionItemProps>) {
const { t } = useTranslation();
const form = Form.useFormInstance();
// Watchers
const filters = Form.useWatch(['input', 'actions'], form);
const triggerValue = Form.useWatch(['resources'], form);
const selectedTrigger = useMemo(
() => form.getFieldValue(['resources']),
[triggerValue, form]
);
const selectedDescriptor = useMemo(
() => filterResources.find((resource) => resource.name === selectedTrigger),
[filterResources, selectedTrigger]
);
const supportedActions = useMemo(
() => selectedDescriptor?.supportedActions,
[selectedDescriptor]
);
const searchEntity = useCallback(
async (search: string, searchIndex: SearchIndex) => {
try {
const response = await searchData(
search,
1,
PAGE_SIZE_LARGE,
'',
'',
'',
searchIndex
);
return response.data.hits.hits.map((d) => ({
label: getEntityName(d._source),
value: d._source.fullyQualifiedName,
}));
} catch (error) {
return [];
}
},
[]
);
const getEntityByFQN = useCallback(
async (searchText: string) => {
try {
return searchEntity(
searchText,
getSearchIndexFromEntityType(selectedTrigger)
);
} catch {
return [];
}
},
[searchEntity, selectedTrigger]
);
const getTableSuggestions = useCallback(
async (searchText: string) => {
try {
return searchEntity(searchText, SearchIndex.TABLE);
} catch {
return [];
}
},
[searchEntity, selectedTrigger]
);
const getDomainOptions = useCallback(
async (searchText: string) => {
return searchEntity(searchText, SearchIndex.DOMAIN);
},
[searchEntity]
);
// Run time values needed for conditional rendering
const functions = useMemo(
() =>
selectedDescriptor?.supportedActions?.map((func) => ({
label: getEntityName(func),
value: func.name,
})),
[selectedDescriptor]
);
const getFieldByArgumentType = useCallback(
(fieldName: number, argument: string) => {
switch (argument) {
case 'fqnList':
return (
<>
<Col key="fqn-list-select" span={11}>
<Form.Item
className="w-full"
name={[fieldName, 'arguments', 'input']}>
<AsyncSelect
api={getEntityByFQN}
data-testid="fqn-list-select"
mode="multiple"
placeholder={t('label.search-by-type', {
type: t('label.fqn-uppercase'),
})}
showArrow={false}
/>
</Form.Item>
</Col>
<Form.Item
className="d-none"
initialValue="fqnList"
name={[fieldName, 'arguments', 'name']}
/>
</>
);
case 'domainList':
return (
<>
<Col key="domain-select" span={11}>
<Form.Item
className="w-full"
name={[fieldName, 'arguments', 'input']}>
<AsyncSelect
api={getDomainOptions}
data-testid="domain-select"
mode="multiple"
placeholder={t('label.search-by-type', {
type: t('label.domain-lowercase'),
})}
/>
</Form.Item>
</Col>
<Form.Item
className="d-none"
initialValue="domainList"
name={[fieldName, 'arguments', 'name']}
/>
</>
);
case 'tableNameList':
return (
<>
<Col key="domain-select" span={11}>
<Form.Item
className="w-full"
name={[fieldName, 'arguments', 'input']}>
<AsyncSelect
api={getTableSuggestions}
data-testid="table-select"
mode="multiple"
placeholder={t('label.search-by-type', {
type: t('label.table-lowercase'),
})}
/>
</Form.Item>
</Col>
<Form.Item
className="d-none"
initialValue="tableNameList"
name={[fieldName, 'arguments', 'name']}
/>
</>
);
default:
return <></>;
}
},
[getEntityByFQN, getDomainOptions]
);
// Render condition field based on function selected
const getConditionField = (condition: string, name: number) => {
const selectedAction = supportedActions?.find(
(action) => action.name === condition
);
const requireInput = selectedAction?.inputType === InputType.Runtime;
const requiredArguments = selectedAction?.arguments;
if (!requireInput) {
return <></>;
}
return (
<>
{requiredArguments?.map((argument) => {
return getFieldByArgumentType(name, argument);
})}
</>
);
};
return (
<Card className="trigger-item-container">
<Row gutter={[8, 8]}>
<Col span={24}>
<Typography.Text>{heading}</Typography.Text>
</Col>
<Col span={24}>
<Typography.Text className="text-xs text-grey-muted">
{subHeading}
</Typography.Text>
</Col>
<Col span={24}>
<Form.List
name={['input', 'actions']}
rules={[
{
validator: listLengthValidator(t('label.action-plural')),
},
]}>
{(fields, { add, remove }, { errors }) => (
<Row gutter={[16, 16]}>
{fields.map(({ key, name }) => (
<Col key={`observability-${key}`} span={24}>
<Row gutter={[8, 8]}>
<Col span={11}>
<Form.Item key={`action-${key}`} name={[name, 'name']}>
<Select
options={functions}
placeholder={t('label.select-field', {
field: t('label.action'),
})}
/>
</Form.Item>
</Col>
{!isNil(selectedDescriptor) &&
!isEmpty(filters) &&
filters[name] &&
getConditionField(filters[name].name ?? '', name)}
<Col span={2}>
<Button
data-testid={`remove-action-rule-${name}`}
icon={<CloseOutlined />}
onClick={() => remove(name)}
/>
</Col>
</Row>
<Form.Item
getValueFromEvent={(value) =>
value ? 'include' : 'exclude'
}
initialValue={Effect.Include}
key={`effect-${key}`}
label={
<Typography.Text>{t('label.include')}</Typography.Text>
}
name={[name, 'effect']}>
<Switch defaultChecked />
</Form.Item>
</Col>
))}
{fields.length < (supportedActions?.length ?? 1) && (
<Col span={24}>
<Button
data-testid="add-action"
type="primary"
onClick={() => add({})}>
{t('label.add-entity', {
entity: t('label.action'),
})}
</Button>
</Col>
)}
<Form.ErrorList errors={errors} />
</Row>
)}
</Form.List>
</Col>
</Row>
</Card>
);
}
export default ObservabilityFormActionItem;

View File

@ -0,0 +1,20 @@
/*
* Copyright 2024 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 { FilterResourceDescriptor } from '../../../generated/events/filterResourceDescriptor';
export interface ObservabilityFormFiltersItemProps {
heading: string;
subHeading: string;
filterResources: FilterResourceDescriptor[];
}

View File

@ -0,0 +1,357 @@
/*
* Copyright 2024 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 { CloseOutlined } from '@ant-design/icons';
import { Button, Card, Col, Form, Row, Select, Switch, Typography } from 'antd';
import { isEmpty, isNil } from 'lodash';
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { AsyncSelect } from '../../../components/AsyncSelect/AsyncSelect';
import { PAGE_SIZE_LARGE } from '../../../constants/constants';
import { SearchIndex } from '../../../enums/search.enum';
import { Effect } from '../../../generated/events/eventSubscription';
import { InputType } from '../../../generated/events/filterResourceDescriptor';
import { searchData } from '../../../rest/miscAPI';
import { listLengthValidator } from '../../../utils/Alerts/AlertsUtil';
import { getEntityName } from '../../../utils/EntityUtils';
import { ObservabilityFormFiltersItemProps } from './ObservabilityFormFiltersItem.interface';
export const getSearchIndexFromEntityType = (type: string) => {
switch (type) {
case 'table':
return SearchIndex.TABLE;
case 'topic':
return SearchIndex.TOPIC;
case 'pipeline':
return SearchIndex.PIPELINE;
case 'container':
return SearchIndex.CONTAINER;
case 'testCase':
return SearchIndex.TEST_CASE;
case 'testSuite':
return SearchIndex.TEST_SUITE;
default:
return SearchIndex.TABLE;
}
};
function ObservabilityFormFiltersItem({
heading,
subHeading,
filterResources,
}: Readonly<ObservabilityFormFiltersItemProps>) {
const { t } = useTranslation();
const form = Form.useFormInstance();
// Watchers
const filters = Form.useWatch(['input', 'filters'], form);
const triggerValue = Form.useWatch(['resources'], form);
const selectedTrigger = useMemo(
() => form.getFieldValue(['resources']),
[triggerValue, form]
);
const supportedFilters = useMemo(
() =>
filterResources.find((resource) => resource.name === selectedTrigger)
?.supportedFilters,
[filterResources, selectedTrigger]
);
const searchEntity = useCallback(
async (search: string, searchIndex: SearchIndex) => {
try {
const response = await searchData(
search,
1,
PAGE_SIZE_LARGE,
'',
'',
'',
searchIndex
);
return response.data.hits.hits.map((d) => ({
label: getEntityName(d._source),
value: d._source.fullyQualifiedName,
}));
} catch (error) {
return [];
}
},
[]
);
const getEntityByFQN = useCallback(
async (searchText: string) => {
try {
return searchEntity(
searchText,
getSearchIndexFromEntityType(selectedTrigger)
);
} catch {
return [];
}
},
[searchEntity, selectedTrigger]
);
const getTableSuggestions = useCallback(
async (searchText: string) => {
try {
return searchEntity(searchText, SearchIndex.TABLE);
} catch {
return [];
}
},
[searchEntity, selectedTrigger]
);
const getDomainOptions = useCallback(
async (searchText: string) => {
return searchEntity(searchText, SearchIndex.DOMAIN);
},
[searchEntity]
);
const getOwnerOptions = useCallback(
async (searchText: string) => {
return searchEntity(searchText, SearchIndex.USER);
},
[searchEntity]
);
// Run time values needed for conditional rendering
const functions = useMemo(
() =>
supportedFilters?.map((func) => ({
label: getEntityName(func),
value: func.name,
})),
[supportedFilters]
);
const getFieldByArgumentType = useCallback(
(fieldName: number, argument: string, index: number) => {
switch (argument) {
case 'fqnList':
return (
<>
<Col key="fqn-list-select" span={11}>
<Form.Item
className="w-full"
name={[fieldName, 'arguments', index, 'input']}>
<AsyncSelect
api={getEntityByFQN}
data-testid="fqn-list-select"
mode="multiple"
placeholder={t('label.search-by-type', {
type: t('label.fqn-uppercase'),
})}
showArrow={false}
/>
</Form.Item>
</Col>
<Form.Item
hidden
initialValue="fqnList"
name={[fieldName, 'arguments', index, 'name']}
/>
</>
);
case 'domainList':
return (
<>
<Col key="domain-select" span={11}>
<Form.Item
className="w-full"
name={[fieldName, 'arguments', 'input']}>
<AsyncSelect
api={getDomainOptions}
data-testid="domain-select"
mode="multiple"
placeholder={t('label.search-by-type', {
type: t('label.domain-lowercase'),
})}
/>
</Form.Item>
</Col>
<Form.Item
hidden
initialValue="domainList"
name={[fieldName, 'arguments', 'name']}
/>
</>
);
case 'tableNameList':
return (
<>
<Col key="domain-select" span={11}>
<Form.Item
className="w-full"
name={[fieldName, 'arguments', 'input']}>
<AsyncSelect
api={getTableSuggestions}
data-testid="table-select"
mode="multiple"
placeholder={t('label.search-by-type', {
type: t('label.table-lowercase'),
})}
/>
</Form.Item>
</Col>
<Form.Item
hidden
initialValue="tableNameList"
name={[fieldName, 'arguments', 'name']}
/>
</>
);
case 'ownerNameList':
return (
<>
<Col key="owner-select" span={11}>
<Form.Item
className="w-full"
name={[fieldName, 'arguments', 'input']}>
<AsyncSelect
api={getOwnerOptions}
data-testid="owner-select"
mode="multiple"
placeholder={t('label.search-by-type', {
type: t('label.owner-lowercase'),
})}
/>
</Form.Item>
</Col>
<Form.Item
hidden
initialValue="ownerNameList"
name={[fieldName, 'arguments', 'name']}
/>
</>
);
default:
return <></>;
}
},
[getEntityByFQN, getDomainOptions]
);
// Render condition field based on function selected
const getConditionField = (condition: string, name: number) => {
const selectedFilter = supportedFilters?.find(
(filter) => filter.name === condition
);
const requireInput = selectedFilter?.inputType === InputType.Runtime;
const requiredArguments = selectedFilter?.arguments;
if (!requireInput) {
return <></>;
}
return (
<>
{requiredArguments?.map((argument, index) => {
return getFieldByArgumentType(name, argument, index);
})}
</>
);
};
return (
<Card className="trigger-item-container">
<Row gutter={[8, 8]}>
<Col span={24}>
<Typography.Text>{heading}</Typography.Text>
</Col>
<Col span={24}>
<Typography.Text className="text-xs text-grey-muted">
{subHeading}
</Typography.Text>
</Col>
<Col span={24}>
<Form.List
name={['input', 'filters']}
rules={[
{
validator: listLengthValidator(t('label.filter-plural')),
},
]}>
{(fields, { add, remove }, { errors }) => (
<Row gutter={[16, 16]}>
{fields.map(({ key, name }) => (
<Col key={`observability-${key}`} span={24}>
<Row gutter={[8, 8]}>
<Col span={11}>
<Form.Item key={`filter-${key}`} name={[name, 'name']}>
<Select
options={functions}
placeholder={t('label.select-field', {
field: t('label.filter'),
})}
/>
</Form.Item>
</Col>
{!isNil(supportedFilters) &&
!isEmpty(filters) &&
filters[name] &&
getConditionField(filters[name].name ?? '', name)}
<Col span={2}>
<Button
data-testid={`remove-filter-rule-${name}`}
icon={<CloseOutlined />}
onClick={() => remove(name)}
/>
</Col>
</Row>
<Form.Item
getValueFromEvent={(value) =>
value ? 'include' : 'exclude'
}
initialValue={Effect.Include}
key={`effect-${key}`}
label={
<Typography.Text>{t('label.include')}</Typography.Text>
}
name={[name, 'effect']}
valuePropName="checked">
<Switch defaultChecked />
</Form.Item>
</Col>
))}
<Col span={24}>
<Button
data-testid="add-filters"
type="primary"
onClick={() => add({})}>
{t('label.add-entity', {
entity: t('label.filter'),
})}
</Button>
</Col>
<Form.ErrorList errors={errors} />
</Row>
)}
</Form.List>
</Col>
</Row>
</Card>
);
}
export default ObservabilityFormFiltersItem;

View File

@ -0,0 +1,21 @@
/*
* Copyright 2024 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 { FilterResourceDescriptor } from '../../../generated/events/filterResourceDescriptor';
export interface ObservabilityFormTriggerItemProps {
heading: string;
subHeading: string;
buttonLabel: string;
filterResources: FilterResourceDescriptor[];
}

View File

@ -0,0 +1,104 @@
/*
* Copyright 2024 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, Form, Row, Select, Space, Typography } from 'antd';
import { startCase } from 'lodash';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as IconTestSuite } from '../../../assets/svg/icon-test-suite.svg';
import { getEntityIcon } from '../../../utils/TableUtils';
import './observability-form-trigger-item.less';
import { ObservabilityFormTriggerItemProps } from './ObservabilityFormTriggerItem.interface';
function ObservabilityFormTriggerItem({
heading,
subHeading,
buttonLabel,
filterResources,
}: Readonly<ObservabilityFormTriggerItemProps>) {
const [editMode, setEditMode] = useState<boolean>(false);
const { t } = useTranslation();
const getIconForEntity = (type: string) => {
switch (type) {
case 'container':
case 'pipeline':
case 'topic':
case 'table':
return getEntityIcon(type);
case 'testCase':
case 'testSuite':
return <IconTestSuite height={16} width={16} />;
}
return null;
};
const handleAddTriggerClick = useCallback(() => {
setEditMode(true);
}, []);
const resourcesOptions = useMemo(
() =>
filterResources.map((resource) => ({
label: (
<Space align="center" size={4}>
{getIconForEntity(resource.name ?? '')}
{startCase(resource.name)}
</Space>
),
value: resource.name,
})),
[filterResources]
);
return (
<Card className="trigger-item-container">
<Row gutter={[8, 8]}>
<Col span={24}>
<Typography.Text>{heading}</Typography.Text>
</Col>
<Col span={24}>
<Typography.Text className="text-xs text-grey-muted">
{subHeading}
</Typography.Text>
</Col>
<Col span={24}>
{editMode ? (
<Form.Item
required
messageVariables={{
fieldName: t('label.data-asset-plural'),
}}
name={['resources']}>
<Select
className="w-full"
data-testid="triggerConfig-type"
options={resourcesOptions}
placeholder={t('label.select-field', {
field: t('label.data-asset-plural'),
})}
/>
</Form.Item>
) : (
<Button type="primary" onClick={handleAddTriggerClick}>
{buttonLabel}
</Button>
)}
</Col>
</Row>
</Card>
);
}
export default ObservabilityFormTriggerItem;

View File

@ -0,0 +1,18 @@
/*
* Copyright 2024 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.
*/
.trigger-item-container {
background-color: #f9f9f9;
.ant-card-body {
padding: 20px;
}
}

View File

@ -0,0 +1,21 @@
/*
* Copyright 2024 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.
*/
.add-notification-container {
.ant-form-item {
margin: 0px;
}
.ant-card {
border-radius: 10px;
}
}

View File

@ -1,143 +0,0 @@
/*
* Copyright 2023 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 {
act,
render,
screen,
waitForElementToBeRemoved,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { getAlertsFromName, triggerEventById } from '../../rest/alertsAPI';
import AlertDataInsightReportPage from './AlertDataInsightReportPage';
const MOCK_DATA_INSIGHTS_ALERT_DATA = {
id: '6bb36fa4-55eb-448c-a96d-f635cce913fd',
name: 'DataInsightReport',
fullyQualifiedName: 'DataInsightReport',
description:
'Data Insight Report send to the admin (organization level) and teams (team level) at given interval.',
href: 'http://localhost:8585/api/v1/events/subscriptions/6bb36fa4-55eb-448c-a96d-f635cce913fd',
version: 0.1,
updatedAt: 1683187040175,
updatedBy: 'admin',
alertType: 'DataInsightReport',
trigger: {
triggerType: 'Scheduled',
scheduleInfo: 'Weekly',
},
subscriptionType: 'DataInsight',
subscriptionConfig: {
sendToTeams: true,
sendToAdmins: true,
},
enabled: true,
batchSize: 10,
timeout: 10,
readTimeout: 12,
deleted: false,
provider: 'system',
};
jest.mock('../../rest/alertsAPI', () => ({
getAlertsFromName: jest
.fn()
.mockImplementation(() => Promise.resolve(MOCK_DATA_INSIGHTS_ALERT_DATA)),
triggerEventById: jest.fn().mockImplementation(() => Promise.resolve()),
}));
jest.mock('../../hooks/authHooks', () => ({
useAuth: jest.fn().mockImplementation(() => ({ isAdminUser: true })),
}));
describe('Test Alert Data Insights Report Page', () => {
it('Should render the child elements', async () => {
render(<AlertDataInsightReportPage />, {
wrapper: MemoryRouter,
});
await waitForElementToBeRemoved(() => screen.getByTestId('loader'));
expect(screen.getByText('label.data-insight-report')).toBeInTheDocument();
expect(
screen.getByText(
'Data Insight Report send to the admin (organization level) and teams (team level) at given interval.'
)
).toBeInTheDocument();
expect(screen.getByTestId('edit-button')).toBeInTheDocument();
expect(screen.getByTestId('trigger')).toBeInTheDocument();
expect(screen.getByTestId('schedule-info')).toBeInTheDocument();
expect(screen.getByTestId('destination')).toBeInTheDocument();
});
it('Should render data', async () => {
render(<AlertDataInsightReportPage />, {
wrapper: MemoryRouter,
});
await waitForElementToBeRemoved(() => screen.getByTestId('loader'));
const editButton = screen.getByTestId('edit-button');
expect(editButton).toHaveAttribute(
'href',
`/settings/notifications/edit-data-insight-report/${MOCK_DATA_INSIGHTS_ALERT_DATA.id}`
);
expect(screen.getByTestId('trigger-type')).toHaveTextContent('Scheduled');
expect(screen.getByTestId('schedule-info-type')).toHaveTextContent(
'Weekly'
);
expect(screen.getByTestId('sendToAdmins')).toBeInTheDocument();
expect(screen.getByTestId('sendToTeams')).toBeInTheDocument();
});
it('Should render the error placeholder if api fails or no data', async () => {
(getAlertsFromName as jest.Mock).mockRejectedValueOnce(() =>
Promise.reject()
);
render(<AlertDataInsightReportPage />, {
wrapper: MemoryRouter,
});
await waitForElementToBeRemoved(() => screen.getByTestId('loader'));
expect(screen.getByTestId('no-data-image')).toBeInTheDocument();
expect(screen.getByTestId('add-placeholder-button')).toBeInTheDocument();
expect(
screen.getByTestId('create-error-placeholder-label.data-insight-report')
).toBeInTheDocument();
});
it('Send Now button should work', async () => {
const mockTrigger = triggerEventById as jest.Mock;
render(<AlertDataInsightReportPage />, {
wrapper: MemoryRouter,
});
await waitForElementToBeRemoved(() => screen.getByTestId('loader'));
const sendNowButton = screen.getByTestId('send-now-button');
expect(sendNowButton).toBeInTheDocument();
await act(async () => {
userEvent.click(sendNowButton);
});
expect(mockTrigger).toHaveBeenCalledWith(MOCK_DATA_INSIGHTS_ALERT_DATA.id);
});
});

View File

@ -1,241 +0,0 @@
/*
* Copyright 2023 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 { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import Icon from '@ant-design/icons/lib/components/Icon';
import {
Badge,
Button,
Card,
Col,
Divider,
Popover,
Row,
Space,
Typography,
} from 'antd';
import { AxiosError } from 'axios';
import formateCron from 'cronstrue';
import { isUndefined } from 'lodash';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useHistory } from 'react-router-dom';
import { ReactComponent as IconEdit } from '../../assets/svg/edit-new.svg';
import { ReactComponent as IconSend } from '../../assets/svg/paper-plane.svg';
import ErrorPlaceHolder from '../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder';
import Loader from '../../components/Loader/Loader';
import PageHeader from '../../components/PageHeader/PageHeader.component';
import { ALERTS_DOCS } from '../../constants/docs.constants';
import {
GlobalSettingOptions,
GlobalSettingsMenuCategory,
} from '../../constants/GlobalSettings.constants';
import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum';
import {
EventSubscription,
ScheduleInfo,
} from '../../generated/events/eventSubscription';
import { useAuth } from '../../hooks/authHooks';
import { getAlertsFromName, triggerEventById } from '../../rest/alertsAPI';
import { EDIT_DATA_INSIGHT_REPORT_PATH } from '../../utils/Alerts/AlertsUtil';
import { getSettingPath } from '../../utils/RouterUtils';
import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils';
const AlertDataInsightReportPage = () => {
const { t } = useTranslation();
const history = useHistory();
const { isAdminUser } = useAuth();
const [isLoading, setLoading] = useState<boolean>(false);
const [dataInsightAlert, setDataInsightAlert] = useState<EventSubscription>();
const [isSendingReport, setIsSendingReport] = useState<boolean>(false);
const fetchDataInsightsAlert = useCallback(async () => {
try {
setLoading(true);
const response = await getAlertsFromName('DataInsightReport');
setDataInsightAlert(response);
} catch (error) {
showErrorToast(error as AxiosError);
} finally {
setLoading(false);
}
}, []);
const handleSendDataInsightReport = async () => {
if (isUndefined(dataInsightAlert)) {
return;
}
try {
setIsSendingReport(true);
await triggerEventById(dataInsightAlert.id);
showSuccessToast(t('message.data-insight-report-send-success-message'));
} catch (error) {
showErrorToast(t('message.data-insight-report-send-failed-message'));
} finally {
setIsSendingReport(false);
}
};
useEffect(() => {
fetchDataInsightsAlert();
}, []);
if (isLoading) {
return <Loader />;
}
if (!isAdminUser) {
return <ErrorPlaceHolder type={ERROR_PLACEHOLDER_TYPE.PERMISSION} />;
}
if (isUndefined(dataInsightAlert)) {
return (
<ErrorPlaceHolder
permission
doc={ALERTS_DOCS}
heading={t('label.data-insight-report')}
type={ERROR_PLACEHOLDER_TYPE.CREATE}
onClick={() =>
history.push(
getSettingPath(
GlobalSettingsMenuCategory.NOTIFICATIONS,
GlobalSettingOptions.ADD_DATA_INSIGHT_REPORT_ALERT
)
)
}
/>
);
}
const isEnabled = Boolean(dataInsightAlert?.enabled);
return (
<Row align="middle" gutter={[16, 16]}>
<Col span={24}>
<Space className="w-full justify-between">
<PageHeader
data={{
header: (
<Space align="center" size={4}>
{t('label.data-insight-report')}{' '}
{!isEnabled ? (
<Badge
className="badge-grey"
count={t('label.disabled')}
data-testid="disabled"
/>
) : null}
</Space>
),
subHeader: dataInsightAlert?.description ?? '',
}}
/>
<Space size={16}>
<Link
data-testid="edit-button"
to={`${EDIT_DATA_INSIGHT_REPORT_PATH}/${dataInsightAlert?.id}`}>
<Button icon={<Icon component={IconEdit} size={12} />}>
{t('label.edit')}
</Button>
</Link>
<Button
data-testid="send-now-button"
icon={<Icon component={IconSend} size={12} />}
loading={isSendingReport}
onClick={handleSendDataInsightReport}>
{t('label.send')}
</Button>
</Space>
</Space>
</Col>
<Col span={24}>
<Card>
{/* Trigger section */}
<>
<Typography.Title data-testid="trigger" level={5}>
{t('label.trigger')}
</Typography.Title>
<Typography.Text data-testid="trigger-type">
{dataInsightAlert?.trigger?.triggerType}
</Typography.Text>
</>
<Divider />
{/* Schedule Info Section */}
<>
<Typography.Title data-testid="schedule-info" level={5}>
{t('label.schedule-info')}
</Typography.Title>
<Space>
<Typography.Text data-testid="schedule-info-type">
{dataInsightAlert?.trigger?.scheduleInfo}
</Typography.Text>
{dataInsightAlert?.trigger?.scheduleInfo ===
ScheduleInfo.Custom &&
dataInsightAlert?.trigger?.cronExpression ? (
<Popover
content={
<div>
{formateCron.toString(
dataInsightAlert.trigger.cronExpression,
{
use24HourTimeFormat: true,
verbose: true,
}
)}
</div>
}
placement="bottom"
trigger="hover">
<Typography.Text code>
{dataInsightAlert.trigger.cronExpression}
</Typography.Text>
</Popover>
) : null}
</Space>
</>
<Divider />
{/* Destination section */}
<>
<Typography.Title data-testid="destination" level={5}>
{t('label.destination')}
</Typography.Title>
<Space size={8}>
<Typography.Text>{t('label.send-to')}</Typography.Text>
<Typography.Text>
{dataInsightAlert?.subscriptionConfig?.sendToAdmins ? (
<CheckCircleOutlined data-testid="sendToAdmins" />
) : (
<CloseCircleOutlined />
)}{' '}
{t('label.admin-plural')}
</Typography.Text>
<Typography.Text>
{dataInsightAlert?.subscriptionConfig?.sendToTeams ? (
<CheckCircleOutlined data-testid="sendToTeams" />
) : (
<CloseCircleOutlined />
)}{' '}
{t('label.team-plural')}
</Typography.Text>
</Space>
</>
</Card>
</Col>
</Row>
);
};
export default AlertDataInsightReportPage;

View File

@ -0,0 +1,83 @@
/*
* Copyright 2022 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 { render } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import ObservabilityPage from './ObservabilityPage';
const MOCK_DATA = [
{
id: '971a21b3-eeaf-4765-bda7-4e2cdb9788de',
name: 'alert-test',
fullyQualifiedName: 'alert-test',
href: 'http://localhost:8585/api/v1/events/subscriptions/971a21b3-eeaf-4765-bda7-4e2cdb9788de',
version: 0.1,
updatedAt: 1682366749021,
updatedBy: 'admin',
filteringRules: {
resources: ['all'],
rules: [
{
name: 'matchIngestionPipelineState',
effect: 'include',
condition: "matchIngestionPipelineState('partialSuccess')",
},
],
},
subscriptionType: 'Email',
subscriptionConfig: {
receivers: ['test@gmail.com'],
},
enabled: true,
batchSize: 10,
timeout: 10,
readTimeout: 12,
deleted: false,
provider: 'user',
},
];
jest.mock('../../rest/alertsAPI', () => ({
getAllAlerts: jest.fn().mockImplementation(() =>
Promise.resolve({
data: MOCK_DATA,
paging: { total: 1 },
})
),
}));
describe.skip('Alerts Page Tests', () => {
it('Title should be rendered', async () => {
const { findByText } = render(<ObservabilityPage />, {
wrapper: MemoryRouter,
});
expect(await findByText('label.alert-plural')).toBeInTheDocument();
});
it('SubTitle should be rendered', async () => {
const { findByText } = render(<ObservabilityPage />, {
wrapper: MemoryRouter,
});
expect(await findByText(/message.alerts-description/)).toBeInTheDocument();
});
it('Add alert button should be rendered', async () => {
const { findByText } = render(<ObservabilityPage />, {
wrapper: MemoryRouter,
});
expect(await findByText(/label.create-entity/)).toBeInTheDocument();
});
});

View File

@ -0,0 +1,259 @@
/*
* Copyright 2022 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, Row, Tooltip, Typography } from 'antd';
import { AxiosError } from 'axios';
import { isEmpty } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useHistory } from 'react-router-dom';
import { ReactComponent as EditIcon } from '../../assets/svg/edit-new.svg';
import DeleteWidgetModal from '../../components/common/DeleteWidget/DeleteWidgetModal';
import ErrorPlaceHolder from '../../components/common/ErrorWithPlaceholder/ErrorPlaceHolder';
import NextPrevious from '../../components/common/NextPrevious/NextPrevious';
import { PagingHandlerParams } from '../../components/common/NextPrevious/NextPrevious.interface';
import Table from '../../components/common/Table/Table';
import PageHeader from '../../components/PageHeader/PageHeader.component';
import { ALERTS_DOCS } from '../../constants/docs.constants';
import {
GlobalSettingOptions,
GlobalSettingsMenuCategory,
} from '../../constants/GlobalSettings.constants';
import { ERROR_PLACEHOLDER_TYPE } from '../../enums/common.enum';
import { EntityType } from '../../enums/entity.enum';
import {
EventSubscription,
ProviderType,
} from '../../generated/events/eventSubscription';
import { Paging } from '../../generated/type/paging';
import { usePaging } from '../../hooks/paging/usePaging';
import { getAllAlerts } from '../../rest/alertsAPI';
import { getEntityName } from '../../utils/EntityUtils';
import { getSettingPath } from '../../utils/RouterUtils';
import SVGIcons, { Icons } from '../../utils/SvgUtils';
import { showErrorToast } from '../../utils/ToastUtils';
const ObservabilityPage = () => {
const { t } = useTranslation();
const history = useHistory();
const [loading, setLoading] = useState(true);
const [alerts, setAlerts] = useState<EventSubscription[]>([]);
const [selectedAlert, setSelectedAlert] = useState<EventSubscription>();
const {
pageSize,
currentPage,
handlePageChange,
handlePageSizeChange,
handlePagingChange,
showPagination,
paging,
} = usePaging();
const fetchAlerts = useCallback(
async (params?: Partial<Paging>) => {
setLoading(true);
try {
const { data, paging } = await getAllAlerts({
after: params?.after,
before: params?.before,
limit: pageSize,
});
setAlerts(data.filter((d) => d.provider !== ProviderType.System));
handlePagingChange(paging);
} catch (error) {
showErrorToast(
t('server.entity-fetch-error', { entity: t('label.alert-plural') })
);
} finally {
setLoading(false);
}
},
[pageSize]
);
useEffect(() => {
fetchAlerts();
}, [pageSize]);
const handleAlertDelete = useCallback(async () => {
try {
setSelectedAlert(undefined);
fetchAlerts();
} catch (error) {
showErrorToast(error as AxiosError);
}
}, [fetchAlerts]);
const onPageChange = useCallback(
({ cursorType, currentPage }: PagingHandlerParams) => {
if (cursorType) {
fetchAlerts({ [cursorType]: paging[cursorType] });
handlePageChange(currentPage);
}
},
[paging]
);
const columns = useMemo(
() => [
{
title: t('label.name'),
dataIndex: 'name',
width: '200px',
key: 'name',
render: (name: string, record: EventSubscription) => {
return <Link to={`alert/${record.id}`}>{name}</Link>;
},
},
{
title: t('label.trigger'),
dataIndex: ['filteringRules', 'resources'],
width: '200px',
key: 'FilteringRules.resources',
render: (resources: string[]) => {
return resources?.join(', ') || '--';
},
},
{
title: t('label.description'),
dataIndex: 'description',
flex: true,
key: 'description',
render: (description: string) =>
isEmpty(description) ? (
<Typography.Text className="text-grey-muted">
{t('label.no-entity', {
entity: t('label.description'),
})}
</Typography.Text>
) : (
description
),
},
{
title: t('label.action-plural'),
dataIndex: 'id',
width: 120,
key: 'id',
render: (id: string, record: EventSubscription) => {
return (
<div className="d-flex items-center">
<Tooltip placement="bottom" title={t('label.edit')}>
<Link to={`edit-alert/${id}`}>
<Button
className="d-inline-flex items-center justify-center"
data-testid={`alert-edit-${record.name}`}
icon={<EditIcon width={16} />}
type="text"
/>
</Link>
</Tooltip>
<Tooltip placement="bottom" title={t('label.delete')}>
<Button
data-testid={`alert-delete-${record.name}`}
disabled={record.provider === ProviderType.System}
icon={<SVGIcons className="w-4" icon={Icons.DELETE} />}
type="text"
onClick={() => setSelectedAlert(record)}
/>
</Tooltip>
</div>
);
},
},
],
[handleAlertDelete]
);
const pageHeaderData = useMemo(
() => ({
header: t('label.observability'),
subHeader: t('message.alerts-description'),
}),
[]
);
return (
<Row gutter={[16, 16]}>
<Col span={24}>
<div className="d-flex justify-between">
<PageHeader data={pageHeaderData} />
<Link
to={getSettingPath(
GlobalSettingsMenuCategory.NOTIFICATIONS,
GlobalSettingOptions.ADD_OBSERVABILITY
)}>
<Button data-testid="create-observability" type="primary">
{t('label.create-entity', { entity: t('label.observability') })}
</Button>
</Link>
</div>
</Col>
<Col span={24}>
<Table
bordered
columns={columns}
dataSource={alerts}
loading={loading}
locale={{
emptyText: (
<ErrorPlaceHolder
permission
className="p-y-md"
doc={ALERTS_DOCS}
heading={t('label.alert')}
type={ERROR_PLACEHOLDER_TYPE.CREATE}
onClick={() =>
history.push(
getSettingPath(
GlobalSettingsMenuCategory.NOTIFICATIONS,
GlobalSettingOptions.ADD_OBSERVABILITY
)
)
}
/>
),
}}
pagination={false}
rowKey="id"
size="small"
/>
</Col>
<Col span={24}>
{showPagination && (
<NextPrevious
currentPage={currentPage}
pageSize={pageSize}
paging={paging}
pagingHandler={onPageChange}
onShowSizeChange={handlePageSizeChange}
/>
)}
<DeleteWidgetModal
afterDeleteAction={handleAlertDelete}
allowSoftDelete={false}
entityId={selectedAlert?.id ?? ''}
entityName={getEntityName(selectedAlert)}
entityType={EntityType.SUBSCRIPTION}
visible={Boolean(selectedAlert)}
onCancel={() => {
setSelectedAlert(undefined);
}}
/>
</Col>
</Row>
);
};
export default ObservabilityPage;

View File

@ -0,0 +1,127 @@
/* eslint-disable @typescript-eslint/ban-types */
/*
* Copyright 2022 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 { PagingResponse } from 'Models';
import axiosClient from '.';
import { CreateEventSubscription } from '../generated/events/api/createEventSubscription';
import {
EventSubscription,
Status,
SubscriptionType,
} from '../generated/events/eventSubscription';
import { FilterResourceDescriptor } from '../generated/events/filterResourceDescriptor';
import { Function } from '../generated/type/function';
const BASE_URL = '/events/subscriptions/observability';
interface ListAlertsRequestParams {
status?: Status;
alertType?: SubscriptionType;
before?: string;
after?: string;
include?: string;
limit?: number;
}
export const getAlertsFromId = async (
id: string,
params?: Pick<ListAlertsRequestParams, 'include'>
) => {
const response = await axiosClient.get<EventSubscription>(
`${BASE_URL}/${id}`,
{
params: {
...params,
include: 'all',
},
}
);
return response.data;
};
export const getAlertsFromName = async (
name: string,
params?: Pick<ListAlertsRequestParams, 'include'>
) => {
const response = await axiosClient.get<EventSubscription>(
`${BASE_URL}/name/${name}`,
{
params: {
...params,
include: 'all',
},
}
);
return response.data;
};
export const getAllAlerts = async (params: ListAlertsRequestParams) => {
const response = await axiosClient.get<PagingResponse<EventSubscription[]>>(
BASE_URL,
{
params: {
...params,
},
}
);
return response.data;
};
export const createObservabilityAlert = async (
alert: CreateEventSubscription
) => {
const response = await axiosClient.post<EventSubscription>(
`/events/subscriptions`,
alert
);
return response.data;
};
export const updateObservabilityAlert = async (alert: EventSubscription) => {
const response = await axiosClient.put<EventSubscription>(BASE_URL, alert);
return response.data;
};
export const deleteObservabilityAlert = async (id: string) => {
const response = await axiosClient.delete(`${BASE_URL}/${id}`);
return response.data;
};
export const getFilterFunctions = async () => {
const response = await axiosClient.get<Function[]>(`${BASE_URL}/functions`);
return response.data;
};
export const getResourceFunctions = async () => {
const response = await axiosClient.get<
PagingResponse<FilterResourceDescriptor[]>
>(`${BASE_URL}/resources`);
return response.data;
};
export const triggerEventById = async (id: string) => {
const response = await axiosClient.put<EventSubscription>(
`${BASE_URL}/trigger/${id}`
);
return response.data;
};

View File

@ -25,15 +25,15 @@ import { SubscriptionType } from '../../generated/events/eventSubscription';
export const getAlertsActionTypeIcon = (type?: SubscriptionType) => {
switch (type) {
case SubscriptionType.SlackWebhook:
case SubscriptionType.Slack:
return <SlackIcon height={16} width={16} />;
case SubscriptionType.MSTeamsWebhook:
case SubscriptionType.MSTeams:
return <MSTeamsIcon height={16} width={16} />;
case SubscriptionType.Email:
return <MailIcon height={16} width={16} />;
case SubscriptionType.ActivityFeed:
return <AllActivityIcon height={16} width={16} />;
case SubscriptionType.GenericWebhook:
case SubscriptionType.Generic:
default:
return <WebhookIcon height={16} width={16} />;
}
@ -115,13 +115,13 @@ export const getAlertActionTypeDisplayName = (
return i18next.t('label.activity-feed-plural');
case SubscriptionType.Email:
return i18next.t('label.email');
case SubscriptionType.GenericWebhook:
case SubscriptionType.Generic:
return i18next.t('label.webhook');
case SubscriptionType.SlackWebhook:
case SubscriptionType.Slack:
return i18next.t('label.slack');
case SubscriptionType.MSTeamsWebhook:
case SubscriptionType.MSTeams:
return i18next.t('label.ms-team-plural');
case SubscriptionType.GChatWebhook:
case SubscriptionType.GChat:
return i18next.t('label.g-chat');
default:
return '';

View File

@ -239,6 +239,12 @@ export const getGlobalSettingsMenuWithPermission = (
key: 'notifications.alerts',
icon: <BellIcon className="side-panel-icons" />,
},
{
label: i18next.t('label.observability'),
isProtected: Boolean(isAdminUser),
key: 'notifications.observability',
icon: <BellIcon className="side-panel-icons" />,
},
],
},
{