mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-11-03 12:08:31 +00:00
Add fine-grained health validation for Airflow APIs (#11792)
* Add fine-grained health validation for Airflow APIs * Add ingestion version response * Improve messaging * Format * Format * Update response * ui:update the hook to return airflow status based on response status field * ui: add unit test for useAirflowStatus hook * chore: only show test connection if airflow is available * feat: add airflow message banner * chore: update icon and background color * chore: update typography to text * test: add unit test * address comments * chore: show banner on service detail page * fix: update test suite api workflow to reflect new implementation --------- Co-authored-by: Sachin Chaurasiya <sachinchaurasiyachotey87@gmail.com> Co-authored-by: Teddy Crepineau <teddy.crepineau@gmail.com>
This commit is contained in:
parent
62af9bb633
commit
ba5f929f77
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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,
|
||||
)
|
||||
@ -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":
|
||||
|
||||
@ -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__,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@ -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<String> 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<String, String> status = Map.of("status", "healthy");
|
||||
return Response.status(200, status.toString()).build();
|
||||
Map<String, String> status = buildHealthyStatus(ingestionVersion);
|
||||
return Response.ok(status, MediaType.APPLICATION_JSON_TYPE).build();
|
||||
} else {
|
||||
Map<String, String> 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<String, String> 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<String, String> 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<String> 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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String, String> buildHealthyStatus(String ingestionVersion) {
|
||||
return Map.of("status", "healthy", "version", ingestionVersion);
|
||||
}
|
||||
|
||||
/** To build the response of getServiceStatus */
|
||||
public Map<String, String> buildUnhealthyStatus(String reason) {
|
||||
return Map.of("status", "unhealthy", "reason", reason);
|
||||
}
|
||||
|
||||
public final Response getHostIp() {
|
||||
|
||||
if (this.ingestionIpInfoEnabled) {
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
<svg viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="18" cy="18" r="17.1" stroke="#F0AF2C" stroke-width="1.8"/>
|
||||
<path d="M26.484 17.7871C26.484 19.8259 25.775 21.8012 24.4786 23.3747C23.1822 24.9482 21.379 26.022 19.3779 26.412C17.3767 26.8021 15.3023 26.4842 13.5098 25.5127C11.7173 24.5413 10.3184 22.9768 9.55273 21.0873" stroke="#F0AF2C" stroke-width="1.8"/>
|
||||
<path d="M10.4863 21.4579L9.29422 22.2647L9.48685 21.2652L10.4863 21.4579Z" stroke="#F0AF2C" stroke-width="1.8" stroke-linecap="round"/>
|
||||
<path d="M8.9086 17.7874C8.9086 15.7486 9.61754 13.7732 10.914 12.1997C12.2104 10.6262 14.0136 9.55247 16.0147 9.16242C18.0158 8.77237 20.0903 9.09029 21.8828 10.0617C23.6753 11.0332 25.0742 12.5977 25.8398 14.4872" stroke="#F0AF2C" stroke-width="1.8"/>
|
||||
<path d="M24.9063 14.1166L26.0984 13.3098L25.9057 14.3092L24.9063 14.1166Z" stroke="#F0AF2C" stroke-width="1.8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 930 B |
@ -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<Props> = ({
|
||||
);
|
||||
};
|
||||
|
||||
return <Fragment>{getConfigFields()}</Fragment>;
|
||||
return (
|
||||
<Fragment>
|
||||
<AirflowMessageBanner />
|
||||
{getConfigFields()}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionConfigForm;
|
||||
|
||||
@ -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(<AirflowMessageBanner />);
|
||||
|
||||
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(<AirflowMessageBanner />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('no-airflow-placeholder')
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -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<SpaceProps> = ({ className }) => {
|
||||
const { reason, isAirflowAvailable } = useAirflowStatus();
|
||||
|
||||
if (isAirflowAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Space
|
||||
align="center"
|
||||
className={classNames('airflow-message-banner', className)}
|
||||
data-testid="no-airflow-placeholder"
|
||||
size={16}>
|
||||
<IconRetry height={24} width={24} />
|
||||
<Typography.Text>{reason}</Typography.Text>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default AirflowMessageBanner;
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -148,16 +148,18 @@ const FormBuilder: FunctionComponent<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isEmpty(schema) && !isUndefined(localFormData) && (
|
||||
<TestConnection
|
||||
connectionType={serviceType}
|
||||
formData={localFormData}
|
||||
isTestingDisabled={disableTestConnection}
|
||||
serviceCategory={serviceCategory}
|
||||
serviceName={serviceName}
|
||||
onValidateFormRequiredFields={handleRequiredFieldsValidation}
|
||||
/>
|
||||
)}
|
||||
{!isEmpty(schema) &&
|
||||
!isUndefined(localFormData) &&
|
||||
isAirflowAvailable && (
|
||||
<TestConnection
|
||||
connectionType={serviceType}
|
||||
formData={localFormData}
|
||||
isTestingDisabled={disableTestConnection}
|
||||
serviceCategory={serviceCategory}
|
||||
serviceName={serviceName}
|
||||
onValidateFormRequiredFields={handleRequiredFieldsValidation}
|
||||
/>
|
||||
)}
|
||||
<div className="tw-mt-6 d-flex tw-justify-between">
|
||||
<div />
|
||||
<div className="tw-text-right" data-testid="buttons">
|
||||
|
||||
@ -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 (
|
||||
<div className="tw-mb-5" data-testid="error-steps">
|
||||
<Card className="d-flex flex-col tw-justify-between tw-p-5 tw-w-4/5 tw-mx-auto">
|
||||
<AirflowMessageBanner className="m-b-xs" />
|
||||
<div>
|
||||
<h6 className="tw-text-base tw-text-grey-body tw-font-medium">
|
||||
{t('message.manage-airflow-api-failed')}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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<void>;
|
||||
}
|
||||
|
||||
@ -26,12 +31,14 @@ export const useAirflowStatus = (): UseAirflowStatusProps => {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isAirflowAvailable, setIsAirflowAvailable] = useState<boolean>(false);
|
||||
const [error, setError] = useState<AxiosError>();
|
||||
const [reason, setReason] = useState<AirflowResponse['reason']>();
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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 (
|
||||
<>
|
||||
<Space className="w-full my-4 justify-end">
|
||||
<Tooltip
|
||||
title={
|
||||
servicePermission.EditAll
|
||||
? t('label.edit-entity', {
|
||||
entity: t('label.connection'),
|
||||
})
|
||||
: t('message.no-permission-for-action')
|
||||
}>
|
||||
<Button
|
||||
ghost
|
||||
data-testid="edit-connection-button"
|
||||
disabled={!servicePermission.EditAll}
|
||||
type="primary"
|
||||
onClick={goToEditConnection}>
|
||||
{t('label.edit-entity', {
|
||||
entity: t('label.connection'),
|
||||
})}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{allowTestConn && isAirflowAvailable && (
|
||||
<Space className="w-full my-4 justify-between">
|
||||
<AirflowMessageBanner />
|
||||
<div>
|
||||
<Tooltip
|
||||
title={
|
||||
servicePermission.EditAll
|
||||
? t('label.test-entity', {
|
||||
? t('label.edit-entity', {
|
||||
entity: t('label.connection'),
|
||||
})
|
||||
: t('message.no-permission-for-action')
|
||||
}>
|
||||
<TestConnection
|
||||
connectionType={serviceDetails?.serviceType ?? ''}
|
||||
formData={connectionDetails as ConfigData}
|
||||
isTestingDisabled={isTestingDisabled}
|
||||
serviceCategory={serviceCategory as ServiceCategory}
|
||||
serviceName={serviceDetails?.name}
|
||||
// validation is not required as we have all the data available and not in edit mode
|
||||
shouldValidateForm={false}
|
||||
showDetails={false}
|
||||
/>
|
||||
<Button
|
||||
ghost
|
||||
data-testid="edit-connection-button"
|
||||
disabled={!servicePermission.EditAll}
|
||||
type="primary"
|
||||
onClick={goToEditConnection}>
|
||||
{t('label.edit-entity', {
|
||||
entity: t('label.connection'),
|
||||
})}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{allowTestConn && isAirflowAvailable && (
|
||||
<Tooltip
|
||||
title={
|
||||
servicePermission.EditAll
|
||||
? t('label.test-entity', {
|
||||
entity: t('label.connection'),
|
||||
})
|
||||
: t('message.no-permission-for-action')
|
||||
}>
|
||||
<TestConnection
|
||||
connectionType={serviceDetails?.serviceType ?? ''}
|
||||
formData={connectionDetails as ConfigData}
|
||||
isTestingDisabled={isTestingDisabled}
|
||||
serviceCategory={serviceCategory as ServiceCategory}
|
||||
serviceName={serviceDetails?.name}
|
||||
// validation is not required as we have all the data available and not in edit mode
|
||||
shouldValidateForm={false}
|
||||
showDetails={false}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
<ServiceConnectionDetails
|
||||
connectionDetails={connectionDetails || {}}
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { Operation } from 'fast-json-patch';
|
||||
import { AirflowResponse } from 'interface/AirflowStatus.interface';
|
||||
import { PagingResponse } from 'Models';
|
||||
import { IngestionPipelineLogByIdInterface } from 'pages/LogsViewer/LogsViewer.interfaces';
|
||||
import QueryString from 'qs';
|
||||
@ -153,8 +154,12 @@ export const patchIngestionPipeline = async (id: string, data: Operation[]) => {
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const checkAirflowStatus = (): Promise<AxiosResponse> => {
|
||||
return APIClient.get('/services/ingestionPipelines/status');
|
||||
export const getAirflowStatus = async () => {
|
||||
const response = await APIClient.get<AirflowResponse>(
|
||||
'/services/ingestionPipelines/status'
|
||||
);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getPipelineServiceHostIp = async () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user