feat: add hook for dataset health check (#11970)
* feat: add hook for dataset health check * add event log * optimize datasource json data like certified data * add unit test * fix review comments * extra code review comments
This commit is contained in:
parent
76f9f185fb
commit
8da1900d8a
|
|
@ -24,6 +24,8 @@ import { Menu } from 'src/common/components';
|
|||
import DatasourceModal from 'src/datasource/DatasourceModal';
|
||||
import ChangeDatasourceModal from 'src/datasource/ChangeDatasourceModal';
|
||||
import DatasourceControl from 'src/explore/components/controls/DatasourceControl';
|
||||
import Icon from 'src/components/Icon';
|
||||
import { Tooltip } from 'src/common/components/Tooltip';
|
||||
|
||||
const defaultProps = {
|
||||
name: 'datasource',
|
||||
|
|
@ -40,6 +42,7 @@ const defaultProps = {
|
|||
backend: 'mysql',
|
||||
name: 'main',
|
||||
},
|
||||
health_check_message: 'Warning message!',
|
||||
},
|
||||
actions: {
|
||||
setDatasource: sinon.spy(),
|
||||
|
|
@ -91,4 +94,14 @@ describe('DatasourceControl', () => {
|
|||
);
|
||||
expect(menuWrapper.find(Menu.Item)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should render health check message', () => {
|
||||
const wrapper = setup();
|
||||
const alert = wrapper.find(Icon).first();
|
||||
expect(alert.prop('name')).toBe('alert-solid');
|
||||
const tooltip = wrapper.find(Tooltip).at(1);
|
||||
expect(tooltip.prop('title')).toBe(
|
||||
defaultProps.datasource.health_check_message,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Col, Collapse, Row, Well } from 'react-bootstrap';
|
||||
import { t, styled } from '@superset-ui/core';
|
||||
import { t, styled, supersetTheme } from '@superset-ui/core';
|
||||
import { ColumnOption, MetricOption } from '@superset-ui/chart-controls';
|
||||
|
||||
import { Dropdown, Menu } from 'src/common/components';
|
||||
|
|
@ -73,6 +73,10 @@ const Styles = styled.div`
|
|||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.datasource-controls {
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
|
|
@ -213,10 +217,13 @@ class DatasourceControl extends React.PureComponent {
|
|||
</Menu>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line camelcase
|
||||
const { health_check_message: healthCheckMessage } = datasource;
|
||||
|
||||
return (
|
||||
<Styles className="DatasourceControl">
|
||||
<ControlHeader {...this.props} />
|
||||
<div>
|
||||
<div className="datasource-controls">
|
||||
<Tooltip title={t('Expand/collapse dataset configuration')}>
|
||||
<Label
|
||||
style={{ textTransform: 'none' }}
|
||||
|
|
@ -230,6 +237,14 @@ class DatasourceControl extends React.PureComponent {
|
|||
/>
|
||||
</Label>
|
||||
</Tooltip>
|
||||
{healthCheckMessage && (
|
||||
<Tooltip title={healthCheckMessage}>
|
||||
<Icon
|
||||
name="alert-solid"
|
||||
color={supersetTheme.colors.warning.base}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Dropdown
|
||||
overlay={datasourceMenu}
|
||||
trigger={['click']}
|
||||
|
|
|
|||
|
|
@ -1012,3 +1012,8 @@ elif importlib.util.find_spec("superset_config") and not is_test():
|
|||
except Exception:
|
||||
logger.exception("Found but failed to import local superset_config")
|
||||
raise
|
||||
|
||||
|
||||
# It's possible to add a dataset health check logic which is specific to your system.
|
||||
# It will get executed each time when user open a chart's explore view.
|
||||
DATASET_HEALTH_CHECK = None
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ from superset.constants import NULL_STRING
|
|||
from superset.db_engine_specs.base import TimestampExpression
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import QueryObjectValidationError, SupersetSecurityException
|
||||
from superset.extensions import event_logger
|
||||
from superset.jinja_context import (
|
||||
BaseTemplateProcessor,
|
||||
ExtraCache,
|
||||
|
|
@ -686,6 +687,10 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
|||
self.table_name, schema=self.schema, show_cols=False, latest_partition=False
|
||||
)
|
||||
|
||||
@property
|
||||
def health_check_message(self) -> Optional[str]:
|
||||
return self.extra_dict.get("health_check", {}).get("message")
|
||||
|
||||
@property
|
||||
def data(self) -> Dict[str, Any]:
|
||||
data_ = super().data
|
||||
|
|
@ -699,6 +704,7 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
|||
data_["fetch_values_predicate"] = self.fetch_values_predicate
|
||||
data_["template_params"] = self.template_params
|
||||
data_["is_sqllab_view"] = self.is_sqllab_view
|
||||
data_["health_check_message"] = self.health_check_message
|
||||
return data_
|
||||
|
||||
@property
|
||||
|
|
@ -1468,6 +1474,26 @@ class SqlaTable( # pylint: disable=too-many-public-methods,too-many-instance-at
|
|||
extra_cache_keys += sqla_query.extra_cache_keys
|
||||
return extra_cache_keys
|
||||
|
||||
def health_check(self, commit: bool = False, force: bool = False) -> None:
|
||||
check = config.get("DATASET_HEALTH_CHECK")
|
||||
if check is None:
|
||||
return
|
||||
|
||||
extra = self.extra_dict
|
||||
# force re-run health check, or health check is updated
|
||||
if force or extra.get("health_check", {}).get("version") != check.version:
|
||||
with event_logger.log_context(action="dataset_health_check"):
|
||||
message = check(self)
|
||||
extra["health_check"] = {
|
||||
"version": check.version,
|
||||
"message": message,
|
||||
}
|
||||
self.extra = json.dumps(extra)
|
||||
|
||||
db.session.merge(self)
|
||||
if commit:
|
||||
db.session.commit()
|
||||
|
||||
|
||||
sa.event.listen(SqlaTable, "after_insert", security_manager.set_perm)
|
||||
sa.event.listen(SqlaTable, "after_update", security_manager.set_perm)
|
||||
|
|
|
|||
|
|
@ -186,6 +186,8 @@ class DatasetDAO(BaseDAO):
|
|||
super().update(model, properties, commit=commit)
|
||||
properties["columns"] = original_properties
|
||||
|
||||
super().update(model, properties, commit=False)
|
||||
model.health_check(force=True, commit=False)
|
||||
return super().update(model, properties, commit=commit)
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -717,6 +717,10 @@ class Superset(BaseSupersetView): # pylint: disable=too-many-public-methods
|
|||
f"datasource_id={datasource_id}&"
|
||||
)
|
||||
|
||||
# if feature enabled, run some health check rules for sqla datasource
|
||||
if hasattr(datasource, "health_check"):
|
||||
datasource.health_check()
|
||||
|
||||
viz_type = form_data.get("viz_type")
|
||||
if not viz_type and datasource.default_endpoint:
|
||||
return redirect(datasource.default_endpoint)
|
||||
|
|
|
|||
|
|
@ -70,6 +70,8 @@ class Datasource(BaseSupersetView):
|
|||
f"Duplicate column name(s): {','.join(duplicates)}", status=409
|
||||
)
|
||||
orm_datasource.update_from_object(datasource_dict)
|
||||
if hasattr(orm_datasource, "health_check"):
|
||||
orm_datasource.health_check(force=True, commit=False)
|
||||
data = orm_datasource.data
|
||||
db.session.commit()
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
import json
|
||||
from copy import deepcopy
|
||||
|
||||
from superset import db
|
||||
from superset import app, db
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.utils.core import get_example_database
|
||||
|
||||
|
|
@ -190,6 +190,22 @@ class TestDatasource(SupersetTestCase):
|
|||
},
|
||||
)
|
||||
|
||||
def test_get_datasource_with_health_check(self):
|
||||
def my_check(datasource):
|
||||
return "Warning message!"
|
||||
|
||||
app.config["DATASET_HEALTH_CHECK"] = my_check
|
||||
my_check.version = 0.1
|
||||
|
||||
self.login(username="admin")
|
||||
tbl = self.get_table_by_name("birth_names")
|
||||
url = f"/datasource/get/{tbl.type}/{tbl.id}/"
|
||||
tbl.health_check(commit=True, force=True)
|
||||
resp = self.get_json_resp(url)
|
||||
self.assertEqual(resp["health_check_message"], "Warning message!")
|
||||
|
||||
del app.config["DATASET_HEALTH_CHECK"]
|
||||
|
||||
def test_get_datasource_failed(self):
|
||||
self.login(username="admin")
|
||||
url = f"/datasource/get/druid/500000/"
|
||||
|
|
|
|||
Loading…
Reference in New Issue