diff --git a/BUILD b/BUILD index 73311d976a..cb8853c2d8 100644 --- a/BUILD +++ b/BUILD @@ -172,6 +172,15 @@ java_library( "@maven//:com_fasterxml_jackson_core_jackson_annotations", "@maven//:com_fasterxml_jackson_core_jackson_core", "@maven//:com_fasterxml_jackson_core_jackson_databind", + "@maven//:com_fasterxml_jackson_dataformat_jackson_dataformat_yaml", + ], +) + +java_library( + name = "k8s_client", + exports = [ + "@maven//:io_kubernetes_client_java", + "@maven//:io_kubernetes_client_java_api", ], ) diff --git a/VERSION b/VERSION index a3968efc37..c5d4cee36a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.50.1 +0.51.0 diff --git a/backend/components/cognigy/BUILD b/backend/components/cognigy/BUILD new file mode 100644 index 0000000000..1aa1098022 --- /dev/null +++ b/backend/components/cognigy/BUILD @@ -0,0 +1,50 @@ +load("//tools/build:springboot.bzl", "springboot") +load("//tools/build:junit5.bzl", "junit5") +load("//tools/build:container_release.bzl", "container_release") +load("@com_github_airyhq_bazel_tools//lint:buildifier.bzl", "check_pkg") + +check_pkg(name = "buildifier") + +app_deps = [ + "//:spring", + "//:springboot", + "//:springboot_actuator", + "//:jackson", + "//:lombok", + "//backend/model/message", + "//backend/model/metadata", + "//:feign", + "//lib/java/log", + "//lib/java/spring/kafka/core:spring-kafka-core", + "//lib/java/spring/core:spring-core", + "//lib/java/spring/kafka/streams:spring-kafka-streams", + "//lib/java/spring/async:spring-async", +] + +springboot( + name = "cognigy-connector", + srcs = glob(["src/main/java/**/*.java"]), + main_class = "co.airy.spring.core.AirySpringBootApplication", + deps = app_deps, +) + +[ + junit5( + size = "medium", + file = file, + resources = glob(["src/test/resources/**/*"]), + deps = [ + ":app", + "//backend:base_test", + "//lib/java/test", + "//lib/java/kafka/test:kafka-test", + "//lib/java/spring/test:spring-test", + ] + app_deps, + ) + for file in glob(["src/test/java/**/*Test.java"]) +] + +container_release( + registry = "ghcr.io/airyhq/connectors", + repository = "cognigy-connector", +) diff --git a/backend/components/cognigy/helm/BUILD b/backend/components/cognigy/helm/BUILD new file mode 100644 index 0000000000..1f602ed4a8 --- /dev/null +++ b/backend/components/cognigy/helm/BUILD @@ -0,0 +1,28 @@ +load("@rules_pkg//:pkg.bzl", "pkg_tar") +load("@com_github_airyhq_bazel_tools//helm:helm.bzl", "helm_template_test") +load("//tools/build:helm.bzl", "helm_push") + +filegroup( + name = "files", + srcs = glob( + ["**/*"], + exclude = ["BUILD"], + ), + visibility = ["//visibility:public"], +) + +pkg_tar( + name = "package", + srcs = [":files"], + extension = "tgz", + strip_prefix = "./", +) + +helm_template_test( + name = "template", + chart = ":package", +) + +helm_push( + chart = ":package", +) diff --git a/backend/components/cognigy/helm/Chart.yaml b/backend/components/cognigy/helm/Chart.yaml new file mode 100644 index 0000000000..c097d8dbec --- /dev/null +++ b/backend/components/cognigy/helm/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +appVersion: "1.0" +description: A Helm chart for the Cognigy connector +name: cognigy-connector +version: 1.0 diff --git a/backend/components/cognigy/helm/templates/configmap.yaml b/backend/components/cognigy/helm/templates/configmap.yaml new file mode 100644 index 0000000000..daae16661f --- /dev/null +++ b/backend/components/cognigy/helm/templates/configmap.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: "{{ .Values.component }}" + labels: + core.airy.co/managed: "true" + core.airy.co/mandatory: "{{ .Values.mandatory }}" + core.airy.co/component: "{{ .Values.component }}" + core.airy.co/enterprise: "false" + annotations: + core.airy.co/enabled: "{{ .Values.enabled }}" diff --git a/backend/components/cognigy/helm/templates/deployment.yaml b/backend/components/cognigy/helm/templates/deployment.yaml new file mode 100644 index 0000000000..92e5fe6e77 --- /dev/null +++ b/backend/components/cognigy/helm/templates/deployment.yaml @@ -0,0 +1,80 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.component }} + labels: + app: {{ .Values.component }} + core.airy.co/managed: "true" + core.airy.co/mandatory: "{{ .Values.mandatory }}" + core.airy.co/component: {{ .Values.component }} +spec: + replicas: {{ if .Values.enabled }} 1 {{ else }} 0 {{ end }} + selector: + matchLabels: + app: {{ .Values.component }} + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + type: RollingUpdate + template: + metadata: + labels: + app: {{ .Values.component }} + spec: + containers: + - name: app + image: "{{ .Values.global.containerRegistry}}/{{ .Values.image }}:{{ default .Chart.Version }}" + imagePullPolicy: Always + envFrom: + - configMapRef: + name: security + - configMapRef: + name: kafka-config + env: + - name: COGNIGY_RESTENDPOINT_URL + valueFrom: + configMapKeyRef: + key: cognigyRestEndpointURL + name: {{ .Values.component }} + - name: COGNIGY_USERID + valueFrom: + configMapKeyRef: + key: cognigyUserId + name: {{ .Values.component }} + - name: SERVICE_NAME + value: {{ .Values.component }} + - name: POD_NAMESPACE + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: metadata.namespace + - name: REQUESTED_CPU + valueFrom: + resourceFieldRef: + containerName: app + resource: requests.cpu + - name: LIMIT_CPU + valueFrom: + resourceFieldRef: + containerName: app + resource: limits.cpu + - name: LIMIT_MEMORY + valueFrom: + resourceFieldRef: + containerName: app + resource: limits.memory + livenessProbe: + httpGet: + path: /actuator/health + port: 8080 + httpHeaders: + - name: Health-Check + value: health-check + initialDelaySeconds: 60 + periodSeconds: 10 + failureThreshold: 3 + volumes: + - name: {{ .Values.component }} + configMap: + name: {{ .Values.component }} diff --git a/backend/components/cognigy/helm/templates/service.yaml b/backend/components/cognigy/helm/templates/service.yaml new file mode 100644 index 0000000000..9ecaf9eed3 --- /dev/null +++ b/backend/components/cognigy/helm/templates/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: {{ .Values.component }} + name: {{ .Values.component }} +spec: + ports: + - port: 80 + protocol: TCP + targetPort: 8080 + selector: + app: {{ .Values.component }} + type: ClusterIP diff --git a/backend/components/cognigy/helm/values.yaml b/backend/components/cognigy/helm/values.yaml new file mode 100644 index 0000000000..d105b64140 --- /dev/null +++ b/backend/components/cognigy/helm/values.yaml @@ -0,0 +1,7 @@ +component: cognigy-connector +mandatory: false +enabled: false +image: connectors/cognigy-connector +global: + containerRegistry: ghcr.io/airyhq +resources: {} diff --git a/backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/CognigyClient.java b/backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/CognigyClient.java new file mode 100644 index 0000000000..7fcb7012eb --- /dev/null +++ b/backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/CognigyClient.java @@ -0,0 +1,15 @@ +package co.airy.core.cognigy_connector; + +import co.airy.core.cognigy.models.MessageSend; +import co.airy.core.cognigy.models.MessageSendResponse; + +import feign.Headers; +import feign.RequestLine; + +import java.util.List; + +public interface CognigyClient { + @RequestLine("POST {restEndpointURL}") + @Headers("Content-Type: application/json") + MessageSendResponse sendMessage(MessageSend content); +} \ No newline at end of file diff --git a/backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/CognigyClientConfig.java b/backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/CognigyClientConfig.java new file mode 100644 index 0000000000..bbf2a42596 --- /dev/null +++ b/backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/CognigyClientConfig.java @@ -0,0 +1,23 @@ +package co.airy.core.cognigy_connector; + +import feign.Feign; +import feign.jackson.JacksonDecoder; +import feign.jackson.JacksonEncoder; +import feign.okhttp.OkHttpClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CognigyClientConfig { + @Bean + public CognigyClient cognigyClient(@Value("${cognigy.restEndpointURL}") String cognigyRestEndpointUrl) { + return Feign.builder() + .client(new OkHttpClient()) + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .logger(new feign.Logger.ErrorLogger()) + .logLevel(feign.Logger.Level.FULL) + .target(CognigyClient.class, cognigyRestEndpointUrl); + } +} diff --git a/backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/CognigyConnectorService.java b/backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/CognigyConnectorService.java new file mode 100644 index 0000000000..62ea75ee04 --- /dev/null +++ b/backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/CognigyConnectorService.java @@ -0,0 +1,69 @@ +package co.airy.core.cognigy_connector; + +import co.airy.avro.communication.Message; +import co.airy.core.cognigy.models.MessageSendResponse; +import co.airy.core.cognigy.models.MessageSend; + +import co.airy.log.AiryLoggerFactory; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.avro.specific.SpecificRecordBase; +import org.apache.kafka.streams.KeyValue; +import org.slf4j.Logger; +import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Value; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + + +@Service +public class CognigyConnectorService { + private final CognigyClient cognigyClient; + + private static final ObjectMapper mapper = new ObjectMapper(); + + private static final Logger log = AiryLoggerFactory.getLogger(CognigyConnectorService.class); + private final MessageHandler messageHandler; + private final String userId; + + CognigyConnectorService(MessageHandler messageHandler, CognigyClient cognigyClient, @Value("${cognigy.userId}") String userId) { + this.messageHandler = messageHandler; + this.cognigyClient = cognigyClient; + this.userId = userId; + } + + public List> send(Message userMessage) { + final List> result = new ArrayList<>(); + try { + MessageSendResponse cognigyResponse = this.cognigyClient.sendMessage(MessageSend.builder() + .text(getTextFromContent(userMessage.getContent())) + .userId(userId) + .sessionId(userMessage.getConversationId()) + .build()); + Message message = messageHandler.getMessage(userMessage, cognigyResponse); + result.add(KeyValue.pair(message.getId(), message)); + } catch (Exception e) { + log.error(String.format("could not call the Cognigy webhook for message id %s %s", userMessage.getId(), e)); + } + return result; + } + + private String getTextFromContent(String content) { + String text = ""; + + try { + final JsonNode node = Optional.ofNullable(mapper.readTree(content)).orElseGet(mapper::createObjectNode); + + //NOTE: Tries to find the text context for text messages + text = Optional.ofNullable(node.findValue("text")).orElseGet(mapper::createObjectNode).asText(); + } catch (JsonProcessingException e) { + log.error(String.format("unable to parse text from content %s", content)); + } + + //NOTE: return default message when text is not found + return Optional.ofNullable(text).filter(s -> !s.isEmpty()).orElse("New message"); + } +} diff --git a/backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/MessageHandler.java b/backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/MessageHandler.java new file mode 100644 index 0000000000..ceae5dbcda --- /dev/null +++ b/backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/MessageHandler.java @@ -0,0 +1,93 @@ +package co.airy.core.cognigy_connector; + +import co.airy.avro.communication.DeliveryState; +import co.airy.avro.communication.Message; +import co.airy.core.cognigy.models.MessageSendResponse; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +@Service +public class MessageHandler { + private final ObjectMapper mapper = new ObjectMapper(); + + MessageHandler() { + } + + public Message getMessage(Message contactMessage, MessageSendResponse response) throws Exception { + String content = getContent(contactMessage.getSource(), response); + if (content == null) { + throw new Exception("Unable to map cognigy reply to source response."); + } + + return Message.newBuilder() + .setId(UUID.randomUUID().toString()) + .setChannelId(contactMessage.getChannelId()) + .setContent(content) + .setConversationId(contactMessage.getConversationId()) + .setHeaders(Map.of()) + .setDeliveryState(DeliveryState.PENDING) + .setSource(contactMessage.getSource()) + .setSenderId("cognigy-bot") + .setSentAt(Instant.now().toEpochMilli()) + .setIsFromContact(false) + .build(); + } + + public String getContent(String source, MessageSendResponse response) throws JsonProcessingException { + + final JsonNode messageNode = mapper.valueToTree(response); + final String text = messageNode.get("text").textValue(); + final JsonNode data = messageNode.findValue("data"); + + final ObjectNode node = getNode(); + switch (source) { + case "google": { + final ObjectNode representative = getNode(); + representative.put("representativeType", "BOT"); + node.set("representative", representative); + node.put("text", text); + return mapper.writeValueAsString(node); + } + case "viber": { + node.put("text", text); + node.put("type", "text"); + return mapper.writeValueAsString(node); + } + case "chatplugin": + case "instagram": + case "facebook": { + node.put("text", text); + if(!data.isEmpty()){ + node.put("message", data); + } + return mapper.writeValueAsString(node); + } + case "twilio.sms": + case "twilio.whatsapp": { + node.put("Body", text); + return mapper.writeValueAsString(node); + } + case "whatsapp": { + node.put("Body", text); + return mapper.writeValueAsString(node); + } + + default: { + return null; + } + } + } + + private ObjectNode getNode() { + final JsonNodeFactory jsonNodeFactory = JsonNodeFactory.instance; + return jsonNodeFactory.objectNode(); + } +} diff --git a/backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/Stores.java b/backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/Stores.java new file mode 100644 index 0000000000..f45364fedf --- /dev/null +++ b/backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/Stores.java @@ -0,0 +1,78 @@ +package co.airy.core.cognigy_connector; + +import co.airy.avro.communication.Message; +import co.airy.avro.communication.Metadata; +import co.airy.kafka.schema.application.ApplicationCommunicationMessages; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; +import co.airy.kafka.streams.KafkaStreamsWrapper; +import org.apache.kafka.streams.KafkaStreams; +import org.apache.kafka.streams.StreamsBuilder; +import org.apache.kafka.streams.Topology; +import org.apache.kafka.streams.kstream.Consumed; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; +import co.airy.log.AiryLoggerFactory; +import org.slf4j.Logger; + +import static co.airy.model.message.MessageRepository.isNewMessage; + +@Component +public class Stores implements HealthIndicator, ApplicationListener, DisposableBean { + private static final String appId = "cognigy-connector"; + private final KafkaStreamsWrapper streams; + private CognigyConnectorService cognigyConnectorService; + private static final Logger log = AiryLoggerFactory.getLogger(Stores.class); + + Stores(KafkaStreamsWrapper streams, CognigyConnectorService cognigyConnectorService) { + this.streams = streams; + this.cognigyConnectorService = cognigyConnectorService; + } + + @Override + public void onApplicationEvent(ApplicationStartedEvent event){ + + final StreamsBuilder builder = new StreamsBuilder(); + + final String applicationCommunicationMetadata = new ApplicationCommunicationMetadata().name(); + final String applicationCommunicationMessages = new ApplicationCommunicationMessages().name(); + + builder.stream( + new ApplicationCommunicationMessages().name(), + Consumed.with(Topology.AutoOffsetReset.LATEST) + ).filter((messageId, message) -> message != null && isNewMessage(message) && message.getIsFromContact()) + .flatMap((messageId, message) -> cognigyConnectorService.send(message)) + .to((recordId, record, context) -> { + if (record instanceof Metadata) { + return applicationCommunicationMetadata; + } + if (record instanceof Message) { + return applicationCommunicationMessages; + } + + throw new IllegalStateException("Unknown type for record " + record); + }); + + streams.start(builder.build(), appId); + } + + @Override + public void destroy(){ + if (streams != null) { + streams.close(); + } + } + + @Override + public Health health() { + return Health.up().build(); + } + + // visible for testing + KafkaStreams.State getStreamState() { + return streams.state(); + } +} diff --git a/backend/components/rasa/src/main/java/co/airy/core/rasa_connector/models/AiryAttachment.java b/backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/models/MessageSend.java similarity index 55% rename from backend/components/rasa/src/main/java/co/airy/core/rasa_connector/models/AiryAttachment.java rename to backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/models/MessageSend.java index 355abd6443..b3f3a12404 100644 --- a/backend/components/rasa/src/main/java/co/airy/core/rasa_connector/models/AiryAttachment.java +++ b/backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/models/MessageSend.java @@ -1,4 +1,4 @@ -package co.airy.core.rasa_connector.models; +package co.airy.core.cognigy.models; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; @@ -7,14 +7,17 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.lang.String; @Data @Builder @NoArgsConstructor @AllArgsConstructor -@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) -public class AiryAttachment { - String type; - AiryPayload payload; +@JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) + +public class MessageSend { + private String text; + private String userId; + private String sessionId; } diff --git a/backend/components/rasa/src/main/java/co/airy/core/rasa_connector/models/AiryPayload.java b/backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/models/MessageSendResponse.java similarity index 55% rename from backend/components/rasa/src/main/java/co/airy/core/rasa_connector/models/AiryPayload.java rename to backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/models/MessageSendResponse.java index 3b1d925231..0f968d7796 100644 --- a/backend/components/rasa/src/main/java/co/airy/core/rasa_connector/models/AiryPayload.java +++ b/backend/components/cognigy/src/main/java/co/airy/core/cognigy_connector/models/MessageSendResponse.java @@ -1,5 +1,6 @@ -package co.airy.core.rasa_connector.models; +package co.airy.core.cognigy.models; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; import lombok.AllArgsConstructor; @@ -7,13 +8,12 @@ import lombok.Data; import lombok.NoArgsConstructor; - - @Data @Builder @NoArgsConstructor @AllArgsConstructor -@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) -public class AiryPayload { - String url; +@JsonNaming(PropertyNamingStrategies.LowerCamelCaseStrategy.class) +public class MessageSendResponse { + private String text; + private JsonNode data; } diff --git a/backend/components/cognigy/src/main/resources/application.properties b/backend/components/cognigy/src/main/resources/application.properties new file mode 100644 index 0000000000..0381791401 --- /dev/null +++ b/backend/components/cognigy/src/main/resources/application.properties @@ -0,0 +1,6 @@ +thpool.core-pool-size=1 +thpool.max-pool-size=2 +thpool.queue-capacity=0 + +cognigy.restEndpointURL=${COGNIGY_RESTENDPOINT_URL} +cognigy.userId=${COGNIGY_USERID} diff --git a/backend/components/cognigy/src/test/java/co/airy/core/cognigy_connector/CognigyConnectorServiceTest.java b/backend/components/cognigy/src/test/java/co/airy/core/cognigy_connector/CognigyConnectorServiceTest.java new file mode 100644 index 0000000000..64c8acd16b --- /dev/null +++ b/backend/components/cognigy/src/test/java/co/airy/core/cognigy_connector/CognigyConnectorServiceTest.java @@ -0,0 +1,91 @@ +package co.airy.core.cognigy_connector; + +import co.airy.avro.communication.DeliveryState; +import co.airy.avro.communication.Message; +import co.airy.core.cognigy.models.MessageSend; +import co.airy.kafka.schema.application.ApplicationCommunicationMessages; +import co.airy.kafka.test.KafkaTestHelper; +import co.airy.kafka.test.junit.SharedKafkaTestResource; +import co.airy.spring.core.AirySpringBootApplication; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.ArgumentCaptor; +import org.mockito.MockitoAnnotations; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.time.Instant; +import java.util.UUID; + +import static co.airy.test.Timing.retryOnException; +import static org.apache.kafka.streams.KafkaStreams.State.RUNNING; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; + +@SpringBootTest(classes = AirySpringBootApplication.class) +@TestPropertySource(value = "classpath:test.properties") +@ExtendWith(SpringExtension.class) +@AutoConfigureMockMvc +public class CognigyConnectorServiceTest { + @RegisterExtension + public static final SharedKafkaTestResource sharedKafkaTestResource = new SharedKafkaTestResource(); + public static final ApplicationCommunicationMessages applicationCommunicationMessages = new ApplicationCommunicationMessages(); + private static KafkaTestHelper kafkaTestHelper; + + @MockBean + CognigyClient cognigyClient; + + @Autowired + Stores stores; + + @BeforeAll + //overloads the kafkaTestHelper.beforeAll with + static void beforeAll() throws Exception { + kafkaTestHelper = new KafkaTestHelper(sharedKafkaTestResource, applicationCommunicationMessages); + kafkaTestHelper.beforeAll(); + } + + @BeforeEach + void beforeEach() throws InterruptedException { + MockitoAnnotations.openMocks(this); + retryOnException(() -> assertEquals(stores.getStreamState(), RUNNING), "Failed to reach RUNNING state."); + } + + @AfterAll + static void afterAll() throws Exception { + kafkaTestHelper.afterAll(); + } + + @Test + public void callsCognigyClientSendWhenMessageIsValid() throws Exception { + //given + String textMessage = "Hello from Airy"; + sendMessage(textMessage); + //when + ArgumentCaptor messageSendArgumentCaptor = ArgumentCaptor.forClass(MessageSend.class); + //then + retryOnException(() -> { + verify(cognigyClient).sendMessage(messageSendArgumentCaptor.capture()); + MessageSend sentMessage = messageSendArgumentCaptor.getValue(); + assertThat(sentMessage.getText(), equalTo(textMessage)); + }, "message was not created"); + } + + private Message sendMessage(String text) throws Exception { + final String Id = UUID.randomUUID().toString(); + final Message message = Message.newBuilder().setId(Id).setSource("test-source").setSentAt(Instant.now().toEpochMilli()).setSenderId("test-sender-id").setDeliveryState(DeliveryState.DELIVERED).setConversationId("test-conversation-id").setChannelId("test-channel-id").setContent(String.format("{\"text\": \"%s\"}", text)).setIsFromContact(true).build(); + kafkaTestHelper.produceRecord(new ProducerRecord<>(applicationCommunicationMessages.name(), Id, message)); + return message; + } +} diff --git a/backend/components/cognigy/src/test/resources/test.properties b/backend/components/cognigy/src/test/resources/test.properties new file mode 100644 index 0000000000..bea3493c5b --- /dev/null +++ b/backend/components/cognigy/src/test/resources/test.properties @@ -0,0 +1,10 @@ +kafka.cleanup=true +kafka.commit-interval-ms=0 +kafka.cache.max.bytes=0 + +thpool.core-pool-size=1 +thpool.max-pool-size=1 +thpool.queue-capacity=1 + +cognigy.restEndpointURL="https://cognigy.trial.airy.com" +cognigy.userId="12345678" diff --git a/backend/components/installer/BUILD b/backend/components/installer/BUILD new file mode 100644 index 0000000000..7fff85ba03 --- /dev/null +++ b/backend/components/installer/BUILD @@ -0,0 +1,48 @@ +load("@com_github_airyhq_bazel_tools//lint:buildifier.bzl", "check_pkg") +load("//tools/build:springboot.bzl", "springboot") +load("//tools/build:junit5.bzl", "junit5") +load("//tools/build:container_release.bzl", "container_release") + +app_deps = [ + "//backend:base_app", + "//backend/model/metadata", + "//lib/java/spring/web:spring-web", + "//lib/java/spring/async:spring-async", + "//:springboot_actuator", + "//:k8s_client", + "@maven//:org_eclipse_jgit_org_eclipse_jgit", + "@maven//:org_springframework_retry_spring_retry", + "@maven//:org_springframework_boot_spring_boot_starter_cache", + "@maven//:org_aspectj_aspectjweaver", +] + +springboot( + name = "api-components-installer", + srcs = glob(["src/main/java/**/*.java"]), + main_class = "co.airy.spring.core.AirySpringBootApplication", + deps = app_deps, +) + +[ + junit5( + size = "medium", + file = file, + resources = glob(["src/test/resources/**/*"]), + deps = [ + ":app", + "//backend:base_test", + "//lib/java/test", + "//lib/java/kafka/test:kafka-test", + "//lib/java/spring/test:spring-test", + "@maven//:org_mockito_mockito_inline", + ] + app_deps, + ) + for file in glob(["src/test/java/**/*Test.java"]) +] + +container_release( + registry = "ghcr.io/airyhq/api/components", + repository = "installer", +) + +check_pkg(name = "buildifier") diff --git a/backend/components/installer/src/main/java/co/airy/core/api/components/installer/CatalogHandler.java b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/CatalogHandler.java new file mode 100644 index 0000000000..5195d4c484 --- /dev/null +++ b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/CatalogHandler.java @@ -0,0 +1,102 @@ +package co.airy.core.api.components.installer; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.slf4j.Logger; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Service; + +import co.airy.core.api.components.installer.model.ComponentDetails; +import co.airy.log.AiryLoggerFactory; + +@Service +public class CatalogHandler implements ApplicationListener, DisposableBean { + + private static final Logger log = AiryLoggerFactory.getLogger(CatalogHandler.class); + + private final ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + private final File repoFolder; + private final String catalogUri; + private final InstalledComponentsHandler installedComponentsHandler; + private Git git; + + CatalogHandler( + InstalledComponentsHandler installedComponentsHandler, + @Value("${catalog.uri}") String catalogUri, + @Value("${catalog.directory}") String catalogDir) { + this.catalogUri = catalogUri; + this.repoFolder = new File(catalogDir); + this.installedComponentsHandler = installedComponentsHandler; + } + + @Override + public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) { + try { + git = Git.cloneRepository() + .setURI(catalogUri) + .setDirectory(repoFolder) + .call(); + } catch (GitAPIException e) { + throw new IllegalStateException("not able to clone catalog repository", e); + } + } + + @Override + public void destroy() { + if (repoFolder != null) { + repoFolder.delete(); + } + } + + public List listComponents() throws Exception { + return getComponents((s -> true)); + } + + public ComponentDetails getComponentByName(String componentName) throws Exception { + return getComponents((s -> s.equals(componentName))) + .stream() + .findAny() + //TODO: throws exception instead + .orElse(null); + } + + + private List getComponents(Function condition) throws Exception { + git.pull(); + final Map installedComponents = installedComponentsHandler.getInstalledComponentsCache(); + + final List components = Stream.of(repoFolder.listFiles()) + .filter(f -> f.isDirectory() && !f.isHidden() && condition.apply(f.getName())) + .map(File::getAbsoluteFile) + //TODO: hanlde other description languages + .map(f -> new File(f, "description.yaml")) + .map(f -> { + ComponentDetails config = null; + try { + config = mapper.readValue(f, ComponentDetails.class); + } catch(IOException e) { + log.error("unable to read config %s", e); + } + + return config; + }) + .filter(c -> c != null) + .map(c -> c.add("installed", installedComponents.getOrDefault(c.getName(), Boolean.FALSE))) + .collect(Collectors.toList()); + + return components; + } +} diff --git a/backend/components/installer/src/main/java/co/airy/core/api/components/installer/HelmJobHandler.java b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/HelmJobHandler.java new file mode 100644 index 0000000000..614984ec30 --- /dev/null +++ b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/HelmJobHandler.java @@ -0,0 +1,143 @@ +package co.airy.core.api.components.installer; + +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.ApiResponse; +import io.kubernetes.client.openapi.apis.BatchV1Api; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1Container; +import io.kubernetes.client.openapi.models.V1Job; +import io.kubernetes.client.openapi.models.V1JobSpec; +import io.kubernetes.client.openapi.models.V1ObjectMeta; +import io.kubernetes.client.openapi.models.V1Pod; +import io.kubernetes.client.openapi.models.V1PodList; +import io.kubernetes.client.openapi.models.V1PodSpec; +import io.kubernetes.client.openapi.models.V1PodTemplateSpec; + +import org.slf4j.Logger; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Component; + +import java.util.List; + +import co.airy.log.AiryLoggerFactory; + +@Component +@EnableRetry +public class HelmJobHandler { + + private static final Logger log = AiryLoggerFactory.getLogger(HelmJobHandler.class); + + private final ApiClient apiClient; + private final String namespace; + + HelmJobHandler(ApiClient apiClient, @Value("${kubernetes.namespace}") String namespace) { + this.apiClient = apiClient; + this.namespace = namespace; + } + + public V1Job launchHelmJob(String jobName, List cmd) throws Exception { + final V1Job runningJob = isJobAlreadyRunning(jobName); + if (runningJob != null) { + return runningJob; + } + + final V1Job job = new V1Job() + .metadata(new V1ObjectMeta().name(jobName)) + .spec(new V1JobSpec() + .template(new V1PodTemplateSpec() + .spec(new V1PodSpec() + .addContainersItem(new V1Container() + .name(jobName) + .image("alpine/helm:latest") + .command(cmd)) + .restartPolicy("Never") + .serviceAccountName("airy-controller"))) + .backoffLimit(0) + .ttlSecondsAfterFinished(10)); + + final BatchV1Api api = new BatchV1Api(apiClient); + final ApiResponse response = api.createNamespacedJobWithHttpInfo( + namespace, + job, + null, + null, + null, + null); + + return response.getData(); + } + + @Retryable(value = JobEmptyException.class, maxAttemptsExpression = "${retry.maxAttempts}", + backoff = @Backoff(delayExpression = "${retry.maxDelay}")) + public V1Job getJobByName(String jobName) throws JobEmptyException { + final V1Job job = isJobAlreadyRunning(jobName); + if (job == null) { + throw new JobEmptyException(); + } + + return job; + } + + @Retryable(value = NotCompletedException.class, maxAttemptsExpression = "${retry.maxAttempts}", + backoff = @Backoff(delayExpression = "${retry.maxDelay}")) + public String waitForCompletedStatus(CoreV1Api api, V1Job job) throws NotCompletedException, ApiException { + final ApiResponse listResponse = api.listNamespacedPodWithHttpInfo( + job.getMetadata().getNamespace(), + null, + null, + null, + null, + "job-name", + null, + null, + null, + null, + null); + final String podName = listResponse + .getData() + .getItems() + .stream() + .map(V1Pod::getMetadata) + .filter(m -> m.getLabels().get("job-name").equals(job.getMetadata().getName())) + .map(V1ObjectMeta::getName) + .findAny() + .orElse(""); + + if (podName.isEmpty()) { + throw new NotCompletedException(); + } + + final ApiResponse podStatus = api.readNamespacedPodStatusWithHttpInfo( + podName, + namespace, + null); + + if (!podStatus.getData().getStatus().getPhase().equals("Succeeded")) { + throw new NotCompletedException(); + } + + return podName; + } + + private V1Job isJobAlreadyRunning(String jobName) { + try { + final BatchV1Api api = new BatchV1Api(apiClient); + final ApiResponse response = api.readNamespacedJobWithHttpInfo( + jobName, + namespace, + null); + return response.getData(); + } catch (ApiException e) { + if (e.getCode() == HttpStatus.NOT_FOUND.value()) { + return null; + } + } + + return null; + } +} diff --git a/backend/components/installer/src/main/java/co/airy/core/api/components/installer/InstalledComponentsHandler.java b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/InstalledComponentsHandler.java new file mode 100644 index 0000000000..9d40816200 --- /dev/null +++ b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/InstalledComponentsHandler.java @@ -0,0 +1,86 @@ +package co.airy.core.api.components.installer; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiResponse; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1Job; + +import org.slf4j.Logger; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; + +import co.airy.log.AiryLoggerFactory; + +@Component +public class InstalledComponentsHandler { + + private static final Logger log = AiryLoggerFactory.getLogger(InstallerHandler.class); + + private final ApiClient apiClient; + private final String namespace; + private final HelmJobHandler helmJobHandler; + + InstalledComponentsHandler( + ApiClient apiClient, + HelmJobHandler helmJobHandler, + @Value("${kubernetes.namespace}") String namespace) { + this.apiClient = apiClient; + this.namespace = namespace; + this.helmJobHandler = helmJobHandler; + } + + @Cacheable("installedComponents") + public Map getInstalledComponentsCache() throws Exception { + return getInstalledComponents(); + } + + @CachePut(value = "installedComponents") + public Map putInstalledComponentsCache() throws Exception { + return getInstalledComponents(); + } + + private Map getInstalledComponents() throws Exception { + + ArrayList cmd = new ArrayList<>(); + cmd.add("sh"); + cmd.add("-c"); + cmd.add(String.format( + "helm -n %s list | awk '{print $1}' | tail -n +2", + namespace)); + + final V1Job job = helmJobHandler.launchHelmJob("helm-installed", cmd); + final CoreV1Api api = new CoreV1Api(apiClient); + + final String podName = helmJobHandler.waitForCompletedStatus(api, job); + + final ApiResponse response = api.readNamespacedPodLogWithHttpInfo( + podName, + job.getMetadata().getNamespace(), + "", + null, + null, + null, + null, + null, + null, + null, + null); + + final Map installedComponents = Arrays.asList(response.getData().split("\\n")) + .stream() + .collect(Collectors.toMap(e -> e, e -> true)); + + if (installedComponents == null) { + throw new JobEmptyException(); + } + + return installedComponents; + } +} diff --git a/backend/components/installer/src/main/java/co/airy/core/api/components/installer/InstallerController.java b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/InstallerController.java new file mode 100644 index 0000000000..c6e8051fcd --- /dev/null +++ b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/InstallerController.java @@ -0,0 +1,73 @@ +package co.airy.core.api.components.installer; + +import co.airy.core.api.components.installer.CatalogHandler; +import co.airy.core.api.components.installer.InstallerHandler; +import co.airy.core.api.components.installer.payload.InstallPayload; +import co.airy.core.api.components.installer.payload.UninstallPayload; + +import org.slf4j.Logger; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.List; + +import co.airy.log.AiryLoggerFactory; +import io.kubernetes.client.openapi.ApiException; +import co.airy.core.api.components.installer.model.ComponentDetails; + +@RestController +public class InstallerController { + private static final Logger log = AiryLoggerFactory.getLogger(InstallerController.class); + + private final InstallerHandler intallerHandler; + private final CatalogHandler catalogHandler; + + InstallerController(InstallerHandler intallerHandler, CatalogHandler catalogHandler) { + this.intallerHandler = intallerHandler; + this.catalogHandler = catalogHandler; + } + + @PostMapping("/components.install") + public ResponseEntity installComponent(@RequestBody @Valid InstallPayload payload) { + try { + intallerHandler.installComponent(payload.getName()); + return ResponseEntity.status(HttpStatus.ACCEPTED).build(); + } catch(ApiException e) { + log.error(String.format("unable to perform install (%s)", e.getResponseBody()), e); + } catch(Exception e) { + log.error("unable to perform install", e); + } + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + + @PostMapping("/components.uninstall") + public ResponseEntity uninstallComponent(@RequestBody @Valid UninstallPayload payload) { + try { + intallerHandler.uninstallComponent(payload.getName()); + return ResponseEntity.status(HttpStatus.ACCEPTED).build(); + } catch(ApiException e) { + log.error(String.format("unable to perform uninstall (%s)", e.getResponseBody()), e); + } catch(Exception e) { + log.error("unable to perform uninstall", e); + } + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + + @PostMapping("/components.list") + public ResponseEntity listComponents() { + try { + final List components = catalogHandler.listComponents(); + return ResponseEntity.status(HttpStatus.OK).body(ComponentDetails.componentsDetailsListToMap(components)); + } catch(ApiException e) { + log.error(String.format("unable to perform list (%s)", e.getResponseBody()), e); + } catch(Exception e) { + log.error("unable to perform list", e); + } + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } +} diff --git a/backend/components/installer/src/main/java/co/airy/core/api/components/installer/InstallerHandler.java b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/InstallerHandler.java new file mode 100644 index 0000000000..132b325c2b --- /dev/null +++ b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/InstallerHandler.java @@ -0,0 +1,160 @@ +package co.airy.core.api.components.installer; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.ApiResponse; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1ConfigMap; + +import org.slf4j.Logger; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import co.airy.core.api.components.installer.model.Component; +import co.airy.core.api.components.installer.model.ComponentDetails; +import co.airy.core.api.components.installer.model.Repository; +import co.airy.log.AiryLoggerFactory; + +@Service +public class InstallerHandler { + + private static final Logger log = AiryLoggerFactory.getLogger(InstallerHandler.class); + + private final ApiClient apiClient; + private final HelmJobHandler helmJobHandler; + private final CatalogHandler catalogHandler; + private final InstallerHandlerCacheManager installerHandlerCacheManager; + private final String namespace; + private final ObjectMapper mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + InstallerHandler( + ApiClient apiClient, + HelmJobHandler helmJobHandler, + CatalogHandler catalogHandler, + InstallerHandlerCacheManager installerHandlerCacheManager, + @Value("${kubernetes.namespace}") String namespace) { + this.apiClient = apiClient; + this.helmJobHandler = helmJobHandler; + this.catalogHandler = catalogHandler; + this.installerHandlerCacheManager = installerHandlerCacheManager; + this.namespace = namespace; + } + + public void installComponent(String componentName) throws Exception { + final CoreV1Api api = new CoreV1Api(apiClient); + final Map coreConfig = getConfigMap(api, "core-config"); + final String globals = coreConfig.get("global.yaml"); + final String version = coreConfig.get("APP_IMAGE_TAG"); + final Map repositories = getRepositories(api); + final Component component = getComponentFromName(repositories, componentName, version); + final List cmd = getInstallCommand(component, globals); + + + final String jobName = String.format("helm-install-%s", componentName); + helmJobHandler.launchHelmJob(jobName, cmd); + installerHandlerCacheManager.resetCacheAfterJob(jobName); + } + + public void uninstallComponent(String componentName) throws Exception { + final List cmd = getUninstallCommand(componentName); + + final String jobName = String.format("helm-uninstall-%s", componentName); + helmJobHandler.launchHelmJob(jobName, cmd); + installerHandlerCacheManager.resetCacheAfterJob(jobName); + } + + private Component getComponentFromName( + Map repositories, + String componentName, + String version) throws Exception { + + ComponentDetails componentDetails = catalogHandler.getComponentByName(componentName); + if (componentDetails.isInstalled()) { + throw new Exception("Already installed"); + } + + final Repository repo = repositories.get(componentDetails.getRepository()); + if (repo == null) { + log.error("repository %s not found", componentDetails.getRepository()); + throw new NoSuchElementException(); + } + + return Component.builder() + .name(componentDetails.getName()) + .url(String.format("%s/charts/%s-%s.tgz", repo.getUrl(), componentDetails.getName(), version)) + .username(repo.getUsername()) + .password(repo.getPassword()) + .build(); + } + + private Map getRepositories(CoreV1Api api) throws ApiException, JsonProcessingException { + final String repositoriesBlob = getConfigMap(api, "repositories").get("repositories.json"); + if (repositoriesBlob == null || repositoriesBlob.isEmpty()) { + log.error("repositories json configuration not found"); + throw new ApiException(); + } + + final List repos = mapper.readValue( + repositoriesBlob, + new TypeReference>>(){}).get("repositories"); + return repos.stream().collect(Collectors.toMap(Repository::getName, Function.identity())); + } + + private Map getConfigMap(CoreV1Api api, String configName) throws ApiException { + final ApiResponse response = api.readNamespacedConfigMapWithHttpInfo( + configName, + namespace, + null); + + final V1ConfigMap config = response.getData(); + final Map data = config.getData(); + if (data == null) { + log.error("core-config configuration not found"); + throw new ApiException(); + } + + return data; + } + + private List getInstallCommand(Component component, String globals) { + + ArrayList cmd = new ArrayList<>(); + cmd.add("sh"); + cmd.add("-c"); + cmd.add(String.format( + "helm -n %s install %s %s %s %s --values <(echo %s | base64 -d)", + namespace, + component.getName(), + component.getUrl(), + Optional.ofNullable(component.getUsername()).map(u -> String.format("--username %s", u)).orElse(""), + Optional.ofNullable(component.getPassword()).map(p -> String.format("--password %s", p)).orElse(""), + Base64.getEncoder().encodeToString(globals.getBytes()))); + + return cmd; + } + + private List getUninstallCommand(String componentName) { + ArrayList cmd = new ArrayList<>(); + cmd.add("sh"); + cmd.add("-c"); + cmd.add(String.format( + "helm -n %s uninstall %s", + namespace, + componentName)); + + return cmd; + } +} diff --git a/backend/components/installer/src/main/java/co/airy/core/api/components/installer/InstallerHandlerCacheManager.java b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/InstallerHandlerCacheManager.java new file mode 100644 index 0000000000..eb0f84336e --- /dev/null +++ b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/InstallerHandlerCacheManager.java @@ -0,0 +1,44 @@ +package co.airy.core.api.components.installer; + +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1Job; + +import org.slf4j.Logger; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import co.airy.log.AiryLoggerFactory; + +@Component +public class InstallerHandlerCacheManager { + + private static final Logger log = AiryLoggerFactory.getLogger(InstallerHandlerCacheManager.class); + + private final HelmJobHandler helmJobHandler; + private final ApiClient apiClient; + private final InstalledComponentsHandler installedComponentsHandler; + + InstallerHandlerCacheManager( + ApiClient apiClient, + HelmJobHandler helmJobHandler, + InstalledComponentsHandler installedComponentsHandler) { + this.apiClient = apiClient; + this.helmJobHandler = helmJobHandler; + this.installedComponentsHandler = installedComponentsHandler; + } + + @Async("threadPoolTaskExecutor") + public void resetCacheAfterJob(String jobName) { + try { + final V1Job job = helmJobHandler.getJobByName(jobName); + final CoreV1Api api = new CoreV1Api(apiClient); + helmJobHandler.waitForCompletedStatus(api, job); + + installedComponentsHandler.putInstalledComponentsCache(); + log.info("cache reset"); + } catch(Exception e) { + log.error("unable to reset cache", e); + } + } +} diff --git a/backend/components/installer/src/main/java/co/airy/core/api/components/installer/JobEmptyException.java b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/JobEmptyException.java new file mode 100644 index 0000000000..58c3d0fbe9 --- /dev/null +++ b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/JobEmptyException.java @@ -0,0 +1,4 @@ +package co.airy.core.api.components.installer; + +public class JobEmptyException extends Exception { +} diff --git a/backend/components/installer/src/main/java/co/airy/core/api/components/installer/NotCompletedException.java b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/NotCompletedException.java new file mode 100644 index 0000000000..afa3a4f688 --- /dev/null +++ b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/NotCompletedException.java @@ -0,0 +1,4 @@ +package co.airy.core.api.components.installer; + +public class NotCompletedException extends Exception { +} diff --git a/backend/components/installer/src/main/java/co/airy/core/api/components/installer/config/InstalledComponentsCache.java b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/config/InstalledComponentsCache.java new file mode 100644 index 0000000000..88a3c16d14 --- /dev/null +++ b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/config/InstalledComponentsCache.java @@ -0,0 +1,16 @@ +package co.airy.core.api.components.installer.config; + +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableCaching +public class InstalledComponentsCache { + + @Bean + public ConcurrentMapCacheManager cache() { + return new ConcurrentMapCacheManager("installedComponents"); + } +} diff --git a/backend/components/installer/src/main/java/co/airy/core/api/components/installer/config/K8sClientConfig.java b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/config/K8sClientConfig.java new file mode 100644 index 0000000000..9a8b23a773 --- /dev/null +++ b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/config/K8sClientConfig.java @@ -0,0 +1,45 @@ +package co.airy.core.api.components.installer.config; + +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.util.ClientBuilder; +import io.kubernetes.client.util.KubeConfig; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.FileReader; +import java.io.IOException; + +@Configuration +public class K8sClientConfig { + + @Bean + public ApiClient apiClient(@Value("${kubernetes.config}") String kubeConfigPath) throws IOException, ApiException { + ApiClient client; + if (kubeConfigPath.length() == 0) { + client = withInCluster(); + } else { + client = fromConfig(kubeConfigPath); + } + + io.kubernetes.client.openapi.Configuration.setDefaultApiClient(client); + + return client; + } + + private ApiClient fromConfig(String kubeConfigPath) throws IOException, ApiException { + final ApiClient client = ClientBuilder.kubeconfig(KubeConfig.loadKubeConfig(new FileReader(kubeConfigPath))).build(); + + + return client; + } + + private ApiClient withInCluster() throws IOException, ApiException { + final ApiClient client = ClientBuilder.cluster().build(); + + return client; + } + +} diff --git a/backend/components/installer/src/main/java/co/airy/core/api/components/installer/model/Component.java b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/model/Component.java new file mode 100644 index 0000000000..c16cee77f7 --- /dev/null +++ b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/model/Component.java @@ -0,0 +1,18 @@ +package co.airy.core.api.components.installer.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Component { + + private String name; + private String url; + private String username; + private String password; +} diff --git a/backend/components/installer/src/main/java/co/airy/core/api/components/installer/model/ComponentDetails.java b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/model/ComponentDetails.java new file mode 100644 index 0000000000..322280969d --- /dev/null +++ b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/model/ComponentDetails.java @@ -0,0 +1,46 @@ +package co.airy.core.api.components.installer.model; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; + + +public class ComponentDetails { + + private final Map props = new HashMap<>(); + + @JsonAnyGetter + public Map getProps() { + return props; + } + + @JsonAnySetter + public ComponentDetails add(String key, Object value) { + props.put(key, value); + + return this; + } + + public String getName() { + return (String) props.getOrDefault("name", ""); + } + + public String getRepository() { + return (String) props.getOrDefault("repository", ""); + } + + public boolean isInstalled() { + return (boolean) props.getOrDefault("installed", false); + } + + public static Map componentsDetailsListToMap(List componentsDetails) { + final Map cm = componentsDetails + .stream() + .collect(Collectors.toMap(ComponentDetails::getName, ComponentDetails::getProps)); + return Map.of("components", cm); + } +} diff --git a/backend/components/installer/src/main/java/co/airy/core/api/components/installer/model/Repository.java b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/model/Repository.java new file mode 100644 index 0000000000..f0a488f34c --- /dev/null +++ b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/model/Repository.java @@ -0,0 +1,18 @@ +package co.airy.core.api.components.installer.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Repository { + + private String name; + private String url; + private String username; + private String password; +} diff --git a/backend/components/installer/src/main/java/co/airy/core/api/components/installer/payload/InstallPayload.java b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/payload/InstallPayload.java new file mode 100644 index 0000000000..ece11a1b6f --- /dev/null +++ b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/payload/InstallPayload.java @@ -0,0 +1,14 @@ +package co.airy.core.api.components.installer.payload; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class InstallPayload { + private String name; +} diff --git a/backend/components/installer/src/main/java/co/airy/core/api/components/installer/payload/UninstallPayload.java b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/payload/UninstallPayload.java new file mode 100644 index 0000000000..6a1af2163f --- /dev/null +++ b/backend/components/installer/src/main/java/co/airy/core/api/components/installer/payload/UninstallPayload.java @@ -0,0 +1,15 @@ +package co.airy.core.api.components.installer.payload; + + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UninstallPayload { + private String name; +} diff --git a/backend/components/installer/src/main/resources/application.properties b/backend/components/installer/src/main/resources/application.properties new file mode 100644 index 0000000000..fe4b81e79b --- /dev/null +++ b/backend/components/installer/src/main/resources/application.properties @@ -0,0 +1,11 @@ +kubernetes.namespace=${KUBERNETES_NAMESPACE} +kubernetes.config=${KUBERNETES_CONFIG:} +catalog.uri=${CATALOG_URI} +catalog.directory=${CATALOG_DIRECTORY:/repo} + +thpool.core-pool-size=${REQUESTED_CPU} +thpool.max-pool-size=${LIMIT_CPU} +thpool.queue-capacity=${QUEUE_CAPACITY:0} + +retry.maxAttempts=${RETRY_MAX_ATTEMPTS:200} +retry.maxDelay=${RETRY_MAX_DELAY:500} diff --git a/backend/components/installer/src/test/java/co/airy/core/api/components/installer/CatalogHandlerTest.java b/backend/components/installer/src/test/java/co/airy/core/api/components/installer/CatalogHandlerTest.java new file mode 100644 index 0000000000..5368e84fdf --- /dev/null +++ b/backend/components/installer/src/test/java/co/airy/core/api/components/installer/CatalogHandlerTest.java @@ -0,0 +1,174 @@ +package co.airy.core.api.components.installer; + +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.support.AnnotationConfigContextLoader; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; + +import co.airy.core.api.components.installer.model.ComponentDetails; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; +import co.airy.kafka.test.KafkaTestHelper; +import co.airy.kafka.test.junit.SharedKafkaTestResource; +import co.airy.spring.core.AirySpringBootApplication; + +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiResponse; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1Job; +import io.kubernetes.client.openapi.models.V1ObjectMeta; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.doReturn; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ContextConfiguration(loader = AnnotationConfigContextLoader.class) +@SpringBootTest(classes = AirySpringBootApplication.class) +@TestPropertySource(value = "classpath:test.properties") +@ExtendWith(SpringExtension.class) +public class CatalogHandlerTest { + + @RegisterExtension + public static final SharedKafkaTestResource sharedKafkaTestResource = new SharedKafkaTestResource(); + private static KafkaTestHelper kafkaTestHelper; + + private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); + + @Mock + private ApplicationReadyEvent event; + + @MockBean + private ApiClient apiClient; + + @MockBean + private HelmJobHandler helmJobHandler; + + @Autowired + private CatalogHandler catalogHandler; + + @Captor + private ArgumentCaptor> cmd; + + @BeforeAll + static void beforeAll() throws Exception { + kafkaTestHelper = new KafkaTestHelper( + sharedKafkaTestResource, + applicationCommunicationMetadata); + kafkaTestHelper.beforeAll(); + } + + @AfterAll + static void afterAll() throws Exception { + kafkaTestHelper.afterAll(); + } + + + @Test + public void canOnApplicationEvent(@TempDir File tempDir) throws Exception { + callOnApplicationEvent(tempDir); + } + + @Test + public void canGetComponents(@TempDir File tempDir) throws Exception { + callOnApplicationEvent(tempDir); + + final V1Job job = new V1Job() + .metadata(new V1ObjectMeta().name("helm-installed").namespace("test-namespace")); + + //TODO: Move all of this to InstalledComponentsHandlerTest class & mock here + doReturn(job).when(helmJobHandler).launchHelmJob(eq(job.getMetadata().getName()), cmd.capture()); + doReturn("helm-installed-test").when(helmJobHandler).waitForCompletedStatus(isA(CoreV1Api.class), eq(job)); + + final MockedConstruction.MockInitializer fn = (mock, context) -> { + final ApiResponse response = new ApiResponse<>( + 200, + null, + getInstalledComponents()); + + doReturn(response).when(mock).readNamespacedPodLogWithHttpInfo( + "helm-installed-test", + job.getMetadata().getNamespace(), + "", + null, + null, + null, + null, + null, + null, + null, + null); + }; + + try (MockedConstruction apiMock = Mockito.mockConstruction(CoreV1Api.class, fn)) { + List listComponents = catalogHandler.listComponents(); + + assertThat(cmd.getValue().size(), equalTo(3)); + assertThat(cmd.getValue().get(2), equalTo("helm -n test-namespace list | awk '{print $1}' | tail -n +2")); + + //NOTE: We are just going to get some of the components in the list, and check his validity + ComponentDetails enterpriseSalesforceContactsIngestion = listComponents + .stream() + .filter((c -> c.getName().equals("enterprise-salesforce-contacts-ingestion"))) + .findAny() + .orElse(null); + assertThat(enterpriseSalesforceContactsIngestion, is(notNullValue())); + assertThat(enterpriseSalesforceContactsIngestion.isInstalled(), is(true)); + + ComponentDetails cognigyConnector = listComponents + .stream() + .filter((c -> c.getName().equals("cognigy-connector"))) + .findAny() + .orElse(null); + assertThat(cognigyConnector, is(notNullValue())); + assertThat(cognigyConnector.isInstalled(), is(false)); + } + + } + + private void callOnApplicationEvent(File tempDir) throws Exception { + ReflectionTestUtils.setField(catalogHandler, "repoFolder", tempDir); + catalogHandler.onApplicationEvent(event); + } + + private String getInstalledComponents() { + final List installedComponents = List.of( + "api-contacts", + "enterprise-salesforce-contacts-ingestion", + "enterprise-zendesk-connector", + "integration-webhook", + "rasa-connector", + "sources-chatplugin", + "sources-facebook", + "sources-google", + "sources-twilio", + "sources-whatsapp"); + + return String.join("\n", installedComponents); + } + +} diff --git a/backend/components/installer/src/test/java/co/airy/core/api/components/installer/InstallerHandlerTest.java b/backend/components/installer/src/test/java/co/airy/core/api/components/installer/InstallerHandlerTest.java new file mode 100644 index 0000000000..7760a4e21e --- /dev/null +++ b/backend/components/installer/src/test/java/co/airy/core/api/components/installer/InstallerHandlerTest.java @@ -0,0 +1,173 @@ +package co.airy.core.api.components.installer; + +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.support.AnnotationConfigContextLoader; +import org.springframework.util.StreamUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; + +import co.airy.core.api.components.installer.model.ComponentDetails; +import co.airy.kafka.schema.application.ApplicationCommunicationMetadata; +import co.airy.kafka.test.KafkaTestHelper; +import co.airy.kafka.test.junit.SharedKafkaTestResource; +import co.airy.spring.core.AirySpringBootApplication; + +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiResponse; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.kubernetes.client.openapi.models.V1ConfigMap; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.MockedConstruction; +import org.mockito.Mockito; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.IsEqual.equalTo; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ContextConfiguration(loader = AnnotationConfigContextLoader.class) +@SpringBootTest(classes = AirySpringBootApplication.class) +@TestPropertySource(value = "classpath:test.properties") +@ExtendWith(SpringExtension.class) +public class InstallerHandlerTest { + @RegisterExtension + public static final SharedKafkaTestResource sharedKafkaTestResource = new SharedKafkaTestResource(); + private static KafkaTestHelper kafkaTestHelper; + + private static final ApplicationCommunicationMetadata applicationCommunicationMetadata = new ApplicationCommunicationMetadata(); + + + @MockBean + private ApiClient apiClient; + + @MockBean + private HelmJobHandler helmJobHandler; + + @MockBean + private CatalogHandler catalogHandler; + + @Autowired + private InstallerHandler installerHandler; + + @Captor + private ArgumentCaptor> cmd; + + @BeforeAll + static void beforeAll() throws Exception { + kafkaTestHelper = new KafkaTestHelper( + sharedKafkaTestResource, + applicationCommunicationMetadata); + kafkaTestHelper.beforeAll(); + } + + @AfterAll + static void afterAll() throws Exception { + kafkaTestHelper.afterAll(); + } + + @Test + public void canInstallComponent() throws Exception { + final ComponentDetails sourcesChatplugin = new ComponentDetails() + .add("name", "sources-chatplugin") + .add("repository", "airy-core") + .add("installed", false); + + doReturn(sourcesChatplugin).when(catalogHandler).getComponentByName("sources-chatplugin"); + + final MockedConstruction.MockInitializer fn = (mock, context) -> { + final ApiResponse coreConfigResponse = new ApiResponse<>( + 200, + null, + new V1ConfigMap().data(getCoreConfig())); + + doReturn(coreConfigResponse).when(mock).readNamespacedConfigMapWithHttpInfo( + "core-config", + "test-namespace", + null); + + doReturn(null).when(helmJobHandler).launchHelmJob( + eq("helm-install-sources-chatplugin"), + cmd.capture()); + + final ApiResponse repositoriesResponse = new ApiResponse<>( + 200, + null, + new V1ConfigMap().data(getRepositoriesConfig())); + + doReturn(repositoriesResponse).when(mock).readNamespacedConfigMapWithHttpInfo( + "repositories", + "test-namespace", + null); + }; + + try (MockedConstruction apiMock = Mockito.mockConstruction(CoreV1Api.class, fn)) { + installerHandler.installComponent("sources-chatplugin"); + + assertThat(cmd.getValue().size(), equalTo(3)); + assertThat(cmd.getValue().get(2), equalTo(getHelmInstallCmd())); + } + } + + @Test + public void canUninstallComponent() throws Exception { + doReturn(null).when(helmJobHandler).launchHelmJob( + eq("helm-uninstall-enterprise-dialogflow-connector"), + cmd.capture()); + + installerHandler.uninstallComponent("enterprise-dialogflow-connector"); + + assertThat(cmd.getValue().size(), equalTo(3)); + assertThat(cmd.getValue().get(2), equalTo("helm -n test-namespace uninstall enterprise-dialogflow-connector")); + } + + private Map getCoreConfig() { + return Map.of( + "global.yaml", getGlobals(), + "APP_IMAGE_TAG", "0.50.0-alpha"); + } + + private String getGlobals() { + final List globals = List.of( + "global:", + " containerRegistry: ghcr.io/airyhq", + " busyboxImage: ghcr.io/airyhq/infrastructure/busybox:latest", + " host: test.airy.co", + " apiHost: https://test.airy.co", + " ingress:", + " letsencrypt: false"); + + return String.join("\n", globals); + } + + private Map getRepositoriesConfig() throws Exception { + final String repositoriesBlob = StreamUtils.copyToString( + getClass().getClassLoader().getResourceAsStream("repositories.json"), + StandardCharsets.UTF_8); + + return Map.of("repositories.json", repositoriesBlob); + } + + private String getHelmInstallCmd() throws Exception { + final String helmInstallCmd = StreamUtils.copyToString( + getClass().getClassLoader().getResourceAsStream("helm-install-cmd.txt"), + StandardCharsets.UTF_8); + + return helmInstallCmd.substring(0, helmInstallCmd.length() - 1); + } +} diff --git a/backend/components/installer/src/test/resources/helm-install-cmd.txt b/backend/components/installer/src/test/resources/helm-install-cmd.txt new file mode 100644 index 0000000000..bab37225da --- /dev/null +++ b/backend/components/installer/src/test/resources/helm-install-cmd.txt @@ -0,0 +1 @@ +helm -n test-namespace install sources-chatplugin https://helm.airy.co/charts/sources-chatplugin-0.50.0-alpha.tgz --values <(echo Z2xvYmFsOgogIGNvbnRhaW5lclJlZ2lzdHJ5OiBnaGNyLmlvL2FpcnlocQogIGJ1c3lib3hJbWFnZTogZ2hjci5pby9haXJ5aHEvaW5mcmFzdHJ1Y3R1cmUvYnVzeWJveDpsYXRlc3QKICBob3N0OiB0ZXN0LmFpcnkuY28KICBhcGlIb3N0OiBodHRwczovL3Rlc3QuYWlyeS5jbwogIGluZ3Jlc3M6CiAgICBsZXRzZW5jcnlwdDogZmFsc2U= | base64 -d) diff --git a/backend/components/installer/src/test/resources/repositories.json b/backend/components/installer/src/test/resources/repositories.json new file mode 100644 index 0000000000..5de226f788 --- /dev/null +++ b/backend/components/installer/src/test/resources/repositories.json @@ -0,0 +1,10 @@ +{ + "repositories": [ + { + "name": "airy-core", + "password": null, + "url": "https://helm.airy.co", + "username": null + } + ] +} diff --git a/backend/components/installer/src/test/resources/test.properties b/backend/components/installer/src/test/resources/test.properties new file mode 100644 index 0000000000..e8fc066f4f --- /dev/null +++ b/backend/components/installer/src/test/resources/test.properties @@ -0,0 +1,15 @@ +kafka.cleanup=true +kafka.commit-interval-ms=0 +kafka.cache.max.bytes=0 + +kubernetes.namespace=test-namespace +kubernetes.config=/path/to/kube/config +catalog.uri=https://github.com/airyhq/catalog.git +catalog.directory=/path/to/catalog/directory + +thpool.core-pool-size=1 +thpool.max-pool-size=1 +thpool.queue-capacity=1 + +retry.maxAttempts=1 +retry.maxDelay=1 diff --git a/backend/components/rasa/src/main/java/co/airy/core/rasa_connector/models/AiryResponse.java b/backend/components/rasa/src/main/java/co/airy/core/rasa_connector/models/AiryResponse.java deleted file mode 100644 index 1e53bda4c6..0000000000 --- a/backend/components/rasa/src/main/java/co/airy/core/rasa_connector/models/AiryResponse.java +++ /dev/null @@ -1,18 +0,0 @@ -package co.airy.core.rasa_connector.models; - -import com.fasterxml.jackson.databind.PropertyNamingStrategies; -import com.fasterxml.jackson.databind.annotation.JsonNaming; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) -public class AiryResponse { - AiryAttachment attachment; -} diff --git a/backend/components/unread-counter/src/main/java/co/airy/core/unread_counter/dto/UnreadCountState.java b/backend/components/unread-counter/src/main/java/co/airy/core/unread_counter/dto/UnreadCountState.java index 84abcaeea9..ba2bdb1f3e 100644 --- a/backend/components/unread-counter/src/main/java/co/airy/core/unread_counter/dto/UnreadCountState.java +++ b/backend/components/unread-counter/src/main/java/co/airy/core/unread_counter/dto/UnreadCountState.java @@ -8,7 +8,9 @@ import java.io.Serializable; import java.util.HashMap; +import java.util.Iterator; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; @Data @Builder(toBuilder = true) @@ -16,10 +18,9 @@ @AllArgsConstructor public class UnreadCountState implements Serializable { @Builder.Default - private Map unreadMessagesSentAt = new HashMap<>(); - // TODO this can grow without bounds so records need to expire + private Map unreadMessagesSentAt = new ConcurrentHashMap<>(); @Builder.Default - private Map messagesReadAt = new HashMap<>(); + private Map messagesReadAt = new ConcurrentHashMap<>(); @JsonIgnore public Integer getUnreadCount() { @@ -28,29 +29,27 @@ public Integer getUnreadCount() { // Moves unread messages before the read date to the map of read messages public void markMessagesReadAfter(long timestamp) { - this.cleanUpReadMap(timestamp); + cleanUpReadMap(timestamp); - for (Map.Entry entry : unreadMessagesSentAt.entrySet()) { + final Iterator> iterator = unreadMessagesSentAt.entrySet().iterator(); + while (iterator.hasNext()) { + final Map.Entry entry = iterator.next(); if (entry.getValue() < timestamp) { messagesReadAt.put(entry.getKey(), entry.getValue()); - unreadMessagesSentAt.remove(entry.getKey()); + iterator.remove(); } } } /* Records in messagesReadAt needs to expire for two reasons: - - Aggregation objects can exceed producer write buffer if they grow unboundedly + - Aggregation objects can exceed producer write buffer if they grow without bounds - We would keep writing user_read metadata for each message indefinitely Expiry should optimally happen once we know the metadata has been produced once. Ensuring this is not feasible with Kafka Streams so we opt for a conservative TTL (30s). */ private void cleanUpReadMap(long timestamp) { - for (Map.Entry entry : messagesReadAt.entrySet()) { - if (timestamp - entry.getValue() > 30_000) { - messagesReadAt.remove(entry.getKey()); - } - } + messagesReadAt.values().removeIf(readAt -> timestamp - readAt > 30_000); } } diff --git a/docs/docs/api/endpoints/components.md b/docs/docs/api/endpoints/components.md index d7b85a224e..5050feb985 100644 --- a/docs/docs/api/endpoints/components.md +++ b/docs/docs/api/endpoints/components.md @@ -119,14 +119,6 @@ List all the components, both installed and not installed. `POST /components.list` -**Sample request** - -```json5 -{ - "repository": "airy-core" // optional -} -``` - **Sample response** ```json5 @@ -156,11 +148,11 @@ Install a new component. ```json { - "name": "airy-core/sources-chatplugin" + "name": "sources-chatplugin" } ``` -**(201) Success Response Payload** +**(202) Success Response Payload** ## Uninstall @@ -172,11 +164,11 @@ Uninstall an existing component. ```json { - "name": "airy-enterprise/enterprise-dialogflow-connector" + "name": "enterprise-dialogflow-connector" } ``` -**(201) Success Response Payload** +**(202) Success Response Payload** ``` diff --git a/docs/docs/integrations/cognigy-ai.md b/docs/docs/integrations/cognigy-ai.md new file mode 100644 index 0000000000..411aced25e --- /dev/null +++ b/docs/docs/integrations/cognigy-ai.md @@ -0,0 +1,160 @@ +--- +title: Cognigy.AI +sidebar_label: Cognigy.AI +--- + +import useBaseUrl from '@docusaurus/useBaseUrl'; +import TLDR from "@site/src/components/TLDR"; +import SuccessBox from "@site/src/components/SuccessBox"; + + + +Cognigy.AI was developed in order to overcome most of the challenges in building conversational AIs. One of the unique aspects of the platform, is the bundling of - conversational AI related resources - in a user-friendly interface. + +- From the [Cognigy.AI documentation](https://docs.cognigy.com/ai/platform-overview/) + + + +:::tip What you will learn + +- The required steps to configure your Cognigy.AI agent +- How to connect Cognigy.AI to Airy Core + +::: + +## Step 1: Create a Cognigy.AI Live Agent + +The first step is to create an account on the [Cognigy.AI platform](https://www.cognigy.com/products/cognigy-ai). Next, create a Live Agent: this [guide](https://docs.cognigy.com/ai/resources/agents/agents/) from the Cognigy.AI documentation details the necessary steps. + +## Step 2: Create a Cognigy.AI Flow + + + +A conversation Flow describes the logic and sequence of a conversation. It defines what your Virtual Agent (VA) can do and how it handles the interaction. + +- From the [Cognigy.AI documentation](https://support.cognigy.com/hc/en-us/articles/360014524180-Design-a-Flow-and-add-a-Message#-3-tell-the-va-what-to-say-0-2) + + + +The second step is to create a Flow on the Cognigy.AI platform: this [guide](https://support.cognigy.com/hc/en-us/articles/360014524180-Design-a-Flow-and-add-a-Message#-3-tell-the-va-what-to-say-0-2) provides excellent +step-by-step instructions. + +Here is a screenshot of a simple Cognigy.AI Flow: + +Cognigy.AI Flow + +## Step 3: Create a Cognigy.AI REST Endpoint + + + +Endpoints are the connector between your user interface and the Cognigy Agent. + +The REST Endpoint lets you connect to a Cognigy Flow directly through a REST interface. + +- From the [Cognigy.AI documentation](https://docs.cognigy.com/ai/endpoints/overview/) + + + +On your agent dashboard on the Cognigy.AI platform, select `Endpoints` from the `Build` section of the sidebar menu. Click on the button `+ New Endpoint`: this will trigger a configuration menu to open. + +Type a name for this Endpoint, select the Flow you previously created in the `Flow` dropdown, select `REST` from the `Endpoint Type` list (scroll down to find the REST type) and click on the `Save` button to save this configuration. + +Note down your REST Endpoint URL, you will need it later on for the [step 6](/integrations/cognigy-ai#step-6-option-1-install-and-configure-cognigyai-to-your-airy-instance-via-api-request). + +## Step 4: Create an API key + +We now need to create an API key in order to use the [Cognigy.AI API](https://docs.cognigy.com/ai/developer-guides/using-api/#valid-api-key). + +Click on the avatar icon on the navigation menu and select `My Profile` in the dropdown. +On your profile page, scroll down to the API Keys section and click on the `+` icon to generate a new API Key. + +Note down your API key, you will need it in the next step. + +## Step 5: Find your Cognigy.AI user ID + +One way to get your Cognigy.AI user ID is to use the Cognigy.AI API sending a GET request to `/flows`. This request serves the data from the Flow(s) created. + +Navigate to the [Cognigy.AI Open API](https://api-trial.cognigy.ai/openapi): this interface enables to easily use the Cognigy.AI's RESTful web services. + +Paste the API Key you generated in the previous step in the Authentication's API Key input at the top of the page (below `Send api_key in query with the given value`). + +Then, scroll down to [GET /flows](https://api-trial.cognigy.ai/openapi#get-/v2.0/flows) and click on the `TRY` button. + +You should get a `200` Response status along with the Flow's data as the response body. The `createdBy` string is your user ID: note it down, you will need it in the next step. + + + +Congratulations! You are now ready to connect Cognigy.AI to your Airy Core instance 🎉 + + +
+ +Connecting Cognigy.AI to your Airy Core instance can be done through API request or +through the [Airy Control Center UI](/ui/control-center/introduction). + +We cover both options in this section: keep reading for connecting Cognigy.AI via +API request, scroll down to the next section for connecting Cognigy.AI via the [Airy Control Center UI](/ui/control-center/introduction). + +## Step 6 (option 1): Install and configure Cognigy.AI to your Airy instance via API request + +Send an API request to the [Components Install](/api/endpoints/components#install) endpoint to install Cognigy.AI on your Airy instance. + +The request body should be: + +```json +{ + "name": "airy-core/cognigy-connector" +} +``` + +Once the installation is successful, you can configure the component with the [Components Update](/api/endpoints/components#update) endpoint. + +Use the REST Endpoint URL and User ID you got in the previous steps ([step 3](/integrations/cognigy-ai#step-3-create-a-cognigyai-rest-endpoint) and [step 5](/integrations/cognigy-ai#step-5-find-your-cognigyai-user-id)) to compose the request body: + +```json +{ + "components": [ + { + "name": "cognigy-connector", + "enabled": true, + "data": { + "cognigyRestEndpointURL": "yourRestEndpointUrl", + "cognigyUserId": "yourUserID" + } + } + ] +} +``` + +The request is considered successful if `cognigy-connector` is returned in the list of configured components and the request status code is `200`. + +```json +{ + "components": { + "cognigy-connector": true + } +} +``` + +Cognigy.AI is now installed and configured. + +## Step 6 (option 2): Install and configure Cognigy.AI to your Airy instance via the Control Center UI + +On the [Airy Control Center UI](/ui/control-center/introduction), navigate to the [Catalog](/ui/control-center/catalog) and select Cognigy.AI. Click on the `Install` button. + +Once the installation is completed, navigate to the [Control Center's Connectors page](/ui/control-center/connectors) and select Cognigy.AI: this will open the connector's configuration page. + +Paste the `REST Endpoint URL` and `User ID` you got in the previous steps ([step 3](/integrations/cognigy-ai#step-3-create-a-cognigyai-rest-endpoint) and [step 5](/integrations/cognigy-ai#step-5-find-your-cognigyai-user-id)) +in the respective fields and save this configuration. + +Cognigy.AI is now installed and configured. + +## Step 7: Cognigy.AI's connection with Airy + +To test the connection, write a message to one of your channels: Airy Core will +forward it to your Cognigy.AI installation, which will respond according to the Cognigy.AI Flow +using the Airy Core API. + +The screenshot below was taken on a [Airy Live Chat Plugin](/sources/chatplugin/overview) channel from an Airy instance connected to Cognigy.AI. It shows an example where a message sent to Airy Core is automatically responded to according to a [Cognigy.AI Flow](/integrations/cognigy-ai#step-2-create-a-cognigyai-flow) where the Live Agent responds "Hi from Cognigy! 👋" to a contact's first message. + +
Cognigy.AI Airy connection
diff --git a/docs/sidebars.js b/docs/sidebars.js index a6f7d259c2..f4b60fc4d7 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -103,7 +103,11 @@ module.exports = { { '🛠️ Integrations': [ { - 'Conversational AI /NLP': ['integrations/rasa-assistant', 'integrations/rasa-suggested-replies'], + 'Conversational AI /NLP': [ + 'integrations/cognigy-ai', + 'integrations/rasa-assistant', + 'integrations/rasa-suggested-replies', + ], }, ], }, diff --git a/docs/static/img/integrations/cognigy/flow.png b/docs/static/img/integrations/cognigy/flow.png new file mode 100644 index 0000000000..8acc860064 Binary files /dev/null and b/docs/static/img/integrations/cognigy/flow.png differ diff --git a/docs/static/img/integrations/cognigy/messagingExample.png b/docs/static/img/integrations/cognigy/messagingExample.png new file mode 100644 index 0000000000..1d11daa1a1 Binary files /dev/null and b/docs/static/img/integrations/cognigy/messagingExample.png differ diff --git a/frontend/control-center/src/components/ChannelAvatar/index.tsx b/frontend/control-center/src/components/ChannelAvatar/index.tsx index 4da1eda0da..04173fc99c 100644 --- a/frontend/control-center/src/components/ChannelAvatar/index.tsx +++ b/frontend/control-center/src/components/ChannelAvatar/index.tsx @@ -10,7 +10,7 @@ import {ReactComponent as ViberAvatar} from 'assets/images/icons/viber.svg'; import {ReactComponent as ZendeskAvatar} from 'assets/images/icons/zendeskLogo.svg'; import {ReactComponent as DialogflowAvatar} from 'assets/images/icons/dialogflowLogo.svg'; import {ReactComponent as SalesforceAvatar} from 'assets/images/icons/salesforceLogo.svg'; -import {ReactComponent as CongnigyAvatar} from 'assets/images/icons/congnigyLogo.svg'; +import {ReactComponent as CognigyAvatar} from 'assets/images/icons/cognigyLogo.svg'; import {ReactComponent as RasaAvatar} from 'assets/images/icons/rasaLogo.svg'; import {ReactComponent as AmeliaAvatar} from 'assets/images/icons/ameliaLogo.svg'; import {ReactComponent as AmazonS3Avatar} from 'assets/images/icons/amazons3Logo.svg'; @@ -67,9 +67,9 @@ export const getChannelAvatar = (source: string) => { case Source.salesforce: case 'Salesforce': return ; - case Source.congnigy: - case 'Congnigy': - return ; + case Source.cognigy: + case 'Cognigy.AI': + return ; case Source.rasa: case 'Rasa': return ; diff --git a/frontend/control-center/src/components/TopBar/index.module.scss b/frontend/control-center/src/components/TopBar/index.module.scss index ad46613001..2c62c3ede5 100644 --- a/frontend/control-center/src/components/TopBar/index.module.scss +++ b/frontend/control-center/src/components/TopBar/index.module.scss @@ -269,5 +269,5 @@ } .animateOut { - animation: topbarDropdownOut 300ms ease; + animation: topbarDropdownOut 300ms forwards; } diff --git a/frontend/control-center/src/components/TopBar/index.tsx b/frontend/control-center/src/components/TopBar/index.tsx index 851674cc9f..4cc977b31f 100644 --- a/frontend/control-center/src/components/TopBar/index.tsx +++ b/frontend/control-center/src/components/TopBar/index.tsx @@ -39,7 +39,9 @@ const TopBar = (props: TopBarProps & ConnectedProps) => { const [isFaqDropdownOn, setFaqDropdownOn] = useState(false); const [isLanguageDropdownOn, setLanguageDropdownOn] = useState(false); const [darkTheme, setDarkTheme] = useState(localStorage.getItem('theme') === 'dark' ? true : false); - const [animationAction, setAnimationAction] = useState(false); + const [animationActionFaq, setAnimationActionFaq] = useState(false); + const [animationActionLanguage, setAnimationActionLanguage] = useState(false); + const [animationActionAccount, setAnimationActionAccount] = useState(false); const [chevronAnim, setChevronAnim] = useState(false); const [chevronLanguageAnim, setChevronLanguageAnim] = useState(false); const [currentLanguage, setCurrentLanguage] = useState(localStorage.getItem('language') || Language.english); @@ -52,16 +54,16 @@ const TopBar = (props: TopBarProps & ConnectedProps) => { const toggleAccountDropdown = useCallback(() => { setChevronAnim(!chevronAnim); - useAnimation(isAccountDropdownOn, setAccountDropdownOn, setAnimationAction, 300); + useAnimation(isAccountDropdownOn, setAccountDropdownOn, setAnimationActionAccount, 300); }, [setAccountDropdownOn, isAccountDropdownOn]); const toggleFaqDropdown = useCallback(() => { - useAnimation(isFaqDropdownOn, setFaqDropdownOn, setAnimationAction, 300); + useAnimation(isFaqDropdownOn, setFaqDropdownOn, setAnimationActionFaq, 300); }, [setFaqDropdownOn, isFaqDropdownOn]); const toggleLanguageDropdown = useCallback(() => { setChevronLanguageAnim(!chevronLanguageAnim); - useAnimation(isLanguageDropdownOn, setLanguageDropdownOn, setAnimationAction, 300); + useAnimation(isLanguageDropdownOn, setLanguageDropdownOn, setAnimationActionLanguage, 300); }, [setLanguageDropdownOn, isLanguageDropdownOn]); const toggleDarkTheme = () => { @@ -120,7 +122,7 @@ const TopBar = (props: TopBarProps & ConnectedProps) => {
?
-
+
{isFaqDropdownOn && (
@@ -165,7 +167,7 @@ const TopBar = (props: TopBarProps & ConnectedProps) => {
-
+
{isLanguageDropdownOn && (
@@ -220,7 +222,7 @@ const TopBar = (props: TopBarProps & ConnectedProps) => {
-
+
{isAccountDropdownOn && (
diff --git a/frontend/control-center/src/pages/Catalog/CatalogCard/index.tsx b/frontend/control-center/src/pages/Catalog/CatalogCard/index.tsx index 1f1e4bbc39..9ec1062f33 100644 --- a/frontend/control-center/src/pages/Catalog/CatalogCard/index.tsx +++ b/frontend/control-center/src/pages/Catalog/CatalogCard/index.tsx @@ -7,7 +7,7 @@ import {installComponent} from '../../../actions/catalog'; import {ComponentInfo, ConnectorPrice, NotificationModel} from 'model'; import {Button, NotificationComponent, SettingsModal, SmartButton} from 'components'; import {getChannelAvatar} from '../../../components/ChannelAvatar'; -import {getCatalogProductRouteForComponent, getConnectedRouteForComponent, removePrefix} from '../../../services'; +import {getCatalogProductRouteForComponent, getConnectedRouteForComponent} from '../../../services'; import {DescriptionComponent, getDescriptionSourceName} from '../../../components/Description'; import {ReactComponent as CheckmarkIcon} from 'assets/images/icons/checkmarkFilled.svg'; import styles from './index.module.scss'; @@ -34,9 +34,9 @@ export const availabilityFormatted = (availability: string) => availability.spli const CatalogCard = (props: CatalogCardProps) => { const {component, connectors, componentInfo, installComponent} = props; - const hasConnectedChannels = connectors[removePrefix(componentInfo?.name)].connectedChannels > 0; - const isConfigured = connectors[removePrefix(componentInfo?.name)].isConfigured; - const isChannel = connectors[removePrefix(componentInfo?.name)].isChannel; + const hasConnectedChannels = connectors[componentInfo?.name].connectedChannels > 0; + const isConfigured = connectors[componentInfo?.name].isConfigured; + const isChannel = connectors[componentInfo?.name].isChannel; const isInstalled = component[componentInfo?.name]?.installed; const [isModalVisible, setIsModalVisible] = useState(false); const [isNotifyMeModalVisible, setIsNotifyMeModalVisible] = useState(false); @@ -129,7 +129,7 @@ const CatalogCard = (props: CatalogCardProps) => { } buttonRef={installButtonCard} > - {t('open').toUpperCase()} + {t('openCatalog').toUpperCase()} ); } @@ -137,7 +137,7 @@ const CatalogCard = (props: CatalogCardProps) => { return ( ) => { }; const BodyContent = () => { + const bodyContentDescription = useRef(null); + + const parseContent = (str: string) => { + const parser = new DOMParser(); + const htmlDoc = parser.parseFromString(str, 'text/html'); + const content = htmlDoc.body; + const element = content.querySelector('div'); + bodyContentDescription.current.appendChild(element); + }; + + useEffect(() => { + if (bodyContentDescription && bodyContentDescription?.current) { + parseContent(componentInfo.description); + } + }, [bodyContentDescription]); + return ( -
+

{t('Description')}

-

{componentInfo.description}

); }; diff --git a/frontend/control-center/src/pages/Catalog/CatalogSearchBar/CatalogSearchBar.module.scss b/frontend/control-center/src/pages/Catalog/CatalogSearchBar/CatalogSearchBar.module.scss index 19037c7251..c35e9452c5 100644 --- a/frontend/control-center/src/pages/Catalog/CatalogSearchBar/CatalogSearchBar.module.scss +++ b/frontend/control-center/src/pages/Catalog/CatalogSearchBar/CatalogSearchBar.module.scss @@ -1,5 +1,6 @@ @import 'assets/scss/fonts.scss'; @import 'assets/scss/colors.scss'; +@import 'assets/scss/animations.scss'; .container { display: flex; @@ -11,7 +12,6 @@ align-items: center; justify-content: space-between; svg { - margin-left: 16px; color: var(--color-text-gray); &:hover { @@ -19,49 +19,31 @@ color: var(--color-airy-blue); } } + .filterIcon { + margin-left: 16px; + } + + .filterIconSelected { transition: background 0.5s ease; color: var(--color-background-white); - margin-left: 16px; background: var(--color-airy-logo-blue); border-radius: 24px; } } .searchField { - width: 200px; + width: 300px; background: var(--color-background-white); border: 1px solid var(--color-airy-blue); border-radius: 4px; + height: 32px; svg { color: black; } } -@keyframes animateFilterModalIn { - 0% { - transform: translateX(200px); - opacity: 0; - } - - 100% { - transform: translateX(0px); - opacity: 1; - } -} - -@keyframes animateFilterModalOut { - 0% { - transform: translateX(0px); - opacity: 1; - } - 100% { - transform: translateX(200px); - opacity: 0; - } -} - .filterModal { position: absolute; top: 170px; @@ -72,5 +54,13 @@ animation: animateFilterModalIn 500ms ease; } .filterModalAnimOut { - animation: animateFilterModalOut 500ms ease; + animation: animateFilterModalOut 500ms forwards; +} + +.animateIn { + animation: searchfieldAnimIn 300ms ease; +} + +.animateOut { + animation: searchfieldAnimOut 300ms forwards; } diff --git a/frontend/control-center/src/pages/Catalog/CatalogSearchBar/CatalogSearchBar.tsx b/frontend/control-center/src/pages/Catalog/CatalogSearchBar/CatalogSearchBar.tsx index 0ba4ebb0ad..4f1da8b8ea 100644 --- a/frontend/control-center/src/pages/Catalog/CatalogSearchBar/CatalogSearchBar.tsx +++ b/frontend/control-center/src/pages/Catalog/CatalogSearchBar/CatalogSearchBar.tsx @@ -20,50 +20,59 @@ export const CatalogSearchBar = (props: CatalogSearchBarProps) => { const [showSearchField, setShowingSearchField] = useState(false); const [showFilter, setShowFilter] = useState(false); const [animationAction, setAnimationAction] = useState(false); + const [animationActionSearchfield, setAnimationActionSearchfield] = useState(false); const toggleShowFilter = useCallback(() => { useAnimation(showFilter, setShowFilter, setAnimationAction, 500); }, [showFilter, setShowFilter]); + const showSearchFieldToggle = useCallback(() => { + useAnimation(showSearchField, setShowingSearchField, setAnimationActionSearchfield, 300); + setQuery(''); + props.setQuery(''); + }, [showSearchField, setShowingSearchField]); + useEffect(() => { props.setCurrentFilter(currentFilter); }, [currentFilter]); - const handleSearchClick = () => { - setShowingSearchField(true); - }; - return (
{showSearchField ? ( - { - setQuery(value), props.setQuery(value); - }} - /> + + { + setQuery(value), props.setQuery(value); + }} + /> + ) : ( - + )} -
+
{showFilter && ( )} diff --git a/frontend/control-center/src/pages/Catalog/CatalogSearchBar/FilterCatalogModal/FilterCatalogModal.tsx b/frontend/control-center/src/pages/Catalog/CatalogSearchBar/FilterCatalogModal/FilterCatalogModal.tsx index d5367340ea..06859c7631 100644 --- a/frontend/control-center/src/pages/Catalog/CatalogSearchBar/FilterCatalogModal/FilterCatalogModal.tsx +++ b/frontend/control-center/src/pages/Catalog/CatalogSearchBar/FilterCatalogModal/FilterCatalogModal.tsx @@ -15,6 +15,7 @@ type FilterCatalogModalProps = { currentFilter: FilterTypes; setCurrentFilter: Dispatch>; setShowFilter: Dispatch>; + animation: string; }; export const FilterCatalogModal = (props: FilterCatalogModalProps) => { @@ -35,7 +36,7 @@ export const FilterCatalogModal = (props: FilterCatalogModalProps) => { }; return ( -
+

{t('searchByType')}

props.setShowFilter(false)} /> diff --git a/frontend/control-center/src/pages/Catalog/index.module.scss b/frontend/control-center/src/pages/Catalog/index.module.scss index 278b13a7b0..1ac01061c5 100644 --- a/frontend/control-center/src/pages/Catalog/index.module.scss +++ b/frontend/control-center/src/pages/Catalog/index.module.scss @@ -36,10 +36,12 @@ flex-direction: column; h1 { @include font-l; + color: var(--color-text-contrast); font-weight: bold; } span { @include font-m; + color: var(--color-text-gray); } } diff --git a/frontend/control-center/src/pages/Catalog/index.tsx b/frontend/control-center/src/pages/Catalog/index.tsx index ca3016fc7e..40ed3a787e 100644 --- a/frontend/control-center/src/pages/Catalog/index.tsx +++ b/frontend/control-center/src/pages/Catalog/index.tsx @@ -51,7 +51,7 @@ const Catalog = (props: ConnectedProps) => { useLayoutEffect(() => { if (query && currentFilter === FilterTypes.all) { const filteredCatalogByName = [...catalogList].filter((component: ComponentInfo) => - component?.displayName?.toLowerCase().includes(query) + component?.displayName?.toLowerCase().startsWith(query.toLowerCase()) ); setOrderedCatalogList(filteredCatalogByName); } else { @@ -73,7 +73,7 @@ const Catalog = (props: ConnectedProps) => { query !== '' ? setOrderedCatalogList( sortedByInstalled.filter((component: ComponentInfo) => - component?.displayName?.toLowerCase().includes(query) + component?.displayName?.toLowerCase().startsWith(query.toLowerCase()) ) ) : setOrderedCatalogList(sortedByInstalled); @@ -82,7 +82,7 @@ const Catalog = (props: ConnectedProps) => { query !== '' ? setOrderedCatalogList( sortedByAccess.filter((component: ComponentInfo) => - component?.displayName?.toLowerCase().includes(query) + component?.displayName?.toLowerCase().startsWith(query.toLowerCase()) ) ) : setOrderedCatalogList(sortedByAccess); @@ -91,7 +91,7 @@ const Catalog = (props: ConnectedProps) => { query !== '' ? setOrderedCatalogList( sortedByUninstalled.filter((component: ComponentInfo) => - component?.displayName?.toLowerCase().includes(query) + component?.displayName?.toLowerCase().startsWith(query.toLowerCase()) ) ) : setOrderedCatalogList(sortedByUninstalled); diff --git a/frontend/control-center/src/pages/Connectors/ChannelCard/index.tsx b/frontend/control-center/src/pages/Connectors/ChannelCard/index.tsx index c42ab0239c..1f4aa933d3 100644 --- a/frontend/control-center/src/pages/Connectors/ChannelCard/index.tsx +++ b/frontend/control-center/src/pages/Connectors/ChannelCard/index.tsx @@ -7,7 +7,7 @@ import {useTranslation} from 'react-i18next'; import {ConfigStatusButton} from '../ConfigStatusButton'; import {ComponentStatus} from 'model'; import {cyAddChannelButton} from 'handles'; -import {getConnectedRouteForComponent, getNewChannelRouteForComponent} from '../../../services'; +import {getConnectedRouteForComponent} from '../../../services'; import {Connector} from 'model'; type ChannelCardProps = { @@ -20,25 +20,17 @@ export const ChannelCard = (props: ChannelCardProps) => { const {componentInfo, channelsToShow, componentStatus} = props; const {t} = useTranslation(); const navigate = useNavigate(); - const CONFIGURATION_ROUTE = getConnectedRouteForComponent( + const route = getConnectedRouteForComponent( componentInfo.source, componentInfo.isChannel, - componentStatus !== ComponentStatus.notConfigured - ); - - const CONFIGURATION_ROUTE_NEW = getNewChannelRouteForComponent( - componentInfo.source, - componentInfo?.isChannel, - componentStatus !== ComponentStatus.notConfigured + componentInfo.connectedChannels > 0, + componentInfo.isConfigured ); return (
{ - event.stopPropagation(), - channelsToShow > 0 - ? navigate(CONFIGURATION_ROUTE, {state: {from: 'connected'}}) - : navigate(CONFIGURATION_ROUTE_NEW, {state: {from: 'new'}}); + event.stopPropagation(), navigate(route); }} className={styles.container} data-cy={cyAddChannelButton} @@ -49,9 +41,7 @@ export const ChannelCard = (props: ChannelCardProps) => { {componentInfo.displayName}
- {componentStatus && ( - - )} + {componentStatus && } {channelsToShow} {channelsToShow === 1 ? t('channel') : t('channels')} diff --git a/frontend/control-center/src/pages/Connectors/ConfigureConnector/SetConfigInputs/SetConfigInputs.module.scss b/frontend/control-center/src/pages/Connectors/ConfigureConnector/SetConfigInputs/SetConfigInputs.module.scss index 8dd7cf3997..6edffe1583 100644 --- a/frontend/control-center/src/pages/Connectors/ConfigureConnector/SetConfigInputs/SetConfigInputs.module.scss +++ b/frontend/control-center/src/pages/Connectors/ConfigureConnector/SetConfigInputs/SetConfigInputs.module.scss @@ -1,7 +1,16 @@ +.inputsContainer { + display: flex; + flex-direction: column; + flex-wrap: wrap; + margin-bottom: 32px; +} + .addColumn { display: flex; } .input { + width: 400px; height: 80px; + margin-right: 24px; } diff --git a/frontend/control-center/src/pages/Connectors/ConfigureConnector/SetConfigInputs/SetConfigInputs.tsx b/frontend/control-center/src/pages/Connectors/ConfigureConnector/SetConfigInputs/SetConfigInputs.tsx index 0ac55c7bee..26da3ec22a 100644 --- a/frontend/control-center/src/pages/Connectors/ConfigureConnector/SetConfigInputs/SetConfigInputs.tsx +++ b/frontend/control-center/src/pages/Connectors/ConfigureConnector/SetConfigInputs/SetConfigInputs.tsx @@ -23,22 +23,37 @@ export const SetConfigInputs = (props: SetConfigInputsProps) => { source !== Source.chatPlugin && Object.entries(configurationValues).forEach((item, index) => { - const key = item[0]; + let key = item[0]; const keyTyped = key as keyof typeof input; const valueTyped = input[keyTyped] || ''; const toolTip = key.charAt(0).toUpperCase() + key.slice(1); - const replacedKey = key.replace(/([A-Z])/g, ' $1'); + + if (key.includes('URL')) { + key = key.replace('URL', 'Url'); + } else if (key.includes('API')) { + key = key.replace('API', 'Api'); + } + + let replacedKey = key.replace(/([A-Z])/g, ' $1'); + + if (replacedKey.includes('Url')) { + replacedKey = replacedKey.replace('Url', 'URL'); + } else if (key.includes('Api')) { + replacedKey = replacedKey.replace('Api', 'API'); + } + const label = replacedKey.charAt(0).toUpperCase() + replacedKey.slice(1); const placeholder = `${replacedKey.charAt(0).toUpperCase() + replacedKey.slice(1)}`; const capitalSource = source?.charAt(0).toUpperCase() + source?.slice(1).replace('.', ''); - const isUrl = label.includes('Url'); + const isUrl = label.includes('URL'); const hasSteps = source === Source.dialogflow && replacedKey.includes('Level'); const stepPlaceholder = `0.1 ${t('to')} 0.9`; + const sensitive = label.includes('Token') || label.includes('Password') || label.includes('Secret'); inputArr.push(
{ ); }); - return <>{inputArr}; + return ( +
4 ? {height: '42vh'} : {}}> + {inputArr} +
+ ); }; diff --git a/frontend/control-center/src/pages/Connectors/ConfigureConnector/index.module.scss b/frontend/control-center/src/pages/Connectors/ConfigureConnector/index.module.scss index b87f14cde2..6636b8eca0 100644 --- a/frontend/control-center/src/pages/Connectors/ConfigureConnector/index.module.scss +++ b/frontend/control-center/src/pages/Connectors/ConfigureConnector/index.module.scss @@ -19,11 +19,3 @@ margin-top: 16px; } } - -.formWrapper { - align-self: center; -} - -.settings { - width: 29rem; -} diff --git a/frontend/control-center/src/pages/Connectors/ConfigureConnector/index.tsx b/frontend/control-center/src/pages/Connectors/ConfigureConnector/index.tsx index 4345731464..0ef2e14e12 100644 --- a/frontend/control-center/src/pages/Connectors/ConfigureConnector/index.tsx +++ b/frontend/control-center/src/pages/Connectors/ConfigureConnector/index.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, {Dispatch, SetStateAction, useEffect, useState} from 'react'; import RestartPopUp from '../RestartPopUp'; import {SmartButton} from 'components'; import {cyConnectorAddButton} from 'handles'; @@ -7,9 +7,9 @@ import styles from './index.module.scss'; import {connect, ConnectedProps} from 'react-redux'; import {StateModel} from '../../../reducers'; import {SetConfigInputs} from './SetConfigInputs/SetConfigInputs'; -import {removePrefix} from '../../../services'; import {updateConnectorConfiguration} from '../../../actions'; import {UpdateComponentConfigurationRequestPayload} from 'httpclient/src'; +import {NotificationModel} from 'model'; const mapStateToProps = (state: StateModel) => { return { @@ -29,15 +29,23 @@ type ConfigureConnectorProps = { isConfigured: boolean; configValues: {[key: string]: string}; source: string; + setNotification: Dispatch>; } & ConnectedProps; const ConfigureConnector = (props: ConfigureConnectorProps) => { const {componentName, isConfigured, configValues, isEnabled, updateConnectorConfiguration, source} = props; const {t} = useTranslation(); - const displayName = componentName && removePrefix(componentName); const [config, setConfig] = useState(configValues); const [isPending, setIsPending] = useState(false); const [isUpdateModalVisible, setIsUpdateModalVisible] = useState(false); + const [configurationButtonDisabled, setConfigurationButtonDisabled] = useState(true); + + useEffect(() => { + Object.keys(config).length > 0 && + Object.values(config).map(item => { + item !== 'string' && item !== '' ? setConfigurationButtonDisabled(false) : setConfigurationButtonDisabled(true); + }); + }, [config]); const updateConfig = (event: React.FormEvent) => { event.preventDefault(); @@ -58,7 +66,7 @@ const ConfigureConnector = (props: ConfigureConnectorProps) => { const payload: UpdateComponentConfigurationRequestPayload = { components: [ { - name: componentName && removePrefix(componentName), + name: componentName, enabled: true, data: configurationValues, }, @@ -66,8 +74,20 @@ const ConfigureConnector = (props: ConfigureConnectorProps) => { }; updateConnectorConfiguration(payload) + .then(() => { + props.setNotification({ + show: true, + text: isConfigured ? t('updateSuccessfulConfiguration') : t('successfulConfiguration'), + successful: true, + }); + }) .catch((error: Error) => { console.error(error); + props.setNotification({ + show: true, + text: isConfigured ? t('updateFailedConfiguration') : t('failedConfiguration'), + successful: false, + }); }) .finally(() => { setIsPending(false); @@ -76,29 +96,23 @@ const ConfigureConnector = (props: ConfigureConnectorProps) => { return (
-
-
-
- - updateConfig(e)} - dataCy={cyConnectorAddButton} - /> -
-
-
+ + updateConfig(e)} + dataCy={cyConnectorAddButton} + /> {isUpdateModalVisible && ( { {isPhoneNumberSource() &&
{channel.sourceChannelId}
}
- - + hoverElementWidth={16} + hoverElementHeight={16} + tooltipContent={t('editChannel')} + direction="left" + /> + } + hoverElementWidth={18} + hoverElementHeight={18} + tooltipContent={t('disconnectChannel')} + direction="topLeft" + />
diff --git a/frontend/control-center/src/pages/Connectors/ConnectedChannelsList/index.module.scss b/frontend/control-center/src/pages/Connectors/ConnectedChannelsList/index.module.scss index 1718354a1b..7eb539c013 100644 --- a/frontend/control-center/src/pages/Connectors/ConnectedChannelsList/index.module.scss +++ b/frontend/control-center/src/pages/Connectors/ConnectedChannelsList/index.module.scss @@ -157,16 +157,24 @@ } } +.searchFieldContainer { + display: flex; + justify-content: flex-end; + height: 32px; + margin-bottom: 16px; +} + .searchFieldButtons { display: flex; align-items: center; } .searchField { - min-width: 300px; - animation: searchFieldAnimation 3000ms ease; + width: 300px; height: 32px; - border-radius: 32px; + background: var(--color-background-white); + border: 1px solid var(--color-airy-blue); + border-radius: 4px; } .connectChannelModalHeader { @@ -224,5 +232,5 @@ } .animateOut { - animation: searchfieldAnimOut 300ms ease; + animation: searchfieldAnimOut 300ms forwards; } diff --git a/frontend/control-center/src/pages/Connectors/ConnectedChannelsList/index.tsx b/frontend/control-center/src/pages/Connectors/ConnectedChannelsList/index.tsx index 9f419a2d44..249dff2656 100644 --- a/frontend/control-center/src/pages/Connectors/ConnectedChannelsList/index.tsx +++ b/frontend/control-center/src/pages/Connectors/ConnectedChannelsList/index.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {connect, ConnectedProps, useSelector} from 'react-redux'; import {listChannels} from '../../../actions/channel'; import {useParams} from 'react-router-dom'; @@ -101,29 +101,26 @@ const ConnectedChannelsList = (props: ConnectedChannelsListProps) => { } }; - const showSearchFieldToggle = () => { + const showSearchFieldToggle = useCallback(() => { useAnimation(showingSearchField, setShowingSearchField, setAnimationAction, 300); setSearchText(''); - }; + }, [showingSearchField, setShowingSearchField]); return (
-
+
-
-
- {showingSearchField && ( - setSearchText(value)} - autoFocus={true} - style={{height: '32px', borderRadius: '32px'}} - resetClicked={() => setSearchText('')} - /> - )} -
-
+ {showingSearchField && ( + setSearchText(value)} + autoFocus={true} + resetClicked={() => setSearchText('')} + /> + )}
+ /> ); }; diff --git a/frontend/control-center/src/pages/Connectors/Providers/Facebook/Messenger/FacebookConnect.tsx b/frontend/control-center/src/pages/Connectors/Providers/Facebook/Messenger/FacebookConnect.tsx index 133b68d03e..e232b38ded 100644 --- a/frontend/control-center/src/pages/Connectors/Providers/Facebook/Messenger/FacebookConnect.tsx +++ b/frontend/control-center/src/pages/Connectors/Providers/Facebook/Messenger/FacebookConnect.tsx @@ -107,6 +107,7 @@ const FacebookConnect = (props: FacebookConnectProps) => { fontClass="font-base" /> { type="submit" styleVariant="normal" disabled={buttonStatus() || isPending} - onClick={() => connectNewChannel()} + onClick={connectNewChannel} /> {modal && {connectError}}
diff --git a/frontend/control-center/src/pages/Connectors/Providers/WhatsappBusinessCloud/WhatsappConnect.tsx b/frontend/control-center/src/pages/Connectors/Providers/WhatsappBusinessCloud/WhatsappConnect.tsx index 017d57c287..82ed33b94b 100644 --- a/frontend/control-center/src/pages/Connectors/Providers/WhatsappBusinessCloud/WhatsappConnect.tsx +++ b/frontend/control-center/src/pages/Connectors/Providers/WhatsappBusinessCloud/WhatsappConnect.tsx @@ -104,6 +104,7 @@ const WhatsappConnect = (props: WhatsappConnectProps) => { fontClass="font-base" /> { @@ -35,7 +34,7 @@ export const getMergedConnectors = createSelector( const isInstalled = catalog[1].installed; const price = catalog[1].price; const source = catalog[1].source; - const name = removePrefix(catalog[0]); + const name = catalog[0]; const displayName = catalog[1].displayName; structuredCatalog = { diff --git a/frontend/control-center/src/services/format.ts b/frontend/control-center/src/services/format.ts index d870486f38..242765ce52 100644 --- a/frontend/control-center/src/services/format.ts +++ b/frontend/control-center/src/services/format.ts @@ -6,8 +6,6 @@ const capitalize = (str: string) => { return str.charAt(0).toUpperCase() + str.slice(1); }; -export const removePrefix = (name: string) => name.split('/').pop(); - export const formatComponentNameToConfigKey = (componentName: string) => { if (!componentName) return null; return componentName.split('/')[1]; diff --git a/frontend/inbox/src/components/TopBar/index.module.scss b/frontend/inbox/src/components/TopBar/index.module.scss index 51dc714d84..103996d423 100644 --- a/frontend/inbox/src/components/TopBar/index.module.scss +++ b/frontend/inbox/src/components/TopBar/index.module.scss @@ -263,5 +263,5 @@ } .animateOut { - animation: topbarDropdownOut 300ms ease; + animation: topbarDropdownOut 300ms forwards; } diff --git a/frontend/inbox/src/components/TopBar/index.tsx b/frontend/inbox/src/components/TopBar/index.tsx index 7b5fd3c48c..50d34cfd4e 100644 --- a/frontend/inbox/src/components/TopBar/index.tsx +++ b/frontend/inbox/src/components/TopBar/index.tsx @@ -40,7 +40,9 @@ const TopBar = (props: TopBarProps & ConnectedProps) => { const [isFaqDropdownOn, setFaqDropdownOn] = useState(false); const [isLanguageDropdownOn, setLanguageDropdownOn] = useState(false); const [darkTheme, setDarkTheme] = useState(localStorage.getItem('theme') === 'dark' ? true : false); - const [animationAction, setAnimationAction] = useState(false); + const [animationActionFaq, setAnimationActionFaq] = useState(false); + const [animationActionLanguage, setAnimationActionLanguage] = useState(false); + const [animationActionAccount, setAnimationActionAccount] = useState(false); const [chevronAnim, setChevronAnim] = useState(false); const [chevronLanguageAnim, setChevronLanguageAnim] = useState(false); const [currentLanguage, setCurrentLanguage] = useState(localStorage.getItem('language') || Language.english); @@ -52,16 +54,16 @@ const TopBar = (props: TopBarProps & ConnectedProps) => { const toggleAccountDropdown = useCallback(() => { setChevronAnim(!chevronAnim); - useAnimation(isAccountDropdownOn, setAccountDropdownOn, setAnimationAction, 300); + useAnimation(isAccountDropdownOn, setAccountDropdownOn, setAnimationActionAccount, 300); }, [setAccountDropdownOn, isAccountDropdownOn]); const toggleFaqDropdown = useCallback(() => { - useAnimation(isFaqDropdownOn, setFaqDropdownOn, setAnimationAction, 300); + useAnimation(isFaqDropdownOn, setFaqDropdownOn, setAnimationActionFaq, 300); }, [setFaqDropdownOn, isFaqDropdownOn]); const toggleLanguageDropdown = useCallback(() => { setChevronLanguageAnim(!chevronLanguageAnim); - useAnimation(isLanguageDropdownOn, setLanguageDropdownOn, setAnimationAction, 300); + useAnimation(isLanguageDropdownOn, setLanguageDropdownOn, setAnimationActionLanguage, 300); }, [setLanguageDropdownOn, isLanguageDropdownOn]); const toggleDarkTheme = () => { @@ -124,7 +126,7 @@ const TopBar = (props: TopBarProps & ConnectedProps) => { ?
-
+
{isFaqDropdownOn && (
@@ -169,7 +171,7 @@ const TopBar = (props: TopBarProps & ConnectedProps) => {
-
+
{isLanguageDropdownOn && (
@@ -223,7 +225,7 @@ const TopBar = (props: TopBarProps & ConnectedProps) => {
-
+
{isAccountDropdownOn && (
diff --git a/frontend/inbox/src/pages/Contacts/ContactInformation/index.module.scss b/frontend/inbox/src/pages/Contacts/ContactInformation/index.module.scss index eb1b32662b..3898de445a 100644 --- a/frontend/inbox/src/pages/Contacts/ContactInformation/index.module.scss +++ b/frontend/inbox/src/pages/Contacts/ContactInformation/index.module.scss @@ -170,7 +170,7 @@ } .fadeOutAnimation { - animation: fadeOutTranslateXLeft 500ms ease; + animation: fadeOutTranslateXLeft 500ms forwards; } .editIcon { diff --git a/frontend/inbox/src/pages/Contacts/ContactInformation/index.tsx b/frontend/inbox/src/pages/Contacts/ContactInformation/index.tsx index 577fdfa0be..47192d2d94 100644 --- a/frontend/inbox/src/pages/Contacts/ContactInformation/index.tsx +++ b/frontend/inbox/src/pages/Contacts/ContactInformation/index.tsx @@ -55,7 +55,7 @@ const ContactInformation = (props: ContactInformationProps) => { } = props; const {t} = useTranslation(); const [showEditDisplayName, setShowEditDisplayName] = useState(false); - const [displayName, setDisplayName] = useState(contact?.displayName); + const [displayName, setDisplayName] = useState(contact?.displayName || ''); const [fade, setFade] = useState(true); const [isEditing, setIsEditing] = useState(false); const [editingCanceled, setEditingCanceled] = useState(false); @@ -91,7 +91,6 @@ const ContactInformation = (props: ContactInformationProps) => { }; const toggleEditDisplayName = useCallback(() => { - setDisplayName(contact?.displayName); useAnimation(showEditDisplayName, setShowEditDisplayName, setFade, 400); }, [showEditDisplayName, setShowEditDisplayName]); diff --git a/frontend/inbox/src/pages/Inbox/ConversationListHeader/index.module.scss b/frontend/inbox/src/pages/Inbox/ConversationListHeader/index.module.scss index ac105edb7d..4f9922a70d 100644 --- a/frontend/inbox/src/pages/Inbox/ConversationListHeader/index.module.scss +++ b/frontend/inbox/src/pages/Inbox/ConversationListHeader/index.module.scss @@ -1,3 +1,5 @@ +@import 'assets/scss/animations.scss'; + .containerSearch { display: flex; width: 100%; @@ -27,15 +29,6 @@ animation-fill-mode: forwards; } -@keyframes searchFieldAnimation { - 0% { - width: 0%; - } - 100% { - width: 100%; - } -} - .backIcon { cursor: pointer; height: 13px; @@ -53,10 +46,18 @@ padding-right: 14px; } -.searchFieldWidth { +.searchField { width: 100%; } +.animateIn { + animation: searchfieldAnimIn 400ms ease; +} + +.animateOut { + animation: searchfieldAnimOut 400ms forwards; +} + .searchBox { color: black; background: var(--color-background-white); @@ -106,6 +107,7 @@ height: 24px; width: 24px; margin-left: 12px; + color: var(--color-text-gray); &:hover { svg { path { diff --git a/frontend/inbox/src/pages/Inbox/ConversationListHeader/index.tsx b/frontend/inbox/src/pages/Inbox/ConversationListHeader/index.tsx index a0fdd9c608..b971bb98da 100644 --- a/frontend/inbox/src/pages/Inbox/ConversationListHeader/index.tsx +++ b/frontend/inbox/src/pages/Inbox/ConversationListHeader/index.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import {connect, ConnectedProps} from 'react-redux'; import {SearchField} from 'components'; @@ -16,6 +16,7 @@ import {cySearchButton, cySearchField, cySearchFieldBackButton} from 'handles'; import Popup from '../QuickFilter/Popup'; import {formatConversationCount} from '../../../services/format/numbers'; import {useTranslation} from 'react-i18next'; +import {useAnimation} from 'render'; const mapDispatchToProps = { setSearch, @@ -37,6 +38,7 @@ const ConversationListHeader = (props: ConversationListHeaderProps) => { const {t} = useTranslation(); const [isShowingSearchInput, setIsShowingSearchInput] = useState(false); + const [animationAction, setAnimationAction] = useState(false); const [searchText, setSearchText] = useState(''); const [isFilterOpen, setIsFilterOpen] = useState(false); @@ -44,18 +46,11 @@ const ConversationListHeader = (props: ConversationListHeaderProps) => { resetFilteredConversationAction(); }, [resetFilteredConversationAction]); - const onClickSearch = () => { - if (isShowingSearchInput) { - setSearch(currentFilter, null); - } - setIsShowingSearchInput(!isShowingSearchInput); - }; - - const onClickBack = () => { - setSearch(currentFilter, null); - setIsShowingSearchInput(!isShowingSearchInput); + const showSearchFieldToggle = useCallback(() => { + useAnimation(isShowingSearchInput, setIsShowingSearchInput, setAnimationAction, 400); setSearchText(''); - }; + setSearch(currentFilter, null); + }, [isShowingSearchInput, setIsShowingSearchInput]); const handleSearch = (value: string) => { setSearch(currentFilter, value); @@ -86,25 +81,30 @@ const ConversationListHeader = (props: ConversationListHeaderProps) => { const renderSearchInput = isShowingSearchInput ? (
- -
- -
+ setSearchText('')} + autoFocus={true} + dataCy={cySearchField} + />
) : (
-
)} - + ); }; diff --git a/infrastructure/helm-chart/templates/components/api-components-installer/configmap.yaml b/infrastructure/helm-chart/templates/components/api-components-installer/configmap.yaml new file mode 100644 index 0000000000..176fbcbdef --- /dev/null +++ b/infrastructure/helm-chart/templates/components/api-components-installer/configmap.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: "{{ .Values.components.api.components.installer.name }}" + labels: + core.airy.co/managed: "true" + core.airy.co/mandatory: "{{ .Values.components.api.components.installer.mandatory }}" + core.airy.co/component: "{{ .Values.components.api.components.installer.name }}" + core.airy.co/enterprise: "false" + annotations: + core.airy.co/enabled: "{{ .Values.components.api.components.installer.enabled }}" diff --git a/infrastructure/helm-chart/templates/components/api-components-installer/deployment.yaml b/infrastructure/helm-chart/templates/components/api-components-installer/deployment.yaml new file mode 100644 index 0000000000..0550a1f9d7 --- /dev/null +++ b/infrastructure/helm-chart/templates/components/api-components-installer/deployment.yaml @@ -0,0 +1,88 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.components.api.components.installer.name }} + labels: + app: {{ .Values.components.api.components.installer.name }} + core.airy.co/managed: "true" + core.airy.co/mandatory: "{{ .Values.components.api.components.installer.mandatory }}" + core.airy.co/component: {{ .Values.components.api.components.installer.name }} +spec: + replicas: {{ if .Values.components.api.components.installer.enabled }} 1 {{ else }} 0 {{ end }} + selector: + matchLabels: + app: {{ .Values.components.api.components.installer.name }} + strategy: + rollingUpdate: + maxSurge: 1 + maxUnavailable: 1 + type: RollingUpdate + template: + metadata: + labels: + app: {{ .Values.components.api.components.installer.name }} + spec: + containers: + - name: app + image: "{{ .Values.global.containerRegistry}}/{{ .Values.components.api.components.installer.image }}:{{ default .Chart.Version }}" + imagePullPolicy: Always + args: + - "-Djdk.tls.client.protocols=TLSv1.2" + - "-jar" + - "app_springboot.jar" + - "-XshowSettings:vm" + - "-XX:MaxRAMPercentage=70" + - "-XX:-UseCompressedOops" + - "-Dsun.net.inetaddr.ttl=0" + envFrom: + - configMapRef: + name: security + - configMapRef: + name: kafka-config + env: + - name: SERVICE_NAME + value: {{ .Values.components.api.components.installer.name }} + - name: KUBERNETES_NAMESPACE + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: metadata.namespace + - name: CATALOG_URI + value: "https://github.com/airyhq/catalog.git" + - name: CATALOG_DIRECTORY + value: "/repo" + - name: POD_NAMESPACE + valueFrom: + fieldRef: + apiVersion: v1 + fieldPath: metadata.namespace + - name: REQUESTED_CPU + valueFrom: + resourceFieldRef: + containerName: app + resource: requests.cpu + - name: LIMIT_CPU + valueFrom: + resourceFieldRef: + containerName: app + resource: limits.cpu + - name: LIMIT_MEMORY + valueFrom: + resourceFieldRef: + containerName: app + resource: limits.memory + livenessProbe: + httpGet: + path: /actuator/health + port: 8080 + httpHeaders: + - name: Health-Check + value: health-check + initialDelaySeconds: 60 + periodSeconds: 10 + failureThreshold: 3 + serviceAccountName: {{ .Values.serviceAccount }} + volumes: + - name: {{ .Values.components.api.components.installer.name }} + configMap: + name: {{ .Values.components.api.components.installer.name }} diff --git a/infrastructure/helm-chart/templates/components/api-components-installer/service.yaml b/infrastructure/helm-chart/templates/components/api-components-installer/service.yaml new file mode 100644 index 0000000000..3a1c233296 --- /dev/null +++ b/infrastructure/helm-chart/templates/components/api-components-installer/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + labels: + app: {{ .Values.components.api.components.installer.name }} + name: {{ .Values.components.api.components.installer.name }} +spec: + ports: + - port: 80 + protocol: TCP + targetPort: 8080 + selector: + app: {{ .Values.components.api.components.installer.name }} + type: ClusterIP diff --git a/infrastructure/helm-chart/templates/components/ingress.yaml b/infrastructure/helm-chart/templates/components/ingress.yaml index 2b5049d884..8043bcbef1 100644 --- a/infrastructure/helm-chart/templates/components/ingress.yaml +++ b/infrastructure/helm-chart/templates/components/ingress.yaml @@ -239,6 +239,27 @@ spec: name: sources-chatplugin port: number: 80 + - path: /components.list + pathType: Prefix + backend: + service: + name: api-components-installer + port: + number: 80 + - path: /components.install + pathType: Prefix + backend: + service: + name: api-components-installer + port: + number: 80 + - path: /components.uninstall + pathType: Prefix + backend: + service: + name: api-components-installer + port: + number: 80 {{- if .Values.global.host }} host: {{ .Values.global.host }} {{- if .Values.global.ingress.letsencrypt }} diff --git a/infrastructure/helm-chart/templates/controller/ingress.yaml b/infrastructure/helm-chart/templates/controller/ingress.yaml index ada48966b6..d4e134d938 100644 --- a/infrastructure/helm-chart/templates/controller/ingress.yaml +++ b/infrastructure/helm-chart/templates/controller/ingress.yaml @@ -14,7 +14,21 @@ spec: rules: - http: paths: - - path: /components. + - path: /components.get + pathType: Prefix + backend: + service: + name: airy-controller + port: + number: 80 + - path: /components.update + pathType: Prefix + backend: + service: + name: airy-controller + port: + number: 80 + - path: /components.delete pathType: Prefix backend: service: diff --git a/infrastructure/helm-chart/values.yaml b/infrastructure/helm-chart/values.yaml index b1f62c7d23..fe9335bacd 100644 --- a/infrastructure/helm-chart/values.yaml +++ b/infrastructure/helm-chart/values.yaml @@ -33,6 +33,13 @@ repositories: password: components: api: + components: + installer: + name: api-components-installer + image: api/components/installer + mandatory: true + enabled: true + resources: {} admin: name: api-admin image: api/admin diff --git a/infrastructure/terraform/modules/aws_eks/main.tf b/infrastructure/terraform/modules/aws_eks/main.tf index e4774005a0..38df16325d 100644 --- a/infrastructure/terraform/modules/aws_eks/main.tf +++ b/infrastructure/terraform/modules/aws_eks/main.tf @@ -30,6 +30,26 @@ module "vpc" { tags = merge(var.tags, { Terraform = "true" }) } +data "aws_subnets" "private" { + count = var.vpc_id == "" ? 1 : 0 + filter { + name = "vpc-id" + values = [var.vpc_id] + } + + tags = { + Tier = "Private" + } +} + +data "aws_subnets" "public" { + count = var.vpc_id == "" ? 1 : 0 + filter { + name = "vpc-id" + values = [var.vpc_id] + } +} + locals { vpc = ( local.create_vpc ? @@ -40,8 +60,8 @@ locals { } : { id = var.vpc_id - private_subnets = var.private_subnets - public_subnets = var.public_subnets + private_subnets = data.aws_subnets.private[0].ids + public_subnets = data.aws_subnets.public[0].ids } ) } diff --git a/infrastructure/terraform/modules/gcp-gke/main.tf b/infrastructure/terraform/modules/gcp-gke/main.tf index 551e8b1539..ad0854690d 100644 --- a/infrastructure/terraform/modules/gcp-gke/main.tf +++ b/infrastructure/terraform/modules/gcp-gke/main.tf @@ -1,25 +1,25 @@ provider "google" { project = var.project_id - region = var.region + region = var.region } resource "google_compute_network" "vpc" { - name = var.vpc_name + name = var.vpc_name } resource "google_container_cluster" "gke_core" { - name = "${var.project_id}-gke" - location = var.region + name = var.gke_name + location = var.region remove_default_node_pool = true initial_node_count = 1 - network = google_compute_network.vpc.name + network = google_compute_network.vpc.name } resource "google_container_node_pool" "gke_core_nodes" { - name = "${google_container_cluster.gke_core.name}-node-pool" - location = var.region - cluster = google_container_cluster.gke_core.name - node_count = var.gke_num_nodes + name = "${google_container_cluster.gke_core.name}-node-pool" + location = var.region + cluster = google_container_cluster.gke_core.name + node_count = var.gke_num_nodes node_locations = var.gke_node_locations node_config { @@ -31,7 +31,7 @@ resource "google_container_node_pool" "gke_core_nodes" { "https://www.googleapis.com/auth/monitoring", ] - tags = ["gke-node", "${var.project_id}-gke"] + tags = ["gke-node", "${var.project_id}-gke"] metadata = { disable-legacy-endpoints = "true" } diff --git a/infrastructure/terraform/modules/gcp-gke/outputs.tf b/infrastructure/terraform/modules/gcp-gke/outputs.tf index a956e386cb..ac66cc9f4e 100644 --- a/infrastructure/terraform/modules/gcp-gke/outputs.tf +++ b/infrastructure/terraform/modules/gcp-gke/outputs.tf @@ -4,5 +4,5 @@ output "kubernetes_cluster_name" { } output "kubeconfig_raw" { sensitive = true - value = module.gke_auth.kubeconfig_raw + value = module.gke_auth.kubeconfig_raw } diff --git a/infrastructure/terraform/modules/gcp-gke/variables.tf b/infrastructure/terraform/modules/gcp-gke/variables.tf index ef94ab8119..fe79619f77 100644 --- a/infrastructure/terraform/modules/gcp-gke/variables.tf +++ b/infrastructure/terraform/modules/gcp-gke/variables.tf @@ -2,26 +2,37 @@ variable "project_id" { default = "airy-core" description = "The project defined in gcloud config is airy-core" } + variable "region" { default = "us-central1" description = "The region defined in gcloud config is us-central1" } + +variable "gke_name" { + description = "The name of the created GKE cluster" + default = "airy-core-gke" +} + variable "gke_num_nodes" { default = 1 description = "Number of gke nodes" } + variable "gke_node_locations" { default = [] description = "List of zones for the nodes in the node pool" } + variable "vpc_name" { default = "airy-core-vpc" description = "The name of the created VPC" } + variable "kubeconfig_output_path" { default = "../kube.conf" description = "The location of the kubeconfig file" } + variable "gke_instance_type" { default = "n1-standard-2" description = "The type of the instances in the node pool" diff --git a/lib/typescript/assets/images/icons/congnigyLogo.svg b/lib/typescript/assets/images/icons/cognigyLogo.svg similarity index 100% rename from lib/typescript/assets/images/icons/congnigyLogo.svg rename to lib/typescript/assets/images/icons/cognigyLogo.svg diff --git a/lib/typescript/assets/scss/animations.scss b/lib/typescript/assets/scss/animations.scss index 70f4c59bda..454a0bb738 100644 --- a/lib/typescript/assets/scss/animations.scss +++ b/lib/typescript/assets/scss/animations.scss @@ -41,12 +41,10 @@ @keyframes searchfieldAnimIn { from { opacity: 0; - transform: translateX(300px); width: 0px; } to { opacity: 1; - transform: translateX(0px); width: 300px; } } @@ -54,12 +52,10 @@ @keyframes searchfieldAnimOut { from { opacity: 1; - transform: translateX(0px); width: 300px; } to { opacity: 0; - transform: translateX(300px); width: 0px; } } @@ -112,3 +108,26 @@ width: 0; } } + +@keyframes animateFilterModalIn { + 0% { + transform: translateX(200px); + opacity: 0; + } + + 100% { + transform: translateX(0px); + opacity: 1; + } +} + +@keyframes animateFilterModalOut { + 0% { + transform: translateX(0px); + opacity: 1; + } + 100% { + transform: translateX(200px); + opacity: 0; + } +} diff --git a/lib/typescript/components/cta/SmartButton/index.tsx b/lib/typescript/components/cta/SmartButton/index.tsx index 85e971b6a5..dda16e6748 100644 --- a/lib/typescript/components/cta/SmartButton/index.tsx +++ b/lib/typescript/components/cta/SmartButton/index.tsx @@ -1,4 +1,4 @@ -import React, {CSSProperties, useEffect, useRef, useState} from 'react'; +import React, {CSSProperties} from 'react'; import {ReactComponent as RefreshIcon} from 'assets/images/icons/refreshIcon.svg'; import styles from './index.module.scss'; @@ -48,13 +48,6 @@ export const SmartButton = ({ height, width, }: ButtonProps) => { - const [buttonWidth, setButtonWidth] = useState(0); - const ref = useRef(null); - - useEffect(() => { - setButtonWidth(ref?.current?.offsetWidth); - }, [ref?.current]); - const styleFor = (variant: styleVariantType) => { switch (variant) { case 'extra-small': @@ -86,17 +79,9 @@ export const SmartButton = ({ return (