diff --git a/openmetadata-airflow-apis/openmetadata_managed_apis/api/routes/health.py b/openmetadata-airflow-apis/openmetadata_managed_apis/api/routes/health.py index a04405f0930..65288107f4f 100644 --- a/openmetadata-airflow-apis/openmetadata_managed_apis/api/routes/health.py +++ b/openmetadata-airflow-apis/openmetadata_managed_apis/api/routes/health.py @@ -11,10 +11,10 @@ """ Health endpoint. Globally accessible """ -import traceback from typing import Callable from flask import Blueprint +from openmetadata_managed_apis.operations.health import health_response from openmetadata_managed_apis.utils.logger import routes_logger try: @@ -22,8 +22,6 @@ try: except ImportError: from importlib_metadata import version -from openmetadata_managed_apis.api.response import ApiResponse - logger = routes_logger() @@ -45,17 +43,6 @@ def get_fn(blueprint: Blueprint) -> Callable: /health endpoint to check Airflow REST status without auth """ - try: - return ApiResponse.success( - {"status": "healthy", "version": version("openmetadata-ingestion")} - ) - except Exception as exc: - msg = f"Error obtaining Airflow REST status due to [{exc}] " - logger.debug(traceback.format_exc()) - logger.error(msg) - return ApiResponse.error( - status=ApiResponse.STATUS_BAD_REQUEST, - error=msg, - ) + return health_response() return health diff --git a/openmetadata-airflow-apis/openmetadata_managed_apis/api/routes/health_auth.py b/openmetadata-airflow-apis/openmetadata_managed_apis/api/routes/health_auth.py new file mode 100644 index 00000000000..70e946fffdd --- /dev/null +++ b/openmetadata-airflow-apis/openmetadata_managed_apis/api/routes/health_auth.py @@ -0,0 +1,53 @@ +# Copyright 2021 Collate +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Health endpoint. Globally accessible +""" +from typing import Callable + +from flask import Blueprint +from openmetadata_managed_apis.operations.health import health_response +from openmetadata_managed_apis.utils.logger import routes_logger + +try: + from importlib.metadata import version +except ImportError: + from importlib_metadata import version + +logger = routes_logger() + + +def get_fn(blueprint: Blueprint) -> Callable: + """ + Return the function loaded to a route + :param blueprint: Flask Blueprint to assign route to + :return: routed function + """ + + # Lazy import the requirements + # pylint: disable=import-outside-toplevel + from airflow.api_connexion import security + from airflow.security import permissions + from airflow.www.app import csrf + + @blueprint.route("/health-auth", methods=["GET"]) + @csrf.exempt + @security.requires_access( + [(permissions.ACTION_CAN_CREATE, permissions.RESOURCE_DAG)] + ) + def health_auth(): + """ + /auth-health endpoint to check Airflow REST status without auth + """ + + return health_response() + + return health_auth diff --git a/openmetadata-airflow-apis/openmetadata_managed_apis/operations/health.py b/openmetadata-airflow-apis/openmetadata_managed_apis/operations/health.py new file mode 100644 index 00000000000..6c518d31363 --- /dev/null +++ b/openmetadata-airflow-apis/openmetadata_managed_apis/operations/health.py @@ -0,0 +1,40 @@ +# Copyright 2021 Collate +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Common health validation, for auth and non-auth endpoint +""" +import traceback + +from openmetadata_managed_apis.utils.logger import operations_logger + +try: + from importlib.metadata import version +except ImportError: + from importlib_metadata import version + +from openmetadata_managed_apis.api.response import ApiResponse + +logger = operations_logger() + + +def health_response(): + try: + return ApiResponse.success( + {"status": "healthy", "version": version("openmetadata-ingestion")} + ) + except Exception as exc: + msg = f"Error obtaining Airflow REST status due to [{exc}] " + logger.debug(traceback.format_exc()) + logger.error(msg) + return ApiResponse.error( + status=ApiResponse.STATUS_BAD_REQUEST, + error=msg, + ) diff --git a/openmetadata-airflow-apis/openmetadata_managed_apis/workflows/ingestion/common.py b/openmetadata-airflow-apis/openmetadata_managed_apis/workflows/ingestion/common.py index d8ecd2f83df..05dfeb89035 100644 --- a/openmetadata-airflow-apis/openmetadata_managed_apis/workflows/ingestion/common.py +++ b/openmetadata-airflow-apis/openmetadata_managed_apis/workflows/ingestion/common.py @@ -114,19 +114,6 @@ def build_source(ingestion_pipeline: IngestionPipeline) -> WorkflowSource: service_type = ingestion_pipeline.service.type - if service_type == "testSuite": - service = metadata.get_by_name( - entity=TestSuite, fqn=ingestion_pipeline.service.name - ) # check we are able to access OM server - if not service: - raise GetServiceException(service_type, ingestion_pipeline.service.name) - - return WorkflowSource( - type=service_type, - serviceName=ingestion_pipeline.service.name, - sourceConfig=ingestion_pipeline.sourceConfig, - ) - entity_class = None try: if service_type == "databaseService": diff --git a/openmetadata-airflow-apis/tests/unit/ingestion_pipeline/test_workflow_creation.py b/openmetadata-airflow-apis/tests/unit/ingestion_pipeline/test_workflow_creation.py index 1edc4907bcf..6c3ea5f43ce 100644 --- a/openmetadata-airflow-apis/tests/unit/ingestion_pipeline/test_workflow_creation.py +++ b/openmetadata-airflow-apis/tests/unit/ingestion_pipeline/test_workflow_creation.py @@ -161,13 +161,6 @@ class OMetaServiceTest(TestCase): config=cls.usage_workflow_source, ) - cls.test_suite: TestSuite = cls.metadata.create_or_update( - CreateTestSuiteRequest( - name="airflow_workflow_test_suite", - description="This is a test suite airflow worflow", - ) - ) - @classmethod def tearDownClass(cls) -> None: """ @@ -180,13 +173,6 @@ class OMetaServiceTest(TestCase): hard_delete=True, ) - cls.metadata.delete( - entity=TestSuite, - entity_id=cls.test_suite.id, - recursive=True, - hard_delete=True, - ) - @patch.object( Workflow, "set_ingestion_pipeline_status", mock_set_ingestion_pipeline_status ) @@ -333,15 +319,20 @@ class OMetaServiceTest(TestCase): name="test_test_suite_workflow", pipelineType=PipelineType.TestSuite, fullyQualifiedName="local_mysql.test_test_suite_workflow", - sourceConfig=SourceConfig(config=TestSuitePipeline(type="TestSuite")), + sourceConfig=SourceConfig( + config=TestSuitePipeline( + type="TestSuite", + entityFullyQualifiedName="service.database.schema.table", + ) + ), openMetadataServerConnection=self.server_config, airflowConfig=AirflowConfig( startDate="2022-06-10T15:06:47+00:00", ), service=EntityReference( - id=self.test_suite.id, - type="testSuite", - name=self.test_suite.name.__root__, + id=self.service.id, + type="databaseService", + name=self.service.name.__root__, ), ) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/clients/pipeline/airflow/AirflowRESTClient.java b/openmetadata-service/src/main/java/org/openmetadata/service/clients/pipeline/airflow/AirflowRESTClient.java index a1c0e09141f..daa82c97d4d 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/clients/pipeline/airflow/AirflowRESTClient.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/clients/pipeline/airflow/AirflowRESTClient.java @@ -25,6 +25,7 @@ import java.time.Duration; import java.util.List; import java.util.Map; import javax.net.ssl.SSLContext; +import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import lombok.extern.slf4j.Slf4j; import org.json.JSONObject; @@ -221,30 +222,47 @@ public class AirflowRESTClient extends PipelineServiceClient { Response.Status.fromStatusCode(response.statusCode())); } + /** + * Scenarios handled here: 1. Failed to access Airflow APIs: No response from Airflow; APIs might not be installed 2. + * Auth failed when accessing Airflow APIs 3. Different versions between server and client + */ @Override public Response getServiceStatus() { HttpResponse response; try { - response = getRequestNoAuthForJsonContent(serviceURL, API_ENDPOINT); + response = getRequestAuthenticatedForJsonContent("%s/%s/health-auth", serviceURL, API_ENDPOINT); + + // We can reach the APIs and get the status back from Airflow if (response.statusCode() == 200) { JSONObject responseJSON = new JSONObject(response.body()); String ingestionVersion = responseJSON.getString("version"); if (Boolean.TRUE.equals(validServerClientVersions(ingestionVersion))) { - Map status = Map.of("status", "healthy"); - return Response.status(200, status.toString()).build(); + Map status = buildHealthyStatus(ingestionVersion); + return Response.ok(status, MediaType.APPLICATION_JSON_TYPE).build(); } else { Map status = - Map.of( - "status", - "unhealthy", - "reason", - String.format( - "Got Ingestion Version %s and Server Version %s. They should match.", - ingestionVersion, SERVER_VERSION)); - return Response.status(500, status.toString()).build(); + buildUnhealthyStatus(buildVersionMismatchErrorMessage(ingestionVersion, SERVER_VERSION)); + return Response.ok(status, MediaType.APPLICATION_JSON_TYPE).build(); } } + + // Auth error when accessing the APIs + if (response.statusCode() == 401 || response.statusCode() == 403) { + Map status = + buildUnhealthyStatus( + String.format("Authentication failed for user [%s] trying to access the Airflow APIs.", this.username)); + return Response.ok(status, MediaType.APPLICATION_JSON_TYPE).build(); + } + + // APIs URL not found + if (response.statusCode() == 404) { + Map status = + buildUnhealthyStatus("Airflow APIs not found. Please follow the installation guide."); + + return Response.ok(status, MediaType.APPLICATION_JSON_TYPE).build(); + } + } catch (Exception e) { throw PipelineServiceClientException.byMessage("Failed to get REST status.", e.getMessage()); } @@ -347,11 +365,4 @@ public class AirflowRESTClient extends PipelineServiceClient { .header(CONTENT_HEADER, CONTENT_TYPE) .header(AUTH_HEADER, getBasicAuthenticationHeader(username, password)); } - - private HttpResponse getRequestNoAuthForJsonContent(Object... stringReplacement) - throws IOException, InterruptedException { - String url = String.format("%s/%s/health", stringReplacement); - HttpRequest request = HttpRequest.newBuilder(URI.create(url)).header(CONTENT_HEADER, CONTENT_TYPE).GET().build(); - return client.send(request, HttpResponse.BodyHandlers.ofString()); - } } diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/pipelineService/PipelineServiceClientTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/pipelineService/PipelineServiceClientTest.java index 361a5c4f8e1..cce419b22ac 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/pipelineService/PipelineServiceClientTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/pipelineService/PipelineServiceClientTest.java @@ -33,4 +33,17 @@ public class PipelineServiceClientTest { assertEquals(expectedMessage, actualMessage); } + + @Test + public void testBuildVersionMismatchErrorMessage() { + String res = mockPipelineServiceClient.buildVersionMismatchErrorMessage("1.1.0.dev0", "1.0.0"); + assertEquals( + res, + "Server version [1.0.0] is older than Ingestion Version [1.1.0.dev0]. Please upgrade your server or downgrade the ingestion client."); + + res = mockPipelineServiceClient.buildVersionMismatchErrorMessage("1.0.0.dev0", "1.0.1"); + assertEquals( + res, + "Ingestion version [1.0.0.dev0] is older than Server Version [1.0.1]. Please upgrade your ingestion client."); + } } diff --git a/openmetadata-spec/src/main/java/org/openmetadata/sdk/PipelineServiceClient.java b/openmetadata-spec/src/main/java/org/openmetadata/sdk/PipelineServiceClient.java index 513f924d6d9..8f9d562176a 100644 --- a/openmetadata-spec/src/main/java/org/openmetadata/sdk/PipelineServiceClient.java +++ b/openmetadata-spec/src/main/java/org/openmetadata/sdk/PipelineServiceClient.java @@ -132,6 +132,27 @@ public abstract class PipelineServiceClient { return getVersionFromString(clientVersion).equals(getVersionFromString(SERVER_VERSION)); } + public String buildVersionMismatchErrorMessage(String ingestionVersion, String serverVersion) { + if (getVersionFromString(ingestionVersion).compareTo(getVersionFromString(serverVersion)) < 0) { + return String.format( + "Ingestion version [%s] is older than Server Version [%s]. Please upgrade your ingestion client.", + ingestionVersion, serverVersion); + } + return String.format( + "Server version [%s] is older than Ingestion Version [%s]. Please upgrade your server or downgrade the ingestion client.", + serverVersion, ingestionVersion); + } + + /** To build the response of getServiceStatus */ + public Map buildHealthyStatus(String ingestionVersion) { + return Map.of("status", "healthy", "version", ingestionVersion); + } + + /** To build the response of getServiceStatus */ + public Map buildUnhealthyStatus(String reason) { + return Map.of("status", "unhealthy", "reason", reason); + } + public final Response getHostIp() { if (this.ingestionIpInfoEnabled) { diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-retry-icon.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-retry-icon.svg new file mode 100644 index 00000000000..6f7d3f3c14d --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-retry-icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ServiceConfig/ConnectionConfigForm.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ServiceConfig/ConnectionConfigForm.tsx index 6ebd643a5ae..e6a31495f84 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ServiceConfig/ConnectionConfigForm.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ServiceConfig/ConnectionConfigForm.tsx @@ -13,6 +13,7 @@ import { IChangeEvent } from '@rjsf/core'; import validator from '@rjsf/validator-ajv8'; +import AirflowMessageBanner from 'components/common/AirflowMessageBanner/AirflowMessageBanner'; import { StorageServiceType } from 'generated/entity/data/container'; import { cloneDeep, isNil } from 'lodash'; import { LoadingState } from 'Models'; @@ -142,7 +143,12 @@ const ConnectionConfigForm: FunctionComponent = ({ ); }; - return {getConfigFields()}; + return ( + + + {getConfigFields()} + + ); }; export default ConnectionConfigForm; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/AirflowMessageBanner/AirflowMessageBanner.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/AirflowMessageBanner/AirflowMessageBanner.test.tsx new file mode 100644 index 00000000000..62e17085c40 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/AirflowMessageBanner/AirflowMessageBanner.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright 2023 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { render, screen } from '@testing-library/react'; +import { useAirflowStatus } from 'hooks/useAirflowStatus'; +import React from 'react'; +import AirflowMessageBanner from './AirflowMessageBanner'; + +jest.mock('hooks/useAirflowStatus', () => ({ + useAirflowStatus: jest.fn().mockImplementation(() => ({ + reason: 'reason message', + isAirflowAvailable: false, + })), +})); + +describe('Test Airflow Message Banner', () => { + it('Should render the banner if airflow is not available', () => { + render(); + + expect(screen.getByTestId('no-airflow-placeholder')).toBeInTheDocument(); + }); + + it('Should not render the banner if airflow is available', () => { + (useAirflowStatus as jest.Mock).mockImplementationOnce(() => ({ + reason: 'reason message', + isAirflowAvailable: true, + })); + render(); + + expect( + screen.queryByTestId('no-airflow-placeholder') + ).not.toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/AirflowMessageBanner/AirflowMessageBanner.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/AirflowMessageBanner/AirflowMessageBanner.tsx new file mode 100644 index 00000000000..a2867e637cd --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/AirflowMessageBanner/AirflowMessageBanner.tsx @@ -0,0 +1,39 @@ +/* + * Copyright 2023 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Space, SpaceProps, Typography } from 'antd'; +import { ReactComponent as IconRetry } from 'assets/svg/ic-retry-icon.svg'; +import classNames from 'classnames'; +import { useAirflowStatus } from 'hooks/useAirflowStatus'; +import React, { FC } from 'react'; +import './airflow-message-banner.less'; + +const AirflowMessageBanner: FC = ({ className }) => { + const { reason, isAirflowAvailable } = useAirflowStatus(); + + if (isAirflowAvailable) { + return null; + } + + return ( + + + {reason} + + ); +}; + +export default AirflowMessageBanner; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/AirflowMessageBanner/airflow-message-banner.less b/openmetadata-ui/src/main/resources/ui/src/components/common/AirflowMessageBanner/airflow-message-banner.less new file mode 100644 index 00000000000..568521953e1 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/AirflowMessageBanner/airflow-message-banner.less @@ -0,0 +1,26 @@ +/* + * Copyright 2023 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import url('../../../styles/variables.less'); + +@warning-bg-color: #f0af2c0d; + +.airflow-message-banner { + border: 1px solid @warning-color; + border-radius: 4px; + padding: 8px; + background: @warning-bg-color; + + .rjsf .form-group.field { + margin-top: 0px; + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilder/FormBuilder.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilder/FormBuilder.tsx index 88b53815a42..2e27b179474 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilder/FormBuilder.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/FormBuilder/FormBuilder.tsx @@ -148,16 +148,18 @@ const FormBuilder: FunctionComponent = ({ )} - {!isEmpty(schema) && !isUndefined(localFormData) && ( - - )} + {!isEmpty(schema) && + !isUndefined(localFormData) && + isAirflowAvailable && ( + + )}
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/error-with-placeholder/ErrorPlaceHolderIngestion.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/error-with-placeholder/ErrorPlaceHolderIngestion.tsx index 9ce897c66af..27004c8857c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/error-with-placeholder/ErrorPlaceHolderIngestion.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/error-with-placeholder/ErrorPlaceHolderIngestion.tsx @@ -15,12 +15,14 @@ import { Card } from 'antd'; import { AIRFLOW_DOCS } from 'constants/docs.constants'; import { t } from 'i18next'; import React from 'react'; +import AirflowMessageBanner from '../AirflowMessageBanner/AirflowMessageBanner'; const ErrorPlaceHolderIngestion = () => { const airflowSetupGuide = () => { return (
+
{t('message.manage-airflow-api-failed')} diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useAirflowStatus.test.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useAirflowStatus.test.ts new file mode 100644 index 00000000000..ee23f251ac7 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useAirflowStatus.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2023 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { renderHook } from '@testing-library/react-hooks'; + +import { + AirflowResponse, + AirflowStatus, +} from 'interface/AirflowStatus.interface'; +import { getAirflowStatus } from 'rest/ingestionPipelineAPI'; +import { useAirflowStatus } from './useAirflowStatus'; + +const mockResponse: AirflowResponse = { + status: AirflowStatus.HEALTHY, + version: '1.0.0.dev01', +}; + +jest.mock('rest/ingestionPipelineAPI', () => ({ + getAirflowStatus: jest + .fn() + .mockImplementation(() => Promise.resolve(mockResponse)), +})); + +describe('useAirflowStatus', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch and update airflow status correctly', async () => { + const { result, waitForNextUpdate } = renderHook(() => useAirflowStatus()); + + expect(result.current.isFetchingStatus).toBe(true); + expect(result.current.isAirflowAvailable).toBe(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.reason).toBeUndefined(); + + await waitForNextUpdate(); + + expect(result.current.isFetchingStatus).toBe(false); + expect(result.current.isAirflowAvailable).toBe(true); + expect(result.current.error).toBeUndefined(); + expect(result.current.reason).toBe(mockResponse.reason); + }); + + it('should handle error case correctly', async () => { + const mockError = new Error('Airflow status fetch failed'); + (getAirflowStatus as jest.Mock).mockImplementationOnce(() => + Promise.reject(mockError) + ); + + const { result, waitForNextUpdate } = renderHook(() => useAirflowStatus()); + + expect(result.current.isFetchingStatus).toBe(true); + expect(result.current.isAirflowAvailable).toBe(false); + expect(result.current.error).toBeUndefined(); + expect(result.current.reason).toBeUndefined(); + + await waitForNextUpdate(); + + expect(result.current.isFetchingStatus).toBe(false); + expect(result.current.isAirflowAvailable).toBe(false); + expect(result.current.error).toBe(mockError); + expect(result.current.reason).toBeUndefined(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/hooks/useAirflowStatus.ts b/openmetadata-ui/src/main/resources/ui/src/hooks/useAirflowStatus.ts index 23ae3157ea3..d289b39122a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/hooks/useAirflowStatus.ts +++ b/openmetadata-ui/src/main/resources/ui/src/hooks/useAirflowStatus.ts @@ -12,13 +12,18 @@ */ import { AxiosError } from 'axios'; +import { + AirflowResponse, + AirflowStatus, +} from 'interface/AirflowStatus.interface'; import { useEffect, useState } from 'react'; -import { checkAirflowStatus } from 'rest/ingestionPipelineAPI'; +import { getAirflowStatus } from 'rest/ingestionPipelineAPI'; interface UseAirflowStatusProps { isFetchingStatus: boolean; isAirflowAvailable: boolean; error: AxiosError | undefined; + reason: AirflowResponse['reason']; fetchAirflowStatus: () => Promise; } @@ -26,12 +31,14 @@ export const useAirflowStatus = (): UseAirflowStatusProps => { const [isLoading, setIsLoading] = useState(false); const [isAirflowAvailable, setIsAirflowAvailable] = useState(false); const [error, setError] = useState(); + const [reason, setReason] = useState(); const fetchAirflowStatus = async () => { setIsLoading(true); try { - const response = await checkAirflowStatus(); - setIsAirflowAvailable(response.status === 200); + const response = await getAirflowStatus(); + setIsAirflowAvailable(response.status === AirflowStatus.HEALTHY); + setReason(response.reason); } catch (error) { setError(error as AxiosError); setIsAirflowAvailable(false); @@ -49,5 +56,6 @@ export const useAirflowStatus = (): UseAirflowStatusProps => { isAirflowAvailable, error, fetchAirflowStatus, + reason, }; }; diff --git a/openmetadata-ui/src/main/resources/ui/src/interface/AirflowStatus.interface.ts b/openmetadata-ui/src/main/resources/ui/src/interface/AirflowStatus.interface.ts new file mode 100644 index 00000000000..eedf9057bac --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/interface/AirflowStatus.interface.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2023 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * need to create this manually because we don't have json schema for airflow status as it's just the ok/ko response + */ + +export enum AirflowStatus { + HEALTHY = 'healthy', + UNHEALTHY = 'unhealthy', +} +export interface AirflowResponse { + status: AirflowStatus; + version?: string; + reason?: string; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/service/index.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/service/index.tsx index 849a7b6c714..2137f50f444 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/service/index.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/service/index.tsx @@ -14,6 +14,7 @@ import { Button, Col, Row, Space, Tabs, Tooltip, Typography } from 'antd'; import Table, { ColumnsType } from 'antd/lib/table'; import { AxiosError } from 'axios'; +import AirflowMessageBanner from 'components/common/AirflowMessageBanner/AirflowMessageBanner'; import Description from 'components/common/description/Description'; import ManageButton from 'components/common/entityPageInfo/ManageButton/ManageButton'; import EntitySummaryDetails from 'components/common/EntitySummaryDetails/EntitySummaryDetails'; @@ -899,47 +900,50 @@ const ServicePage: FunctionComponent = () => { const testConnectionTab = useMemo(() => { return ( <> - - - - - {allowTestConn && isAirflowAvailable && ( + + +
- + - )} + {allowTestConn && isAirflowAvailable && ( + + + + )} +
{ return response.data; }; -export const checkAirflowStatus = (): Promise => { - return APIClient.get('/services/ingestionPipelines/status'); +export const getAirflowStatus = async () => { + const response = await APIClient.get( + '/services/ingestionPipelines/status' + ); + + return response.data; }; export const getPipelineServiceHostIp = async () => {