mirror of
https://github.com/open-metadata/OpenMetadata.git
synced 2025-08-27 10:26:09 +00:00
Minor: lineage data quality failure tracing support (#18580)
This commit is contained in:
parent
a6d97b67a8
commit
eeb27c3cbf
@ -1,4 +1,5 @@
|
|||||||
import { DateRangeObject } from 'Models';
|
import { DateRangeObject } from 'Models';
|
||||||
|
import { LinkProps } from 'react-router-dom';
|
||||||
import { TestCaseStatus } from '../../generated/tests/testCase';
|
import { TestCaseStatus } from '../../generated/tests/testCase';
|
||||||
import { TestCaseResolutionStatusTypes } from '../../generated/tests/testCaseResolutionStatus';
|
import { TestCaseResolutionStatusTypes } from '../../generated/tests/testCaseResolutionStatus';
|
||||||
import { TestPlatform } from '../../generated/tests/testDefinition';
|
import { TestPlatform } from '../../generated/tests/testDefinition';
|
||||||
@ -76,7 +77,7 @@ export interface DataStatisticWidgetProps {
|
|||||||
icon: SvgComponent;
|
icon: SvgComponent;
|
||||||
dataLabel: string;
|
dataLabel: string;
|
||||||
countValue: number;
|
countValue: number;
|
||||||
redirectPath: string;
|
redirectPath: LinkProps['to'];
|
||||||
linkLabel: string;
|
linkLabel: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,7 @@ import { EdgeProps, getBezierPath } from 'reactflow';
|
|||||||
import { ReactComponent as FunctionIcon } from '../../../assets/svg/ic-function.svg';
|
import { ReactComponent as FunctionIcon } from '../../../assets/svg/ic-function.svg';
|
||||||
import { ReactComponent as IconTimesCircle } from '../../../assets/svg/ic-times-circle.svg';
|
import { ReactComponent as IconTimesCircle } from '../../../assets/svg/ic-times-circle.svg';
|
||||||
import { ReactComponent as PipelineIcon } from '../../../assets/svg/pipeline-grey.svg';
|
import { ReactComponent as PipelineIcon } from '../../../assets/svg/pipeline-grey.svg';
|
||||||
|
import { RED_3 } from '../../../constants/Color.constants';
|
||||||
import { FOREIGN_OBJECT_SIZE } from '../../../constants/Lineage.constants';
|
import { FOREIGN_OBJECT_SIZE } from '../../../constants/Lineage.constants';
|
||||||
import { useLineageProvider } from '../../../context/LineageProvider/LineageProvider';
|
import { useLineageProvider } from '../../../context/LineageProvider/LineageProvider';
|
||||||
import { EntityType } from '../../../enums/entity.enum';
|
import { EntityType } from '../../../enums/entity.enum';
|
||||||
@ -76,6 +77,7 @@ export const CustomEdge = ({
|
|||||||
isPipelineRootNode,
|
isPipelineRootNode,
|
||||||
...rest
|
...rest
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
const offset = 4;
|
const offset = 4;
|
||||||
|
|
||||||
const { fromEntity, toEntity, pipeline, pipelineEntityType } =
|
const { fromEntity, toEntity, pipeline, pipelineEntityType } =
|
||||||
@ -150,10 +152,16 @@ export const CustomEdge = ({
|
|||||||
opacity = tracedNodes.length === 0 || isStrokeNeeded ? 1 : 0.25;
|
opacity = tracedNodes.length === 0 || isStrokeNeeded ? 1 : 0.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let stroke = isStrokeNeeded ? theme.primaryColor : undefined;
|
||||||
|
|
||||||
|
if (edge?.isDqTestFailure) {
|
||||||
|
stroke = RED_3;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...style,
|
...style,
|
||||||
...{
|
...{
|
||||||
stroke: isStrokeNeeded ? theme.primaryColor : undefined,
|
stroke,
|
||||||
opacity,
|
opacity,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -49,6 +49,7 @@ const CustomNodeV1 = (props: NodeProps) => {
|
|||||||
} = useLineageProvider();
|
} = useLineageProvider();
|
||||||
|
|
||||||
const { label, isNewNode, node = {}, isRootNode } = data;
|
const { label, isNewNode, node = {}, isRootNode } = data;
|
||||||
|
|
||||||
const nodeType = isEditMode ? EntityLineageNodeType.DEFAULT : type;
|
const nodeType = isEditMode ? EntityLineageNodeType.DEFAULT : type;
|
||||||
const isSelected = selectedNode === node;
|
const isSelected = selectedNode === node;
|
||||||
const { id, lineage, fullyQualifiedName } = node;
|
const { id, lineage, fullyQualifiedName } = node;
|
||||||
@ -257,6 +258,7 @@ const CustomNodeV1 = (props: NodeProps) => {
|
|||||||
className={classNames(
|
className={classNames(
|
||||||
'lineage-node p-0',
|
'lineage-node p-0',
|
||||||
isSelected ? 'custom-node-header-active' : 'custom-node-header-normal',
|
isSelected ? 'custom-node-header-active' : 'custom-node-header-normal',
|
||||||
|
{ 'data-quality-failed-custom-node-header': node?.isDqTestFailure },
|
||||||
{ 'custom-node-header-tracing': isTraced }
|
{ 'custom-node-header-tracing': isTraced }
|
||||||
)}
|
)}
|
||||||
data-testid={`lineage-node-${fullyQualifiedName}`}>
|
data-testid={`lineage-node-${fullyQualifiedName}`}>
|
||||||
|
@ -154,6 +154,41 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.react-flow__node-default.selectable.selected,
|
||||||
|
.react-flow__node-default.selectable:focus,
|
||||||
|
.react-flow__node-default.selectable:focus-visible,
|
||||||
|
.react-flow__node-input.selectable.selected,
|
||||||
|
.react-flow__node-input.selectable:focus,
|
||||||
|
.react-flow__node-input.selectable:focus-visible,
|
||||||
|
.react-flow__node-output.selectable.selected,
|
||||||
|
.react-flow__node-output.selectable:focus,
|
||||||
|
.react-flow__node-output.selectable:focus-visible,
|
||||||
|
.react-flow__node-group.selectable.selected,
|
||||||
|
.react-flow__node-group.selectable:focus,
|
||||||
|
.react-flow__node-group.selectable:focus-visible,
|
||||||
|
.react-flow__node-load-more.selectable.selected,
|
||||||
|
.react-flow__node-load-more.selectable:focus,
|
||||||
|
.react-flow__node-load-more.selectable:focus-visible {
|
||||||
|
.data-quality-failed-custom-node-header {
|
||||||
|
&.lineage-node {
|
||||||
|
border-color: @red-3 !important;
|
||||||
|
}
|
||||||
|
.lineage-node-handle {
|
||||||
|
border-color: @red-3;
|
||||||
|
svg {
|
||||||
|
color: @red-3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.label-container {
|
||||||
|
background: fade(@red-3, 10%);
|
||||||
|
}
|
||||||
|
.column-container {
|
||||||
|
background: fade(@red-3, 10%);
|
||||||
|
border-top: 1px solid @border-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.custom-node-header-active {
|
.custom-node-header-active {
|
||||||
.label-container {
|
.label-container {
|
||||||
background: @primary-1;
|
background: @primary-1;
|
||||||
@ -163,6 +198,15 @@
|
|||||||
border-top: 1px solid @border-color;
|
border-top: 1px solid @border-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.data-quality-failed-custom-node-header.custom-node-header-active {
|
||||||
|
.label-container {
|
||||||
|
background: fade(@red-3, 10%);
|
||||||
|
}
|
||||||
|
.column-container {
|
||||||
|
background: fade(@red-3, 10%);
|
||||||
|
border-top: 1px solid @red-3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.react-flow__handle.connectable {
|
.react-flow__handle.connectable {
|
||||||
background-color: @text-color;
|
background-color: @text-color;
|
||||||
|
@ -44,6 +44,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.data-quality-failed-custom-node-header {
|
||||||
|
&.custom-node-header-normal {
|
||||||
|
border: 1px solid @red-3;
|
||||||
|
}
|
||||||
|
.react-flow__handle {
|
||||||
|
border-color: @red-3;
|
||||||
|
}
|
||||||
|
.lineage-node-handle {
|
||||||
|
border: 1px solid @red-3;
|
||||||
|
svg {
|
||||||
|
color: @red-3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.custom-node-header-active {
|
||||||
|
border-color: @red-3;
|
||||||
|
}
|
||||||
|
}
|
||||||
.custom-node-header-active {
|
.custom-node-header-active {
|
||||||
border-color: @primary-color;
|
border-color: @primary-color;
|
||||||
}
|
}
|
||||||
|
@ -52,6 +52,7 @@ export interface EdgeDetails {
|
|||||||
columns?: ColumnLineage[];
|
columns?: ColumnLineage[];
|
||||||
description?: string;
|
description?: string;
|
||||||
pipelineEntityType?: EntityType.PIPELINE | EntityType.STORED_PROCEDURE;
|
pipelineEntityType?: EntityType.PIPELINE | EntityType.STORED_PROCEDURE;
|
||||||
|
doc_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LineageSourceType = Omit<SourceType, 'service'> & {
|
export type LineageSourceType = Omit<SourceType, 'service'> & {
|
||||||
|
@ -15,7 +15,10 @@ import React, { useEffect } from 'react';
|
|||||||
import { Edge } from 'reactflow';
|
import { Edge } from 'reactflow';
|
||||||
import { EdgeTypeEnum } from '../../components/Entity/EntityLineage/EntityLineage.interface';
|
import { EdgeTypeEnum } from '../../components/Entity/EntityLineage/EntityLineage.interface';
|
||||||
import { EntityType } from '../../enums/entity.enum';
|
import { EntityType } from '../../enums/entity.enum';
|
||||||
import { getLineageDataByFQN } from '../../rest/lineageAPI';
|
import {
|
||||||
|
getDataQualityLineage,
|
||||||
|
getLineageDataByFQN,
|
||||||
|
} from '../../rest/lineageAPI';
|
||||||
import LineageProvider, { useLineageProvider } from './LineageProvider';
|
import LineageProvider, { useLineageProvider } from './LineageProvider';
|
||||||
|
|
||||||
const mockLocation = {
|
const mockLocation = {
|
||||||
@ -128,9 +131,16 @@ jest.mock(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
let mockIsAlertSupported = false;
|
||||||
|
jest.mock('../../utils/TableClassBase', () => ({
|
||||||
|
getAlertEnableStatus: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(() => mockIsAlertSupported),
|
||||||
|
}));
|
||||||
|
|
||||||
jest.mock('../../rest/lineageAPI', () => ({
|
jest.mock('../../rest/lineageAPI', () => ({
|
||||||
getLineageDataByFQN: jest.fn(),
|
getLineageDataByFQN: jest.fn(),
|
||||||
|
getDataQualityLineage: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('LineageProvider', () => {
|
describe('LineageProvider', () => {
|
||||||
@ -148,6 +158,38 @@ describe('LineageProvider', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(getLineageDataByFQN).toHaveBeenCalled();
|
expect(getLineageDataByFQN).toHaveBeenCalled();
|
||||||
|
expect(getDataQualityLineage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getDataQualityLineage should be called if alert is supported', async () => {
|
||||||
|
mockIsAlertSupported = true;
|
||||||
|
(getLineageDataByFQN as jest.Mock).mockImplementationOnce(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
render(
|
||||||
|
<LineageProvider>
|
||||||
|
<DummyChildrenComponent />
|
||||||
|
</LineageProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getLineageDataByFQN).toHaveBeenCalledWith(
|
||||||
|
'table1',
|
||||||
|
'table',
|
||||||
|
{ downstreamDepth: 1, nodesPerLayer: 50, upstreamDepth: 1 },
|
||||||
|
''
|
||||||
|
);
|
||||||
|
expect(getDataQualityLineage).toHaveBeenCalledWith(
|
||||||
|
'table1',
|
||||||
|
{ downstreamDepth: 1, nodesPerLayer: 50, upstreamDepth: 1 },
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
mockIsAlertSupported = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call loadChildNodesHandler', async () => {
|
it('should call loadChildNodesHandler', async () => {
|
||||||
|
@ -14,8 +14,9 @@ import Icon from '@ant-design/icons/lib/components/Icon';
|
|||||||
import { Button, Modal } from 'antd';
|
import { Button, Modal } from 'antd';
|
||||||
import { AxiosError } from 'axios';
|
import { AxiosError } from 'axios';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { isEqual, isUndefined, uniqueId, uniqWith } from 'lodash';
|
import { isEqual, isUndefined, uniq, uniqueId, uniqWith } from 'lodash';
|
||||||
import { LoadingState } from 'Models';
|
import { LoadingState } from 'Models';
|
||||||
|
import QueryString from 'qs';
|
||||||
import React, {
|
import React, {
|
||||||
createContext,
|
createContext,
|
||||||
DragEvent,
|
DragEvent,
|
||||||
@ -76,8 +77,13 @@ import {
|
|||||||
LineageDetails,
|
LineageDetails,
|
||||||
} from '../../generated/type/entityLineage';
|
} from '../../generated/type/entityLineage';
|
||||||
import { useApplicationStore } from '../../hooks/useApplicationStore';
|
import { useApplicationStore } from '../../hooks/useApplicationStore';
|
||||||
|
import useCustomLocation from '../../hooks/useCustomLocation/useCustomLocation';
|
||||||
import { useFqn } from '../../hooks/useFqn';
|
import { useFqn } from '../../hooks/useFqn';
|
||||||
import { getLineageDataByFQN, updateLineageEdge } from '../../rest/lineageAPI';
|
import {
|
||||||
|
getDataQualityLineage,
|
||||||
|
getLineageDataByFQN,
|
||||||
|
updateLineageEdge,
|
||||||
|
} from '../../rest/lineageAPI';
|
||||||
import {
|
import {
|
||||||
addLineageHandler,
|
addLineageHandler,
|
||||||
centerNodePosition,
|
centerNodePosition,
|
||||||
@ -104,6 +110,7 @@ import {
|
|||||||
removeLineageHandler,
|
removeLineageHandler,
|
||||||
} from '../../utils/EntityLineageUtils';
|
} from '../../utils/EntityLineageUtils';
|
||||||
import { getEntityReferenceFromEntity } from '../../utils/EntityUtils';
|
import { getEntityReferenceFromEntity } from '../../utils/EntityUtils';
|
||||||
|
import tableClassBase from '../../utils/TableClassBase';
|
||||||
import { showErrorToast } from '../../utils/ToastUtils';
|
import { showErrorToast } from '../../utils/ToastUtils';
|
||||||
import { useTourProvider } from '../TourProvider/TourProvider';
|
import { useTourProvider } from '../TourProvider/TourProvider';
|
||||||
import {
|
import {
|
||||||
@ -117,6 +124,7 @@ export const LineageContext = createContext({} as LineageContextType);
|
|||||||
const LineageProvider = ({ children }: LineageProviderProps) => {
|
const LineageProvider = ({ children }: LineageProviderProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { fqn: decodedFqn } = useFqn();
|
const { fqn: decodedFqn } = useFqn();
|
||||||
|
const location = useCustomLocation();
|
||||||
const { isTourOpen, isTourPage } = useTourProvider();
|
const { isTourOpen, isTourPage } = useTourProvider();
|
||||||
const { appPreferences } = useApplicationStore();
|
const { appPreferences } = useApplicationStore();
|
||||||
const defaultLineageConfig = appPreferences?.lineageConfig as LineageSettings;
|
const defaultLineageConfig = appPreferences?.lineageConfig as LineageSettings;
|
||||||
@ -180,6 +188,15 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
|
|||||||
const [paginationData, setPaginationData] = useState({});
|
const [paginationData, setPaginationData] = useState({});
|
||||||
const { showModal } = useEntityExportModalProvider();
|
const { showModal } = useEntityExportModalProvider();
|
||||||
|
|
||||||
|
const lineageLayer = useMemo(() => {
|
||||||
|
const param = location.search;
|
||||||
|
const searchData = QueryString.parse(
|
||||||
|
param.startsWith('?') ? param.substring(1) : param
|
||||||
|
);
|
||||||
|
|
||||||
|
return searchData.layers as LineageLayer[] | undefined;
|
||||||
|
}, [location.search]);
|
||||||
|
|
||||||
const initLineageChildMaps = useCallback(
|
const initLineageChildMaps = useCallback(
|
||||||
(
|
(
|
||||||
lineageData: EntityLineageResponse,
|
lineageData: EntityLineageResponse,
|
||||||
@ -220,25 +237,63 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
|
|||||||
config,
|
config,
|
||||||
queryFilter
|
queryFilter
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const dqLineageResp =
|
||||||
|
entityType === EntityType.TABLE &&
|
||||||
|
tableClassBase.getAlertEnableStatus()
|
||||||
|
? await getDataQualityLineage(fqn, config, queryFilter)
|
||||||
|
: { nodes: [], edges: [] };
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
const { nodes = [], entity } = res;
|
const { nodes = [], entity, edges } = res;
|
||||||
const allNodes = uniqWith(
|
const allNodes = uniqWith(
|
||||||
[...nodes, entity].filter(Boolean),
|
[...nodes, entity].filter(Boolean),
|
||||||
isEqual
|
isEqual
|
||||||
);
|
).map((node) => {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
isDqTestFailure:
|
||||||
|
dqLineageResp.nodes?.some((dqNode) => dqNode.id === node.id) ??
|
||||||
|
false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedEntity = {
|
||||||
|
...entity,
|
||||||
|
isDqTestFailure:
|
||||||
|
dqLineageResp.nodes?.some((dqNode) => dqNode.id === entity.id) ??
|
||||||
|
false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedEdges = edges?.map((edge) => {
|
||||||
|
return {
|
||||||
|
...edge,
|
||||||
|
isDqTestFailure:
|
||||||
|
dqLineageResp.edges?.some(
|
||||||
|
(dqEdge) => dqEdge?.doc_id === edge?.doc_id
|
||||||
|
) ?? false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
entityType !== EntityType.PIPELINE &&
|
entityType !== EntityType.PIPELINE &&
|
||||||
entityType !== EntityType.STORED_PROCEDURE
|
entityType !== EntityType.STORED_PROCEDURE
|
||||||
) {
|
) {
|
||||||
const { map: childMapObj } = getChildMap(
|
const { map: childMapObj } = getChildMap(
|
||||||
{ ...res, nodes: allNodes },
|
{
|
||||||
|
...res,
|
||||||
|
nodes: allNodes,
|
||||||
|
edges: updatedEdges,
|
||||||
|
entity: updatedEntity,
|
||||||
|
},
|
||||||
decodedFqn
|
decodedFqn
|
||||||
);
|
);
|
||||||
setChildMap(childMapObj);
|
setChildMap(childMapObj);
|
||||||
const { nodes: newNodes, edges: newEdges } = getPaginatedChildMap(
|
const { nodes: newNodes, edges: newEdges } = getPaginatedChildMap(
|
||||||
{
|
{
|
||||||
...res,
|
...res,
|
||||||
|
entity: updatedEntity,
|
||||||
|
edges: updatedEdges,
|
||||||
nodes: allNodes,
|
nodes: allNodes,
|
||||||
},
|
},
|
||||||
childMapObj,
|
childMapObj,
|
||||||
@ -248,12 +303,14 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
|
|||||||
|
|
||||||
setEntityLineage({
|
setEntityLineage({
|
||||||
...res,
|
...res,
|
||||||
|
entity: updatedEntity,
|
||||||
nodes: newNodes,
|
nodes: newNodes,
|
||||||
edges: [...(res.edges ?? []), ...newEdges],
|
edges: [...(updatedEdges ?? []), ...newEdges],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setEntityLineage({
|
setEntityLineage({
|
||||||
...res,
|
...res,
|
||||||
|
entity: updatedEntity,
|
||||||
nodes: allNodes,
|
nodes: allNodes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1308,6 +1365,12 @@ const LineageProvider = ({ children }: LineageProviderProps) => {
|
|||||||
}
|
}
|
||||||
}, [isTourOpen, isTourPage]);
|
}, [isTourOpen, isTourPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lineageLayer) {
|
||||||
|
setActiveLayer((pre) => uniq([...lineageLayer, ...pre]));
|
||||||
|
}
|
||||||
|
}, [lineageLayer]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LineageContext.Provider value={activityFeedContextValues}>
|
<LineageContext.Provider value={activityFeedContextValues}>
|
||||||
<div
|
<div
|
||||||
|
@ -48,7 +48,8 @@ export const getLineageDataByFQN = async (
|
|||||||
|
|
||||||
export const getDataQualityLineage = async (
|
export const getDataQualityLineage = async (
|
||||||
fqn: string,
|
fqn: string,
|
||||||
config?: Partial<LineageConfig>
|
config?: Partial<LineageConfig>,
|
||||||
|
queryFilter?: string
|
||||||
) => {
|
) => {
|
||||||
const { upstreamDepth = 1 } = config ?? {};
|
const { upstreamDepth = 1 } = config ?? {};
|
||||||
const response = await APIClient.get<EntityLineageResponse>(
|
const response = await APIClient.get<EntityLineageResponse>(
|
||||||
@ -58,6 +59,7 @@ export const getDataQualityLineage = async (
|
|||||||
fqn,
|
fqn,
|
||||||
upstreamDepth,
|
upstreamDepth,
|
||||||
includeDeleted: false,
|
includeDeleted: false,
|
||||||
|
query_filter: queryFilter,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user