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:
Pere Miquel Brull 2023-06-12 07:47:45 +02:00 committed by GitHub
parent 62af9bb633
commit ba5f929f77
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 457 additions and 113 deletions

View File

@ -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

View File

@ -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

View File

@ -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,
)

View File

@ -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":

View File

@ -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__,
),
)

View File

@ -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());
}
}

View File

@ -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.");
}
}

View File

@ -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) {

View File

@ -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

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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">

View File

@ -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')}

View File

@ -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();
});
});

View File

@ -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,
};
};

View File

@ -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;
}

View File

@ -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 || {}}

View File

@ -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 () => {