Skip to content

Commit

Permalink
Add query mutations to new UI (apache#15068)
Browse files Browse the repository at this point in the history
* UI: add save and trigger dag mutations

* testing, names and table header

* use pipeline in url

* linting

* use humps.decamelize and duration var

* missing toast durations
  • Loading branch information
bbovenzi authored Mar 30, 2021
1 parent 7949226 commit 9ca49b6
Show file tree
Hide file tree
Showing 7 changed files with 400 additions and 32 deletions.
122 changes: 120 additions & 2 deletions airflow/ui/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,19 @@
*/

import axios, { AxiosResponse } from 'axios';
import { useQuery, setLogger } from 'react-query';
import {
useMutation, useQuery, useQueryClient, setLogger,
} from 'react-query';
import humps from 'humps';
import { useToast } from '@chakra-ui/react';

import type { Dag, DagRun, Version } from 'interfaces';
import type { DagsResponse, DagRunsResponse, TaskInstancesResponse } from 'interfaces/api';
import type {
DagsResponse,
DagRunsResponse,
TaskInstancesResponse,
TriggerRunRequest,
} from 'interfaces/api';

axios.defaults.baseURL = `${process.env.WEBSERVER_URL}/api/v1`;
axios.interceptors.response.use(
Expand All @@ -39,6 +47,7 @@ setLogger({
error: isTest ? () => {} : console.warn,
});

const toastDuration = 3000;
const refetchInterval = isTest ? false : 1000;

export function useDags() {
Expand Down Expand Up @@ -75,3 +84,112 @@ export function useVersion() {
(): Promise<Version> => axios.get('/version'),
);
}

export function useTriggerRun(dagId: Dag['dagId']) {
const queryClient = useQueryClient();
const toast = useToast();
return useMutation(
(trigger: TriggerRunRequest) => axios.post(`dags/${dagId}/dagRuns`, humps.decamelizeKeys(trigger)),
{
onSettled: (res, error) => {
if (error) {
toast({
title: 'Error triggering DAG',
description: (error as Error).message,
status: 'error',
duration: toastDuration,
isClosable: true,
});
} else {
toast({
title: 'DAG Triggered',
status: 'success',
duration: toastDuration,
isClosable: true,
});
const dagRunData = queryClient.getQueryData(['dagRun', dagId]) as unknown as DagRunsResponse;
if (dagRunData) {
queryClient.setQueryData(['dagRun', dagId], {
dagRuns: [...dagRunData.dagRuns, res],
totalEntries: dagRunData.totalEntries += 1,
});
} else {
queryClient.setQueryData(['dagRun', dagId], {
dagRuns: [res],
totalEntries: 1,
});
}
}
queryClient.invalidateQueries(['dagRun', dagId]);
},
},
);
}

export function useSaveDag(dagId: Dag['dagId']) {
const queryClient = useQueryClient();
const toast = useToast();
return useMutation(
(updatedValues: Record<string, any>) => axios.patch(`dags/${dagId}`, humps.decamelizeKeys(updatedValues)),
{
onMutate: async (updatedValues: Record<string, any>) => {
await queryClient.cancelQueries(['dag', dagId]);
const previousDag = queryClient.getQueryData(['dag', dagId]) as Dag;
const previousDags = queryClient.getQueryData('dags') as DagsResponse;

const newDags = previousDags.dags.map((dag) => (
dag.dagId === dagId ? { ...dag, ...updatedValues } : dag
));
const newDag = {
...previousDag,
...updatedValues,
};

// optimistically set the dag before the async request
queryClient.setQueryData(['dag', dagId], () => newDag);
queryClient.setQueryData('dags', (old) => ({
...(old as Dag[]),
...{
dags: newDags,
totalEntries: previousDags.totalEntries,
},
}));
return { [dagId]: previousDag, dags: previousDags };
},
onSettled: (res, error, variables, context) => {
const previousDag = (context as any)[dagId] as Dag;
const previousDags = (context as any).dags as DagsResponse;
// rollback to previous cache on error
if (error) {
queryClient.setQueryData(['dag', dagId], previousDag);
queryClient.setQueryData('dags', previousDags);
toast({
title: 'Error updating pipeline',
description: (error as Error).message,
status: 'error',
duration: toastDuration,
isClosable: true,
});
} else {
// check if server response is different from our optimistic update
if (JSON.stringify(res) !== JSON.stringify(previousDag)) {
queryClient.setQueryData(['dag', dagId], res);
queryClient.setQueryData('dags', {
dags: previousDags.dags.map((dag) => (
dag.dagId === dagId ? res : dag
)),
totalEntries: previousDags.totalEntries,
});
}
toast({
title: 'Pipeline Updated',
status: 'success',
duration: toastDuration,
isClosable: true,
});
}
queryClient.invalidateQueries(['dag', dagId]);
},
},
);
}
83 changes: 83 additions & 0 deletions airflow/ui/src/components/TriggerRunModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*!
* 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, { ChangeEvent, useState } from 'react';
import {
Button,
FormControl,
FormLabel,
Modal,
ModalHeader,
ModalFooter,
ModalCloseButton,
ModalOverlay,
ModalContent,
ModalBody,
Textarea,
} from '@chakra-ui/react';

import type { Dag } from 'interfaces';
import { useTriggerRun } from 'api';

interface Props {
dagId: Dag['dagId'];
isOpen: boolean;
onClose: () => void;
}

const TriggerRunModal: React.FC<Props> = ({ dagId, isOpen, onClose }) => {
const mutation = useTriggerRun(dagId);
const [config, setConfig] = useState('{}');

const onTrigger = () => {
mutation.mutate({
conf: JSON.parse(config),
executionDate: new Date(),
});
onClose();
};

return (
<Modal size="lg" isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>
Trigger Run:
{' '}
{dagId}
</ModalHeader>
<ModalCloseButton />
<ModalBody>
<FormControl>
<FormLabel htmlFor="configuration">Configuration JSON (Optional)</FormLabel>
<Textarea name="configuration" value={config} onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setConfig(e.target.value)} />
</FormControl>
</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button ml={2} onClick={onTrigger}>
Trigger
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};

export default TriggerRunModal;
7 changes: 7 additions & 0 deletions airflow/ui/src/interfaces/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,10 @@ export interface DagRunsResponse extends Entries {
export interface TaskInstancesResponse extends Entries {
taskInstances: TaskInstance[];
}

export interface TriggerRunRequest {
conf: Record<string, any>;
dagRunId?: string;
executionDate: Date;
state?: 'success' | 'running' | 'failed';
}
29 changes: 29 additions & 0 deletions airflow/ui/src/utils/memo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*!
* 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 type { PropsWithChildren } from 'react';

const compareObjectProps = (
prevProps: PropsWithChildren<any>,
nextProps: PropsWithChildren<any>,
) => (
JSON.stringify(prevProps) === JSON.stringify(nextProps)
);

export default compareObjectProps;
115 changes: 115 additions & 0 deletions airflow/ui/src/views/Pipelines/Row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*!
* 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 { Link as RouterLink } from 'react-router-dom';
import {
Flex,
Link,
Tr,
Td,
Tag,
Tooltip,
useColorModeValue,
Switch,
useDisclosure,
IconButton,
} from '@chakra-ui/react';

import TriggerRunModal from 'components/TriggerRunModal';
import compareObjectProps from 'utils/memo';
import type { Dag, DagTag } from 'interfaces';
import { useSaveDag } from 'api';
import { MdPlayArrow } from 'react-icons/md';

interface Props {
dag: Dag;
}

const Row: React.FC<Props> = ({ dag }) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const mutation = useSaveDag(dag.dagId);
const togglePaused = () => mutation.mutate({ isPaused: !dag.isPaused });

const oddColor = useColorModeValue('gray.50', 'gray.900');
const hoverColor = useColorModeValue('gray.100', 'gray.700');

return (
<Tr
_odd={{ backgroundColor: oddColor }}
_hover={{ backgroundColor: hoverColor }}
>
<Td onClick={(e) => e.stopPropagation()} paddingRight="0" width="58px">
<Tooltip
label={dag.isPaused ? 'Activate DAG' : 'Pause DAG'}
aria-label={dag.isPaused ? 'Activate DAG' : 'Pause DAG'}
hasArrow
>
{/* span helps tooltip find its position */}
<span>
<Switch
role="switch"
isChecked={!dag.isPaused}
onChange={togglePaused}
/>
</span>
</Tooltip>
</Td>
<Td>
<Flex alignItems="center">
<Link
as={RouterLink}
to={`/pipelines/${dag.dagId}`}
fontWeight="bold"
>
{dag.dagId}
</Link>
{dag.tags.map((tag: DagTag) => (
<Tag
size="sm"
mt="1"
ml="1"
mb="1"
key={tag.name}
>
{tag.name}
</Tag>
))}
</Flex>
</Td>
<Td textAlign="right">
<Tooltip
label="Trigger DAG"
aria-label="Trigger DAG"
hasArrow
>
<IconButton
size="sm"
aria-label="Trigger Dag"
icon={<MdPlayArrow />}
onClick={onToggle}
/>
</Tooltip>
<TriggerRunModal dagId={dag.dagId} isOpen={isOpen} onClose={onClose} />
</Td>
</Tr>
);
};

export default React.memo(Row, compareObjectProps);
Loading

0 comments on commit 9ca49b6

Please sign in to comment.