Skip to content

Commit

Permalink
Datasets graph (apache#25707)
Browse files Browse the repository at this point in the history
* Add dependencies graph to datasets page.
Temporarily uses an exact uri lookup until get_dataset() updates.

* improve node width calculation

* dep data as separate endpoint

* move data transformations to api hook

* add center button

* fix test

Co-authored-by: Daniel Standish <[email protected]>
  • Loading branch information
bbovenzi and dstandish authored Aug 23, 2022
1 parent 9a5d83d commit aa20ba2
Show file tree
Hide file tree
Showing 18 changed files with 815 additions and 128 deletions.
6 changes: 6 additions & 0 deletions airflow/www/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"terser-webpack-plugin": "<5.0.0",
"typescript": "^4.6.3",
"url-loader": "4.1.0",
"web-worker": "^1.2.0",
"webpack": "^5.73.0",
"webpack-cli": "^4.0.0",
"webpack-license-plugin": "^4.2.1",
Expand All @@ -83,6 +84,10 @@
"@emotion/cache": "^11.9.3",
"@emotion/react": "^11.9.3",
"@emotion/styled": "^11",
"@visx/group": "^2.10.0",
"@visx/marker": "^2.12.2",
"@visx/shape": "^2.12.2",
"@visx/zoom": "^2.10.0",
"axios": "^0.26.0",
"bootstrap-3-typeahead": "^4.0.2",
"camelcase-keys": "^7.0.0",
Expand All @@ -94,6 +99,7 @@
"dagre-d3": "^0.6.4",
"datatables.net": "^1.11.4",
"datatables.net-bs": "^1.11.4",
"elkjs": "^0.7.1",
"eonasdan-bootstrap-datetimepicker": "^4.17.47",
"framer-motion": "^6.0.0",
"jquery": ">=3.5.0",
Expand Down
2 changes: 2 additions & 0 deletions airflow/www/static/js/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import useGridData from './useGridData';
import useMappedInstances from './useMappedInstances';
import useDatasets from './useDatasets';
import useDataset from './useDataset';
import useDatasetDependencies from './useDatasetDependencies';
import useDatasetEvents from './useDatasetEvents';
import useUpstreamDatasetEvents from './useUpstreamDatasetEvents';
import useTaskInstance from './useTaskInstance';
Expand All @@ -49,6 +50,7 @@ export {
useClearTask,
useConfirmMarkTask,
useDataset,
useDatasetDependencies,
useDatasetEvents,
useDatasets,
useExtraLinks,
Expand Down
107 changes: 107 additions & 0 deletions airflow/www/static/js/api/useDatasetDependencies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 axios, { AxiosResponse } from 'axios';
import { useQuery } from 'react-query';
import ELK, { ElkShape, ElkExtendedEdge } from 'elkjs';

import { getMetaValue } from 'src/utils';
import type { DepEdge, DepNode } from 'src/types';
import type { NodeType } from 'src/datasets/Graph/Node';

interface DatasetDependencies {
edges: DepEdge[];
nodes: DepNode[];
}

interface GenerateProps {
nodes: DepNode[];
edges: DepEdge[];
font: string;
}

interface Data extends ElkShape {
children: NodeType[];
edges: ElkExtendedEdge[];
}

// Take text and font to calculate how long each node should be
function getTextWidth(text: string, font: string) {
const context = document.createElement('canvas').getContext('2d');
if (context) {
context.font = font;
const metrics = context.measureText(text);
return metrics.width;
}
return text.length * 9;
}

const generateGraph = ({ nodes, edges, font }: GenerateProps) => ({
id: 'root',
layoutOptions: {
'spacing.nodeNodeBetweenLayers': '40.0',
'spacing.edgeNodeBetweenLayers': '10.0',
'layering.strategy': 'INTERACTIVE',
algorithm: 'layered',
'spacing.edgeEdgeBetweenLayers': '10.0',
'spacing.edgeNode': '10.0',
'spacing.edgeEdge': '10.0',
'spacing.nodeNode': '20.0',
'elk.direction': 'DOWN',
},
children: nodes.map(({ id, value }) => ({
id,
// calculate text width and add space for padding/icon
width: getTextWidth(value.label, font) + 36,
height: 40,
value,
})),
edges: edges.map((e) => ({ id: `${e.u}-${e.v}`, sources: [e.u], targets: [e.v] })),
});

const formatDependencies = async ({ edges, nodes }: DatasetDependencies) => {
const elk = new ELK();

// get computed style to calculate how large each node should be
const font = `bold ${16}px ${window.getComputedStyle(document.body).fontFamily}`;

// Make sure we only show edges that are connected to two nodes.
const newEdges = edges.filter((e) => {
const edgeNodes = nodes.filter((n) => n.id === e.u || n.id === e.v);
return edgeNodes.length === 2;
});

// Then filter out any nodes without an edge.
const newNodes = nodes.filter((n) => newEdges.some((e) => e.u === n.id || e.v === n.id));

// Finally generate the graph data with elk
const data = await elk.layout(generateGraph({ nodes: newNodes, edges: newEdges, font }));
return data as Data;
};

export default function useDatasetDependencies() {
return useQuery(
'datasetDependencies',
async () => {
const datasetDepsUrl = getMetaValue('dataset_dependencies_url');
const rawData = await axios.get<AxiosResponse, DatasetDependencies>(datasetDepsUrl);
return formatDependencies(rawData);
},
);
}
19 changes: 14 additions & 5 deletions airflow/www/static/js/api/useDatasets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,26 @@ interface Props {
limit?: number;
offset?: number;
order?: string;
uri?: string;
}

export default function useDatasets({ limit, offset, order }: Props) {
export default function useDatasets({
limit, offset, order, uri,
}: Props) {
const query = useQuery(
['datasets', limit, offset, order],
['datasets', limit, offset, order, uri],
() => {
const datasetsUrl = getMetaValue('datasets_api') || '/api/v1/datasets';
const orderParam = order ? { order_by: order } : {};
return axios.get<AxiosResponse, DatasetsData>(datasetsUrl, {
params: { offset, limit, ...orderParam },
});
const uriParam = uri ? { uri_pattern: uri } : {};
return axios.get<AxiosResponse, DatasetsData>(
datasetsUrl,
{
params: {
offset, limit, ...orderParam, ...uriParam,
},
},
);
},
{
keepPreviousData: true,
Expand Down
2 changes: 1 addition & 1 deletion airflow/www/static/js/components/Table/Cells.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const DatasetLink = ({ cell: { value } }: CellProps) => {
return (
<Link
color="blue.600"
href={`${datasetsUrl}?dataset_uri=${encodeURIComponent(value)}`}
href={`${datasetsUrl}?uri=${encodeURIComponent(value)}`}
>
{value}
</Link>
Expand Down
56 changes: 8 additions & 48 deletions airflow/www/static/js/datasets/Details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,78 +19,38 @@

import React from 'react';
import {
Box, Heading, Flex, Text, Spinner, Button, Link,
Box, Heading, Flex, Spinner, Button,
} from '@chakra-ui/react';

import { useDataset } from 'src/api';
import { ClipboardButton } from 'src/components/Clipboard';
import InfoTooltip from 'src/components/InfoTooltip';
import { getMetaValue } from 'src/utils';
import Events from './DatasetEvents';

interface Props {
datasetUri: string;
onBack: () => void;
}

const gridUrl = getMetaValue('grid_url');

const DatasetDetails = ({ datasetUri, onBack }: Props) => {
const { data: dataset, isLoading } = useDataset({ datasetUri });

return (
<Box mt={[6, 3]} maxWidth="1500px">
<Box mt={[6, 3]}>
<Button onClick={onBack}>See all datasets</Button>
{isLoading && <Spinner display="block" />}
<Box>
<Heading my={2} fontWeight="normal">
<Heading my={2} fontWeight="normal" size="lg">
Dataset:
{' '}
{datasetUri}
<ClipboardButton value={datasetUri} iconOnly ml={2} />
</Heading>
{dataset?.producingTasks && !!dataset.producingTasks.length && (
<Box mb={2}>
<Flex alignItems="center">
<Heading size="md" fontWeight="normal">Producing Tasks</Heading>
<InfoTooltip label="Tasks that will update this dataset." size={14} />
</Flex>
{dataset.producingTasks.map(({ dagId, taskId }) => (
<Link
key={`${dagId}.${taskId}`}
color="blue.600"
href={dagId ? gridUrl?.replace('__DAG_ID__', dagId) : ''}
display="block"
>
{`${dagId}.${taskId}`}
</Link>
))}
</Box>
)}
{dataset?.consumingDags && !!dataset.consumingDags.length && (
<Box>
<Flex alignItems="center">
<Heading size="md" fontWeight="normal">Consuming DAGs</Heading>
<InfoTooltip label="DAGs that depend on this dataset updating to trigger a run." size={14} />
</Flex>
{dataset.consumingDags.map(({ dagId }) => (
<Link
key={dagId}
color="blue.600"
href={dagId ? gridUrl?.replace('__DAG_ID__', dagId) : ''}
display="block"
>
{dagId}
</Link>
))}
</Box>
)}
</Box>
<Box>
<Heading size="lg" mt={3} mb={2} fontWeight="normal">History</Heading>
<Text>Whenever a DAG has updated this dataset.</Text>
</Box>
{dataset?.id && (
<Flex alignItems="center">
<Heading size="md" mt={3} mb={2} fontWeight="normal">History</Heading>
<InfoTooltip label="Whenever a DAG has updated this dataset." size={18} />
</Flex>
{dataset && dataset.id && (
<Events datasetId={dataset.id} />
)}
</Box>
Expand Down
80 changes: 80 additions & 0 deletions airflow/www/static/js/datasets/Graph/DagNode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*!
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 React from 'react';
import {
Flex,
Link,
Popover,
PopoverArrow,
PopoverBody,
PopoverCloseButton,
PopoverContent,
PopoverHeader,
PopoverTrigger,
Portal,
Text,
useTheme,
} from '@chakra-ui/react';
import { MdOutlineAccountTree } from 'react-icons/md';
import { useContainerRef } from 'src/context/containerRef';
import { getMetaValue } from 'src/utils';

const DagNode = ({
dagId,
isHighlighted,
}: { dagId: string, isHighlighted: boolean }) => {
const { colors } = useTheme();
const containerRef = useContainerRef();

const gridUrl = getMetaValue('grid_url').replace('__DAG_ID__', dagId);
return (
<Popover>
<PopoverTrigger>
<Flex
borderWidth={2}
borderColor={isHighlighted ? colors.blue[400] : undefined}
borderRadius={5}
p={2}
height="100%"
width="100%"
cursor="pointer"
fontSize={16}
justifyContent="space-between"
alignItems="center"
>
<MdOutlineAccountTree size="16px" />
<Text>{dagId}</Text>
</Flex>
</PopoverTrigger>
<Portal containerRef={containerRef}>
<PopoverContent bg="gray.100">
<PopoverArrow bg="gray.100" />
<PopoverCloseButton />
<PopoverHeader>{dagId}</PopoverHeader>
<PopoverBody>
<Link color="blue" href={gridUrl}>View DAG</Link>
</PopoverBody>
</PopoverContent>
</Portal>
</Popover>
);
};

export default DagNode;
Loading

0 comments on commit aa20ba2

Please sign in to comment.