diff --git a/cypress/integration/sidebar_test.js b/cypress/integration/sidebar_test.js index 679690e5..0bf12ded 100644 --- a/cypress/integration/sidebar_test.js +++ b/cypress/integration/sidebar_test.js @@ -29,6 +29,8 @@ describe('Sidebar tests', () => { .as('interfaces') .next('.nav-link') .as('triggers') + .next('.nav-link') + .as('trigger-delivery-policies') .next('.nav-item') .next('.nav-link') .as('devices') @@ -54,6 +56,9 @@ describe('Sidebar tests', () => { cy.get('@home').should('have.attr', 'href', '/').contains('Home'); cy.get('@interfaces').should('have.attr', 'href', '/interfaces').contains('Interfaces'); cy.get('@triggers').should('have.attr', 'href', '/triggers').contains('Triggers'); + cy.get('@trigger-delivery-policies') + .should('have.attr', 'href', '/trigger-delivery-policies') + .contains('Delivery Policies'); cy.get('@devices').should('have.attr', 'href', '/devices').contains('Devices'); cy.get('@groups').should('have.attr', 'href', '/groups').contains('Groups'); cy.get('@flows').should('have.attr', 'href', '/flows').contains('Flows'); diff --git a/src/App.tsx b/src/App.tsx index 94f41500..d10ccddd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -65,6 +65,7 @@ const DashboardSidebar = () => { + diff --git a/src/NewTriggerDeliveryPolicyPage.tsx b/src/NewTriggerDeliveryPolicyPage.tsx new file mode 100644 index 00000000..b537cb09 --- /dev/null +++ b/src/NewTriggerDeliveryPolicyPage.tsx @@ -0,0 +1,117 @@ +/* +This file is part of Astarte. + +Copyright 2023 SECO Mind Srl + +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. +*/ +/* eslint-disable camelcase */ + +import React, { useCallback, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button, Container, Row, Spinner } from 'react-bootstrap'; +import { AstarteTriggerDeliveryPolicyDTO } from 'astarte-client/types/dto'; + +import { AlertsBanner, useAlerts } from './AlertManager'; +import { useAstarte } from './AstarteManager'; +import BackButton from './ui/BackButton'; +import TriggerDeliveryPolicyEditor from './components/TriggerDeliveryPolicyEditor'; + +const parsedErrorMessage = (status: number): string => { + switch (status) { + case 400: + return 'Bad request'; + case 401: + return 'Authorization information is missing or invalid.'; + case 403: + return 'Authorization failed for the resource. This could also result from unexisting resources.'; + case 409: + return 'A trigger delivery policy with this name already exists.'; + case 422: + return 'The provided trigger delivery policy is not valid.'; + default: + return 'Not found'; + } +}; + +export default (): React.ReactElement => { + const [policyDraft, setPolicyDraft] = useState(); + const [isValidPolicy, setIsValidPolicy] = useState(false); + const [isInstallingPolicy, setIsInstallingPolicy] = useState(false); + const [isSourceVisible, setIsSourceVisible] = useState(true); + const [installationAlerts, installationAlertsController] = useAlerts(); + const astarte = useAstarte(); + const navigate = useNavigate(); + + const handleToggleSourceVisibility = useCallback(() => { + setIsSourceVisible((isVisible) => !isVisible); + }, []); + + const handlePolicyChange = useCallback( + (updatedPolicy: AstarteTriggerDeliveryPolicyDTO, isValid: boolean) => { + setPolicyDraft(updatedPolicy); + setIsValidPolicy(isValid); + }, + [], + ); + + const handleInstallPolicy = useCallback(() => { + if (policyDraft == null || isInstallingPolicy) { + return; + } + setIsInstallingPolicy(true); + astarte.client + .installTriggerDeliveryPolicy(policyDraft) + .then(() => { + navigate({ pathname: '/trigger-delivery-policies' }); + }) + .catch((err) => { + installationAlertsController.showError( + `Could not install policy: ${parsedErrorMessage(err.response.status)}`, + ); + setIsInstallingPolicy(false); + }); + }, [astarte.client, policyDraft, isInstallingPolicy, navigate, installationAlertsController]); + + return ( + +

+ + Trigger Delivery Policy Editor +

+ +
+ + + + + +
+
+ ); +}; diff --git a/src/Router.tsx b/src/Router.tsx index 227b9587..05342584 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -46,6 +46,9 @@ import DeviceStatusPage from './DeviceStatusPage'; import DeviceInterfaceValues from './DeviceInterfaceValues'; import { useConfig } from './ConfigManager'; import { useAstarte } from './AstarteManager'; +import TriggerPoliciesPage from './TriggerDeliveryPoliciesPage'; +import NewPolicyPage from './NewTriggerDeliveryPolicyPage'; +import TriggerDeliveryPolicyPage from './TriggerDeliveryPolicyPage'; function AttemptLogin(): React.ReactElement { const { search, hash } = useLocation(); @@ -106,6 +109,9 @@ const privateRoutes: RouteObject[] = [ { path: 'triggers', element: }, { path: 'triggers/new', element: }, { path: 'triggers/:triggerName/edit', element: }, + { path: 'trigger-delivery-policies', element: }, + { path: 'trigger-delivery-policies/new', element: }, + { path: 'trigger-delivery-policies/:policyName/edit', element: }, { path: 'interfaces', element: }, { path: 'interfaces/new', element: }, { path: 'interfaces/:interfaceName/:interfaceMajor/edit', element: }, diff --git a/src/TriggerDeliveryPoliciesPage.tsx b/src/TriggerDeliveryPoliciesPage.tsx new file mode 100644 index 00000000..60de5f85 --- /dev/null +++ b/src/TriggerDeliveryPoliciesPage.tsx @@ -0,0 +1,116 @@ +/* + This file is part of Astarte. + + Copyright 2023 SECO Mind Srl + + 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 React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Button, Col, Container, ListGroup, Row, Spinner } from 'react-bootstrap'; + +import { useAstarte } from './AstarteManager'; +import Empty from './components/Empty'; +import Icon from './components/Icon'; +import WaitForData from './components/WaitForData'; +import useFetch from './hooks/useFetch'; +import useInterval from './hooks/useInterval'; + +interface TriggerPolicyRowProps { + name: string; + onClick: () => void; +} + +const TriggerPolicyRow = ({ name, onClick }: TriggerPolicyRowProps): React.ReactElement => ( + + + +); + +const LoadingRow = (): React.ReactElement => ( + + + + + +); + +interface ErrorRowProps { + onRetry: () => void; +} + +const ErrorRow = ({ onRetry }: ErrorRowProps): React.ReactElement => ( + + + +); + +export default (): React.ReactElement => { + const astarte = useAstarte(); + const navigate = useNavigate(); + const policiesFetcher = useFetch(astarte.client.getTriggerDeliveryPolicyNames); + + useInterval(policiesFetcher.refresh, 30000); + + return ( + + + +

Trigger Delivery Policies

+ +
+ + + + + + + } + errorFallback={} + > + {(policies) => ( + <> + {policies.map((policy) => ( + { + navigate(`/trigger-delivery-policies/${policy}/edit`); + }} + /> + ))} + + )} + + + + +
+ ); +}; diff --git a/src/TriggerDeliveryPolicyPage.tsx b/src/TriggerDeliveryPolicyPage.tsx new file mode 100644 index 00000000..195c0613 --- /dev/null +++ b/src/TriggerDeliveryPolicyPage.tsx @@ -0,0 +1,204 @@ +/* eslint-disable camelcase */ +/* + This file is part of Astarte. + + Copyright 2023 SECO Mind Srl + + 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 React, { useCallback, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { Button, Container, Form, Row, Spinner } from 'react-bootstrap'; + +import { AstarteTriggerDeliveryPolicyDTO } from 'astarte-client/types/dto'; +import { AlertsBanner, useAlerts } from './AlertManager'; +import { useAstarte } from './AstarteManager'; +import Empty from './components/Empty'; +import TriggerDeliveryPolicyEditor from './components/TriggerDeliveryPolicyEditor'; +import ConfirmModal from './components/modals/Confirm'; +import WaitForData from './components/WaitForData'; +import useFetch from './hooks/useFetch'; +import BackButton from './ui/BackButton'; + +const parsedErrorMessage = (status: number): string => { + switch (status) { + case 401: + return 'Unauthorized.'; + case 403: + return 'Forbidden.'; + case 409: + return 'Cannot delete policy as it is being currently used by triggers.'; + default: + return 'Not found'; + } +}; + +interface DeleteModalProps { + policyName: string; + onCancel: () => void; + onConfirm: () => void; + isDeletingPolicy: boolean; +} + +const DeleteModal = ({ policyName, onCancel, onConfirm, isDeletingPolicy }: DeleteModalProps) => { + const [confirmString, setConfirmString] = useState(''); + + const canDelete = confirmString === policyName; + + return ( + +

+ You are going to delete  + {policyName}. This might cause data loss, deleted trigger delivery policy cannot be + restored. Are you sure? +

+

+ Please type {policyName} to proceed. +

+ + ) => setConfirmString(e.target.value)} + /> + +
+ ); +}; + +export default (): React.ReactElement => { + const [isDeletingPolicy, setIsDeletingPolicy] = useState(false); + const [isSourceVisible, setIsSourceVisible] = useState(true); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deletionAlerts, deletionAlertsController] = useAlerts(); + const astarte = useAstarte(); + const navigate = useNavigate(); + const { policyName = '' } = useParams(); + + const policyFetcher = useFetch(() => astarte.client.getTriggerDeliveryPolicy(policyName)); + + const checkData = (data: AstarteTriggerDeliveryPolicyDTO | null) => { + const newData: AstarteTriggerDeliveryPolicyDTO | null = data; + if (data?.event_ttl === null) { + delete newData?.event_ttl; + } + if (data?.retry_times === null || data?.retry_times === 0) { + delete newData?.retry_times; + } + return newData; + }; + + const handleToggleSourceVisibility = useCallback(() => { + setIsSourceVisible((isVisible) => !isVisible); + }, []); + + const showConfirmDeleteModal = useCallback(() => { + setShowDeleteModal(true); + }, []); + + const hideConfirmDeleteModal = useCallback(() => { + setShowDeleteModal(false); + }, []); + + const handleConfirmDeletePolicy = useCallback(() => { + setIsDeletingPolicy(true); + astarte.client + .deleteTriggerDeliveryPolicy(policyName) + .then(() => { + navigate('/trigger-delivery-policies'); + }) + .catch((err) => { + deletionAlertsController.showError( + `Could not delete policy: ${parsedErrorMessage(err.response.status)}`, + ); + setIsDeletingPolicy(false); + hideConfirmDeleteModal(); + }); + }, [astarte.client, policyName, navigate, deletionAlertsController, hideConfirmDeleteModal]); + + return ( + +

+ + Trigger Delivery Policy Editor +

+
+ + + + + } + errorFallback={ + + } + > + {(policy) => ( + <> + + + + + + + )} + +
+ {showDeleteModal && ( + + )} +
+ ); +}; diff --git a/src/astarte-client/client.ts b/src/astarte-client/client.ts index a228e0a3..25b678f6 100644 --- a/src/astarte-client/client.ts +++ b/src/astarte-client/client.ts @@ -19,6 +19,7 @@ import axios from 'axios'; import { Channel, Socket } from 'phoenix'; import _ from 'lodash'; +import { AstarteTriggerDeliveryPolicyDTO } from 'astarte-client/types/dto'; import { AstarteDataTreeNode, @@ -183,6 +184,7 @@ class AstarteClient { this.getInterfaceNames = this.getInterfaceNames.bind(this); this.getTriggerNames = this.getTriggerNames.bind(this); this.getTrigger = this.getTrigger.bind(this); + this.getTriggerDeliveryPolicyNames = this.getTriggerDeliveryPolicyNames.bind(this); this.deleteTrigger = this.deleteTrigger.bind(this); this.getAppengineHealth = this.getAppengineHealth.bind(this); this.getRealmManagementHealth = this.getRealmManagementHealth.bind(this); @@ -201,8 +203,9 @@ class AstarteClient { interface: astarteAPIurl`${config.realmManagementApiUrl}v1/${'realm'}/interfaces/${'interfaceName'}/${'interfaceMajor'}`, interfaceData: astarteAPIurl`${config.realmManagementApiUrl}v1/${'realm'}/interfaces/${'interfaceName'}/${'interfaceMajor'}`, trigger: astarteAPIurl`${config.realmManagementApiUrl}v1/${'realm'}/triggers/${'triggerName'}`, - triggers: astarteAPIurl`${config.realmManagementApiUrl}v1/${'realm'}/triggers`, + triggers: astarteAPIurl`${config.realmManagementApiUrl}v1/${'realm'}/triggers`, policies: astarteAPIurl`${config.realmManagementApiUrl}v1/${'realm'}/policies`, + policy: astarteAPIurl`${config.realmManagementApiUrl}v1/${'realm'}/policies/${'policyName'}`, appengineHealth: astarteAPIurl`${config.appEngineApiUrl}health`, devicesStats: astarteAPIurl`${config.appEngineApiUrl}v1/${'realm'}/stats/devices`, devices: astarteAPIurl`${config.appEngineApiUrl}v1/${'realm'}/devices`, @@ -339,6 +342,24 @@ class AstarteClient { await this.$post(this.apiConfig.triggers(this.config), toAstarteTriggerDTO(trigger)); } + async getTriggerDeliveryPolicyNames(): Promise { + const response = await this.$get(this.apiConfig.policies(this.config)); + return response.data; + } + + async installTriggerDeliveryPolicy(policy: AstarteTriggerDeliveryPolicyDTO): Promise { + await this.$post(this.apiConfig.policies(this.config), policy); + } + + async getTriggerDeliveryPolicy(policyName: string): Promise { + const response = await this.$get(this.apiConfig.policy({ ...this.config, policyName })); + return response.data; + } + + async deleteTriggerDeliveryPolicy(policyName: string): Promise { + await this.$delete(this.apiConfig.policy({ ...this.config, policyName })); + } + async getDevicesStats(): Promise<{ connectedDevices: number; totalDevices: number }> { const response = await this.$get(this.apiConfig.devicesStats(this.config)); return { diff --git a/src/astarte-client/models/Policy/index.ts b/src/astarte-client/models/Policy/index.ts new file mode 100644 index 00000000..0edaa41e --- /dev/null +++ b/src/astarte-client/models/Policy/index.ts @@ -0,0 +1,125 @@ +/* +This file is part of Astarte. + +Copyright 2023 SECO Mind Srl + +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. +*/ +/* eslint-disable camelcase */ + +import * as yup from 'yup'; +import { AstarteTriggerDeliveryPolicyHandlerDTO } from 'astarte-client/types/dto'; + +import _ from 'lodash'; + +interface AstarteTriggerDeliveryPolicyObject { + name: string; + error_handlers: AstarteTriggerDeliveryPolicyHandlerDTO[]; + retry_times?: number; + maximum_capacity: number; + event_ttl?: number; +} + +const isOnFieldEqual = ( + on1: AstarteTriggerDeliveryPolicyHandlerDTO['on'], + on2: AstarteTriggerDeliveryPolicyHandlerDTO['on'], +) => { + if (typeof on1 === 'string') { + return on1 === on2; + } + if (typeof on2 === 'string') { + return false; + } + return on1.some((customError) => on2.includes(customError)); +}; + +const AstarteTriggerDeliveryPolicyHandlerDTOSchema = yup.object().shape({ + on: yup + .mixed() + .test( + 'is-valid-on', + 'Invalid On field. Must be "client_error" | "server_error" | "any_error" | [ (400-599)]!', + (value) => { + if ( + typeof value === 'string' && + ['any_error', 'client_error', 'server_error'].includes(value) + ) { + return true; + } + if (Array.isArray(value) && value.length > 0) { + const uniqueValues: Set = new Set(value); + return ( + uniqueValues.size === value.length && + value.every((item) => Number.isInteger(item) && item >= 400 && item < 600) + ); + } + return false; + }, + ) + .required('on is required'), + strategy: yup.mixed().oneOf(['discard', 'retry']).required(), +}); + +const policySchema = yup.object().shape({ + name: yup.string().required().min(1).max(128), + error_handlers: yup + .array() + .of(AstarteTriggerDeliveryPolicyHandlerDTOSchema) + .test('unique-on', 'On field must be unique between error handlers', (value) => { + const handlers = (value || []) as AstarteTriggerDeliveryPolicyHandlerDTO[]; + const onFields = handlers.map((handler) => handler.on); + const uniqueOnFields = _.uniqWith(onFields, isOnFieldEqual); + return onFields.length === uniqueOnFields.length; + }) + .required(), + retry_times: yup + .number() + .integer() + .when('error_handlers', { + is: (errorHandlers) => + errorHandlers.some((e: AstarteTriggerDeliveryPolicyHandlerDTO) => e.strategy === 'retry'), + then: yup.number().integer().min(1).max(100).required(), + otherwise: yup.number().integer().max(0).default(0), + }), + maximum_capacity: yup.number().integer().min(1).required(), + event_ttl: yup.number().integer().min(0).max(86400), +}); + +class AstarteTriggerDeliveryPolicy { + name: string; + + error_handlers: AstarteTriggerDeliveryPolicyHandlerDTO[]; + + maximum_capacity: number; + + retry_times?: number; + + event_ttl?: number; + + constructor(obj: AstarteTriggerDeliveryPolicyObject) { + const validatedObj = AstarteTriggerDeliveryPolicy.validation.validateSync(obj, { + abortEarly: false, + }) as AstarteTriggerDeliveryPolicyObject; + this.name = validatedObj.name; + this.error_handlers = validatedObj.error_handlers; + this.maximum_capacity = validatedObj.maximum_capacity; + this.retry_times = validatedObj.retry_times; + this.event_ttl = validatedObj.event_ttl; + } + + static validation = policySchema; +} + +export type { AstarteTriggerDeliveryPolicyObject }; + +export { AstarteTriggerDeliveryPolicy }; diff --git a/src/astarte-client/models/Trigger/index.ts b/src/astarte-client/models/Trigger/index.ts index 541f8fe6..836f1c2a 100644 --- a/src/astarte-client/models/Trigger/index.ts +++ b/src/astarte-client/models/Trigger/index.ts @@ -80,7 +80,7 @@ interface AstarteTriggerObject { name: string; action: AstarteTriggerHTTPActionObject | AstarteTriggerAMQPActionObject; simpleTriggers: AstarteSimpleTriggerObject[]; - policy?: string; + policy?: string | null; } const reservedHttpHeaders = [ @@ -358,7 +358,7 @@ const astarteTriggerObjectSchema: yup.ObjectSchema = yup name: yup.string().required(), action: astarteTriggerActionObjectSchema, simpleTriggers: yup.array(astarteSimpleTriggerObjectSchema).required(), - policy: yup.string(), + policy: yup.string().nullable(), }) .required(); @@ -382,7 +382,9 @@ class AstarteTrigger { this.name = validatedObj.name; this.action = validatedObj.action; this.simpleTriggers = validatedObj.simpleTriggers; - this.policy = validatedObj.policy; + if (validatedObj.policy) { + this.policy = validatedObj.policy; + } } static validation = astarteTriggerObjectSchema; diff --git a/src/astarte-client/models/index.ts b/src/astarte-client/models/index.ts index 1f27f9dd..a0ea238b 100644 --- a/src/astarte-client/models/index.ts +++ b/src/astarte-client/models/index.ts @@ -25,3 +25,4 @@ export * from './Pipeline'; export * from './Mapping'; export * from './Interface'; export * from './Trigger'; +export * from './Policy'; diff --git a/src/astarte-client/types/dto/index.ts b/src/astarte-client/types/dto/index.ts index 12db3755..5f21685d 100644 --- a/src/astarte-client/types/dto/index.ts +++ b/src/astarte-client/types/dto/index.ts @@ -30,3 +30,7 @@ export type { AstarteTriggerHTTPActionDTO, AstarteTriggerAMQPActionDTO, } from './trigger.d'; +export type { + AstarteTriggerDeliveryPolicyHandlerDTO, + AstarteTriggerDeliveryPolicyDTO, +} from './triggerDeliveryPolicy.d'; diff --git a/src/astarte-client/types/dto/trigger.d.ts b/src/astarte-client/types/dto/trigger.d.ts index 867fcdfd..56e1ed8c 100644 --- a/src/astarte-client/types/dto/trigger.d.ts +++ b/src/astarte-client/types/dto/trigger.d.ts @@ -70,7 +70,7 @@ interface AstarteTriggerDTO { name: string; action: AstarteTriggerHTTPActionDTO | AstarteTriggerAMQPActionDTO; simple_triggers: AstarteSimpleTriggerDTO[]; - policy?: string; + policy?: string | null; } interface AstarteTransientTriggerDTO { @@ -78,7 +78,7 @@ interface AstarteTransientTriggerDTO { device_id?: string; group_name?: string; simple_trigger: AstarteSimpleTriggerDTO; - policy?: string; + policy?: string | null; } export { diff --git a/src/astarte-client/types/dto/triggerDeliveryPolicy.d.ts b/src/astarte-client/types/dto/triggerDeliveryPolicy.d.ts new file mode 100644 index 00000000..461444a0 --- /dev/null +++ b/src/astarte-client/types/dto/triggerDeliveryPolicy.d.ts @@ -0,0 +1,33 @@ +/* + This file is part of Astarte. + + Copyright 2023 SECO Mind Srl + + 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. +*/ +/* eslint camelcase: 0 */ + +interface AstarteTriggerDeliveryPolicyHandlerDTO { + on: 'any_error' | 'client_error' | 'server_error' | number[]; + strategy: 'discard' | 'retry'; +} + +interface AstarteTriggerDeliveryPolicyDTO { + name: string; + error_handlers: AstarteTriggerDeliveryPolicyHandlerDTO[]; + retry_times?: number; + maximum_capacity: number; + event_ttl?: number; +} + +export { AstarteTriggerDeliveryPolicyHandlerDTO, AstarteTriggerDeliveryPolicyDTO }; diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 93897701..b3a4a3a3 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -43,6 +43,7 @@ const iconToClassName = { statusKO: 'fas fa-times-circle color-red', statusNeverConnected: 'fas fa-circle color-grey', triggers: 'fas fa-bolt', + policy: 'fas fa-file-invoice', }; type Icon = keyof typeof iconToClassName; diff --git a/src/components/TriggerDeliveryPolicyEditor.tsx b/src/components/TriggerDeliveryPolicyEditor.tsx new file mode 100644 index 00000000..177e06e1 --- /dev/null +++ b/src/components/TriggerDeliveryPolicyEditor.tsx @@ -0,0 +1,272 @@ +/* +This file is part of Astarte. + +Copyright 2023 SECO Mind Srl + +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. +*/ +/* eslint-disable camelcase */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { Col, Container, Form, InputGroup, Row } from 'react-bootstrap'; +import _, { toInteger } from 'lodash'; +import { AstarteTriggerDeliveryPolicyDTO } from 'astarte-client/types/dto'; +import * as yup from 'yup'; +import { AstarteTriggerDeliveryPolicy } from 'astarte-client/models/Policy'; +import TriggerDeliveryPolicyHandler from './TriggerDeliveryPolicyHandler'; + +const validateName = (name: string) => { + const regex = /^(?!@).{1,128}$/; + return regex.test(name); +}; + +const checkValidJSONText = (text: string): boolean => { + try { + JSON.parse(text); + return true; + } catch { + return false; + } +}; + +const defaultPolicy: AstarteTriggerDeliveryPolicyDTO = { + name: '', + error_handlers: [], + maximum_capacity: 100, +}; + +interface Props { + initialData?: AstarteTriggerDeliveryPolicyDTO; + isReadOnly: boolean; + isSourceVisible?: boolean; + onChange?: (updatedPolicy: AstarteTriggerDeliveryPolicyDTO, isValid: boolean) => unknown; +} + +export default ({ + initialData, + isReadOnly, + isSourceVisible, + onChange, +}: Props): React.ReactElement => { + const [policyDraft, setPolicyDraft] = useState( + initialData || defaultPolicy, + ); + const [policySource, setPolicySource] = useState(JSON.stringify(policyDraft, null, 4)); + const [policySourceError, setPolicySourceError] = useState(''); + const [isValidPolicySource, setIsValidPolicySource] = useState(true); + const isRetryTimesDisabled = !policyDraft.error_handlers.some((e) => e.strategy === 'retry'); + + const handlePolicyNameChange = (e: React.ChangeEvent) => { + const { value } = e.target; + setPolicyDraft((draft) => ({ ...draft, name: value })); + }; + + const handlePolicyCapacityChange = useCallback((e: React.ChangeEvent) => { + const { value } = e.target; + setPolicyDraft((draft) => ({ ...draft, maximum_capacity: toInteger(value) })); + }, []); + + const handleRetryChange = useCallback((e: React.ChangeEvent) => { + const { value } = e.target; + if (toInteger(value) >= 1) { + setPolicyDraft((draft) => ({ ...draft, retry_times: toInteger(value) })); + } else { + setPolicyDraft((draft) => { + const { retry_times, ...restElements } = draft; + return restElements; + }); + } + }, []); + + const handleEventTtlChange = useCallback((e: React.ChangeEvent) => { + const { value } = e.target; + if (toInteger(value) >= 1) { + setPolicyDraft((draft) => ({ ...draft, event_ttl: toInteger(value) })); + } else { + setPolicyDraft((draft) => { + const { event_ttl, ...restElements } = draft; + return restElements; + }); + } + }, []); + + const setErrorMessage = (message: string) => { + setPolicySourceError(message); + setIsValidPolicySource(false); + }; + + const isValidJSON = (policy: AstarteTriggerDeliveryPolicyDTO) => { + try { + AstarteTriggerDeliveryPolicy.validation.validateSync(policy, { abortEarly: false }); + setIsValidPolicySource(true); + } catch (error) { + setIsValidPolicySource(false); + if (error) { + if (error instanceof yup.ValidationError) { + setErrorMessage(error.inner[0].message); + } + } + } + }; + + const validJSONText = (value: string) => { + if (!checkValidJSONText(value)) { + setErrorMessage('Invalid JSON!'); + return; + } + const newPolicy: AstarteTriggerDeliveryPolicyDTO = JSON.parse(value); + isValidJSON(newPolicy); + }; + + const handlePolicyChange = useCallback((updatedPolicy: AstarteTriggerDeliveryPolicyDTO) => { + const isStrategyFieldRetry = updatedPolicy.error_handlers.some((e) => e.strategy === 'retry'); + const newPolicy: AstarteTriggerDeliveryPolicyDTO = updatedPolicy; + if ( + isStrategyFieldRetry && + (updatedPolicy.retry_times === undefined || updatedPolicy.retry_times === 0) + ) { + newPolicy.retry_times = 1; + } + if (!isStrategyFieldRetry) { + const { retry_times, ...restElements } = newPolicy; + setPolicyDraft(restElements); + return; + } + setPolicyDraft(newPolicy); + setPolicySource(JSON.stringify(newPolicy, null, 4)); + isValidJSON(newPolicy); + }, []); + + const handlePolicySourceChange = (e: React.ChangeEvent) => { + const { value } = e.target; + setPolicySource(value); + validJSONText(value); + if (checkValidJSONText(value)) { + handlePolicyChange(JSON.parse(value)); + } + }; + + useEffect(() => { + setPolicySource(JSON.stringify(policyDraft, null, 4)); + isValidJSON(policyDraft); + }, [policyDraft]); + + useEffect(() => { + if (onChange) { + onChange(policyDraft, isValidPolicySource); + } + }, [isValidPolicySource, onChange, policyDraft]); + + return ( + + + +
+ + + + Name + + + name is a required field + + + + + + + Retry Times + + + + Maximum Capacity + + + maximum_capacity must be greater than 0 + + + + Event TTL + + + + seconds + + + + + +
+
+ + {isSourceVisible && ( + + + + {policySourceError} + + + )} +
+ ); +}; diff --git a/src/components/TriggerDeliveryPolicyHandler.tsx b/src/components/TriggerDeliveryPolicyHandler.tsx new file mode 100644 index 00000000..c39b38d9 --- /dev/null +++ b/src/components/TriggerDeliveryPolicyHandler.tsx @@ -0,0 +1,349 @@ +/* +This file is part of Astarte. + +Copyright 2023 SECO Mind Srl + +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 React, { ChangeEvent, useEffect, useState } from 'react'; +import { Button, Form, Modal, Table } from 'react-bootstrap'; +import { + AstarteTriggerDeliveryPolicyDTO, + AstarteTriggerDeliveryPolicyHandlerDTO, +} from 'astarte-client/types/dto'; +import Icon from './Icon'; + +const checkInvalidCodes = (value: string) => { + const containsInvalidCodes = value + .split(',') + .map((stringCode) => parseInt(stringCode, 10)) + .some((code) => Number.isNaN(code) || code < 400 || code > 599); + return containsInvalidCodes; +}; + +type ErrorCodesControlProps = { + name: string; + value: string; + readonly: boolean; + onChange: (newValue: string) => void; +}; + +const ErrorCodesControl = ({ name, value, readonly, onChange }: ErrorCodesControlProps) => { + const handleInputChange = (e: React.ChangeEvent) => { + onChange(e.target.value); + }; + + return ( + <> + + + enter between 400 and 599 comma separated numbers + + + ); +}; + +const defaultHandler: AstarteTriggerDeliveryPolicyHandlerDTO = { + on: 'any_error', + strategy: 'discard', +}; + +type HandlerModalProps = { + initialHandler?: AstarteTriggerDeliveryPolicyHandlerDTO; + readOnly: boolean; + showModal: boolean; + closeModal: () => void; + addHandler: (handler: AstarteTriggerDeliveryPolicyHandlerDTO) => void; +}; + +const HandlerModal = ({ + initialHandler, + readOnly, + showModal, + closeModal, + addHandler, +}: HandlerModalProps) => { + const [handler] = useState( + initialHandler || defaultHandler, + ); + const [handlerOn, setHandlerOn] = useState(handler.on); + const [handlerStrategy, setHandlerStrategy] = useState(handler.strategy); + const [selectedErrorType, setSelectedErrorType] = useState( + typeof handlerOn === 'object' ? 'custom_errors' : 'any_error', + ); + const [customErrorsText, setCustomErrorsText] = useState( + typeof handlerOn === 'object' ? handler.on.toString() : '', + ); + + const handleOnChange = (event: ChangeEvent) => { + const { value } = event.target; + setSelectedErrorType(value); + setHandlerOn(value as 'any_error' | 'client_error' | 'server_error' | number[]); + }; + + const handleStrategyChange = (event: ChangeEvent) => { + const { value } = event.target; + setHandlerStrategy(value); + }; + + const handleCodes = (value: string) => { + setCustomErrorsText(value); + }; + + const handleAddErrorHandler = () => { + let updatedHandler: AstarteTriggerDeliveryPolicyHandlerDTO; + if (selectedErrorType === 'custom_errors') { + const customErrorsArray = customErrorsText + .split(',') + .map((stringCode) => parseInt(stringCode, 10)) + .filter((code) => !Number.isNaN(code)); + updatedHandler = { + on: customErrorsArray, + strategy: handlerStrategy as 'discard' | 'retry', + }; + addHandler(updatedHandler); + } else { + updatedHandler = { + on: handlerOn, + strategy: handlerStrategy as 'discard' | 'retry', + }; + addHandler(updatedHandler); + } + }; + + return ( + <> + + + Error Handler + + +
+ + On + + + + + + + + {selectedErrorType === 'custom_errors' && ( + + )} + + Strategy + + + + + + +
+ + + + +
+ + ); +}; + +type AddHandlerModalProps = { + isReadOnly: boolean; + showModal: boolean; + onCancel: () => void; + onConfirm: (handler: AstarteTriggerDeliveryPolicyHandlerDTO) => void; +}; + +const AddHandlerModal = ({ isReadOnly, showModal, onCancel, onConfirm }: AddHandlerModalProps) => ( + +); + +type EditHandlerModalProps = { + initialHandler: AstarteTriggerDeliveryPolicyHandlerDTO; + isReadOnly: boolean; + showModal: boolean; + onCancel: () => void; + onConfirm: (handler: AstarteTriggerDeliveryPolicyHandlerDTO) => void; +}; + +const EditHandlerModal = ({ + initialHandler, + isReadOnly, + showModal, + onCancel, + onConfirm, +}: EditHandlerModalProps) => ( + +); + +interface Props { + isReadOnly: boolean; + initialData: AstarteTriggerDeliveryPolicyDTO; + onChange?: (updatedPolicy: AstarteTriggerDeliveryPolicyDTO) => unknown; +} + +export default ({ isReadOnly, initialData, onChange }: Props): React.ReactElement => { + const [isAddingHandler, setIsAddingHandler] = useState(false); + const [handlerToEditIndex, setHandlerToEditIndex] = useState(null); + const [policyDraft, setPolicyDraft] = useState(initialData); + + const handleDeleteErrorHandler = ( + i: number, + handlers: AstarteTriggerDeliveryPolicyHandlerDTO[], + ) => { + const newErrorHandlers = handlers.filter((_, index) => index !== i); + setPolicyDraft({ ...initialData, error_handlers: newErrorHandlers }); + }; + + useEffect(() => { + if (onChange) { + onChange(policyDraft); + } + }, [onChange, policyDraft]); + + return ( + <> + + {!isReadOnly && ( + + )} + {!initialData.error_handlers.length && ( +

+ error handler is required +

+ )} + {initialData.error_handlers.length > 0 && ( + + + + + + {!isReadOnly && } + + + + {initialData.error_handlers.map((el, index) => ( + + + + {!isReadOnly && ( + + )} + + ))} + +
OnStrategyActions
{el.on && el.on.toString()}{el.strategy} + setHandlerToEditIndex(index)} + className="color-grey mr-2" + /> + handleDeleteErrorHandler(index, initialData.error_handlers)} + /> +
+ )} +
+ {isAddingHandler && ( + setIsAddingHandler(false)} + onConfirm={(handler) => { + if (initialData.error_handlers.findIndex((x) => x.on === handler.on) === -1) { + setPolicyDraft({ + ...initialData, + error_handlers: initialData.error_handlers.concat(handler), + }); + } + if (onChange) { + onChange(policyDraft); + } + setIsAddingHandler(false); + }} + isReadOnly + showModal={isAddingHandler} + /> + )} + + {handlerToEditIndex != null && ( + setHandlerToEditIndex(null)} + onConfirm={(handler) => { + initialData.error_handlers.splice(handlerToEditIndex, 1, handler); + setPolicyDraft(initialData); + if (onChange) { + onChange(policyDraft); + } + setHandlerToEditIndex(null); + }} + isReadOnly + showModal={handlerToEditIndex != null} + /> + )} + + ); +};