diff --git a/superset-frontend/src/features/mitmDatasets/MitMDatasetModal/CollectionTable.tsx b/superset-frontend/src/features/mitmDatasets/MitMDatasetModal/CollectionTable.tsx index 22e9df44f3a305d0afba8489d84b309f03a090e4..06eb4ccc88338cf23a2b30ce6754695be4338c0f 100644 --- a/superset-frontend/src/features/mitmDatasets/MitMDatasetModal/CollectionTable.tsx +++ b/superset-frontend/src/features/mitmDatasets/MitMDatasetModal/CollectionTable.tsx @@ -31,7 +31,7 @@ import { t, styled } from '@superset-ui/core'; import Button from 'src/components/Button'; import Icons from 'src/components/Icons'; import Fieldset from './Fieldset'; -import { recurseReactClone } from './utils'; +import { recurseReactClone } from '../utils'; interface CRUDCollectionProps { allowAddItem?: boolean; diff --git a/superset-frontend/src/features/mitmDatasets/MitMDatasetModal/Fieldset.tsx b/superset-frontend/src/features/mitmDatasets/MitMDatasetModal/Fieldset.tsx index 3d93f37ba5cadac7c9ec9261f95bc74ad9f0539c..eebff01d4241f399c76037338994ae42e98ac694 100644 --- a/superset-frontend/src/features/mitmDatasets/MitMDatasetModal/Fieldset.tsx +++ b/superset-frontend/src/features/mitmDatasets/MitMDatasetModal/Fieldset.tsx @@ -16,10 +16,10 @@ * specific language governing permissions and limitations * under the License. */ -import { ReactNode, PureComponent } from 'react'; +import { PureComponent, ReactNode } from 'react'; import { Form } from 'src/components/Form'; -import { recurseReactClone } from './utils'; +import { recurseReactClone } from '../utils'; import Field from './Field'; interface FieldsetProps { diff --git a/superset-frontend/src/features/mitmDatasets/UploadMitMDatasetModal/index.tsx b/superset-frontend/src/features/mitmDatasets/UploadMitMDatasetModal/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a08d2a1d0fc6b570f91492b588c280fb8242a6c2 --- /dev/null +++ b/superset-frontend/src/features/mitmDatasets/UploadMitMDatasetModal/index.tsx @@ -0,0 +1,192 @@ +import { FunctionComponent, useEffect, useState } from 'react'; +import { MITM } from '../types'; +import withToasts from '../../../components/MessageToasts/withToasts'; +import Modal from '../../../components/Modal'; +import { ClientErrorObject, SupersetTheme, t } from '@superset-ui/core'; +import { + antDModalNoPaddingStyles, + antDModalStyles, + formStyles, +} from '../../databases/UploadDataModel/styles'; +import { Form, Input, Select, Upload } from 'antd-v5'; +import { AsyncActionResult, useExternalService } from '../asyncExternalService'; +import { Button } from '../../../components'; +import { InboxOutlined, UploadOutlined } from '@ant-design/icons'; + +interface UploadMitMDatasetModalProps { + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; + onHide: () => void; + show: boolean; + allowedExtensions: string[]; + mitm: MITM; +} + +interface UploadMitMDatasetRequestFormData { + mitm: MITM; + datasetName: string; + mitmZip: File | Blob; +} + +const UploadMitMDatasetModal: FunctionComponent< + UploadMitMDatasetModalProps +> = ({ addDangerToast, addSuccessToast, onHide, show, allowedExtensions }) => { + const { asyncAction } = useExternalService(); + + // const [mitm, setMitm] = useState<MITM | null>(null); + // const [datasetName, setDatasetName] = useState<string | null>(null); + // const [fileList, setFileList] = useState<UploadFile[]>([]); + const [isLoading, setIsLoading] = useState<boolean>(false); + const [error, setError] = useState<ClientErrorObject>(); + + const [form] = Form.useForm(); + + const onSuccess = ( + r: AsyncActionResult, + { + mitm, + datasetName, + }: Pick<UploadMitMDatasetRequestFormData, 'mitm' | 'datasetName'>, + ) => { + addSuccessToast(t('Successfully uploaded \"%s\" (%s)', datasetName, mitm)); + }; + const onFailure = (error: ClientErrorObject) => { + setError(error); + }; + + useEffect(() => { + addDangerToast(t('An error occurred when attempting to upload: %s', error)); + }, [error]); + + const clearModal = () => { + // setDatasetName(null); + // setMitm(null); + // setFileList([]); + setIsLoading(false); + form.resetFields(); + }; + + const makeRequest = ({ + datasetName, + mitm, + mitmZip, + }: UploadMitMDatasetRequestFormData) => { + const formData = new FormData(); + formData.append('dataset_name', datasetName); + formData.append('mitm', String(mitm)); + formData.append('mitm_zip', mitmZip); + + setIsLoading(true); + asyncAction({ + endpoint: '/api/v1/mitm_dataset/upload/', + headers: { 'Content-Type': 'multipart/form-data' }, + postPayload: formData, + }) + .then(r => { + console.log(r); + onSuccess(r, { datasetName, mitm }); + }) + .catch(onFailure) + .finally(() => setIsLoading(false)); + }; + + const onFinish = (values: any) => { + const fields = form.getFieldsValue(); + + const mitmZip = fields.fileList[0]?.originFileObj; + + const mitm = fields.mitm as MITM; + makeRequest({ datasetName: fields.datasetName, mitm, mitmZip }); + }; + + const onClose = () => { + clearModal(); + onHide(); + }; + + const extractFileList = (e: any) => { + console.log('Upload event:', e); + if (Array.isArray(e)) { + return e; + } + return e?.fileList; + }; + + return ( + <Modal + css={(theme: SupersetTheme) => [ + antDModalNoPaddingStyles, + antDModalStyles(theme), + formStyles(theme), + ]} + primaryButtonLoading={isLoading} + name="database" + data-test="upload-modal" + onHandledPrimaryAction={form.submit} + onHide={onClose} + width="500px" + primaryButtonName="Upload" + centered + show={show} + title="Upload MitM Dataset" + > + <Form form={form} onFinish={onFinish}> + <Form.Item + name="datasetName" + label="Dataset Name" + rules={[{ required: true }]} + > + <Input /> + </Form.Item> + <Form.Item + name="mitm" + label="MitM Name" + rules={[{ required: true }, { enum: [MITM.MAED] }]} + > + <Select + placeholder="Select the MitM Name" + allowClear + options={[{ name: 'MAED', value: 'MAED' }]} + ></Select> + </Form.Item> + <Form.Item + name="upload" + label="Upload" + valuePropName="fileList" + getValueFromEvent={extractFileList} + extra="longgggggggggggggggggggggggggggggggggg" + > + <Upload + name="logo" + accept=".zip,.maed,.mitm" + customRequest={() => false} + > + <Button icon={<UploadOutlined />}>Click to upload</Button> + </Upload> + </Form.Item> + <Form.Item label="Dragger"> + <Form.Item + name="dragger" + valuePropName="fileList" + getValueFromEvent={extractFileList} + noStyle + > + <Upload.Dragger name="files"> + <p className="ant-upload-drag-icon"> + <InboxOutlined /> + </p> + <p className="ant-upload-text"> + Click or drag file to this area to upload + </p> + <p className="ant-upload-hint"> + Support for a single or bulk upload. + </p> + </Upload.Dragger> + </Form.Item> + </Form.Item> + </Form> + </Modal> + ); +}; + +export default withToasts(UploadMitMDatasetModal); diff --git a/superset-frontend/src/features/mitmDatasets/asyncExternalService.ts b/superset-frontend/src/features/mitmDatasets/asyncExternalService.ts new file mode 100644 index 0000000000000000000000000000000000000000..7296040a08cd5d46b8e81daaa3769d18e1ca1a6a --- /dev/null +++ b/superset-frontend/src/features/mitmDatasets/asyncExternalService.ts @@ -0,0 +1,146 @@ +import { + ClientErrorObject, + getClientErrorObject, + JsonObject, + logging, + parseErrorJson, + RequestConfig, + SupersetClient, + SupersetError, +} from '@superset-ui/core'; +import {useAsyncEventHandling} from "../../middleware/asyncEvent"; + +const extServiceCallApiEndpoint = '/api/v1/ext_service/call'; + +export enum ExternalService { + MitMService = 'mitm_service', +} + +type AsyncJobMetadata = { + id?: string | null; + channel_id: string; + job_id: string; + user_id?: string; + status: string; + errors?: SupersetError[]; + result_url: string | null; + task_id?: string; // my addition +}; + +const JOB_STATUS = { + PENDING: 'pending', + RUNNING: 'running', + ERROR: 'error', + DONE: 'done', +}; + +const fetchJsonResult = async ( + asyncEvent: AsyncJobMetadata, // & { result_url: string } +): Promise<{ + status: 'success' | 'error'; + data: JsonObject | ClientErrorObject; +}> => { + try { + const { json } = await SupersetClient.get({ + endpoint: String(asyncEvent.result_url), + }); + return { status: 'success', data: 'result' in json ? json.result : json }; + } catch (response) { + const err = await getClientErrorObject(response); + return { status: 'error', data: err }; + } +}; + +export type AsyncActionResult = { + status: string; + lastEvent: AsyncJobMetadata; +}; + +const {addListener, removeListener} = useAsyncEventHandling() + +export const waitForAsyncData = async (asyncResponse: AsyncJobMetadata) => + new Promise<AsyncActionResult | JsonObject>((resolve, reject) => { + const jobId = asyncResponse.job_id; + const listener = async (asyncEvent: AsyncJobMetadata) => { + switch (asyncEvent.status) { + case JOB_STATUS.DONE: { + if ('result_url' in asyncEvent) { + let { data, status } = await fetchJsonResult(asyncEvent); + if (status === 'success') { + resolve(data); + } else { + reject(data); + } + } else { + resolve({ + status: JOB_STATUS.DONE, + lastEvent: asyncEvent, + } as AsyncActionResult); + } + break; + } + case JOB_STATUS.ERROR: { + const err = parseErrorJson(asyncEvent); + reject(err); + break; + } + default: { + logging.warn('received event with status', asyncEvent.status); + } + } + removeListener(jobId); + }; + addListener(jobId, listener); + }); + + +export const asyncCall = async (requestConfig: RequestConfig) => { + return SupersetClient.post({ + ...requestConfig, + }).then( + ({ response, json }) => + new Promise<AsyncActionResult | JsonObject>((resolve, reject) => { + { + if (response.status === 202) { + const jm: AsyncJobMetadata = json; + waitForAsyncData(jm).then(resolve).catch(reject); + } else { + const err = parseErrorJson(json); + reject(err); + } + } + }), + ); +}; + +export const asyncAction = async ( + requestConfig: RequestConfig, +): Promise<AsyncActionResult> => { + return asyncCall(requestConfig); +}; + +type CallExternalServiceProps = { + extService: ExternalService; + path: string; + payload?: JsonObject | FormData | undefined; +}; + +export const callExternalService = async ({ + extService, + path, + payload, +}: CallExternalServiceProps) => { + const endpoint = + extServiceCallApiEndpoint + '/' + String(extService) + '/' + path; + const contentType = + payload instanceof FormData ? 'multipart/form-data' : 'application/json'; + return asyncCall({ + endpoint, + headers: { 'Content-Type': contentType }, + postPayload: payload, + }); +}; + +export const useExternalService = () => { + return {asyncCall, asyncAction, callExternalService} +} diff --git a/superset-frontend/src/features/mitmDatasets/types.ts b/superset-frontend/src/features/mitmDatasets/types.ts index a81dc5f14e5e765b754b4b8fc81d989ec364a6cf..132de6df1a00ccd0027e5cd2b74f748367546557 100644 --- a/superset-frontend/src/features/mitmDatasets/types.ts +++ b/superset-frontend/src/features/mitmDatasets/types.ts @@ -25,3 +25,7 @@ export interface MitMDataset slices?: Array<Slice>; dashboards?: Array<Dashboard>; } + +export enum MITM { + MAED = 'MAED' +} diff --git a/superset-frontend/src/features/mitmDatasets/utils/index.js b/superset-frontend/src/features/mitmDatasets/utils/index.js new file mode 100644 index 0000000000000000000000000000000000000000..be1972cd19480d7bd4617ceeaded09838818c37a --- /dev/null +++ b/superset-frontend/src/features/mitmDatasets/utils/index.js @@ -0,0 +1,24 @@ +import {Children, cloneElement} from "react"; + +export function recurseReactClone(children, type, propExtender) { + /** + * Clones a React component's children, and injects new props + * where the type specified is matched. + */ + return Children.map(children, child => { + let newChild = child; + if (child && child.type.name === type.name) { + newChild = cloneElement(child, propExtender(child)); + } + if (newChild && newChild.props.children) { + newChild = cloneElement(newChild, { + children: recurseReactClone( + newChild.props.children, + type, + propExtender, + ), + }); + } + return newChild; + }); +} diff --git a/superset-frontend/src/middleware/asyncEvent.ts b/superset-frontend/src/middleware/asyncEvent.ts index 0512e6817b32bd062154106fc79a00b8566b1de2..7bcf76f7f4cb7b6a0b2c6cf2f7c83b1a685c75ce 100644 --- a/superset-frontend/src/middleware/asyncEvent.ts +++ b/superset-frontend/src/middleware/asyncEvent.ts @@ -18,13 +18,13 @@ */ import { ensureIsArray, - isFeatureEnabled, FeatureFlag, - makeApi, - SupersetClient, - logging, getClientErrorObject, + isFeatureEnabled, + logging, + makeApi, parseErrorJson, + SupersetClient, SupersetError, } from '@superset-ui/core'; import getBootstrapData from 'src/utils/getBootstrapData'; @@ -76,6 +76,10 @@ const removeListener = (id: string) => { delete listenersByJobId[id]; }; +export const useAsyncEventHandling = () => { + return { addListener, removeListener }; +}; + const fetchCachedData = async ( asyncEvent: AsyncEvent, ): Promise<CachedDataResponse> => { diff --git a/superset-frontend/src/pages/MitMDatasetCreation/index.tsx b/superset-frontend/src/pages/MitMDatasetCreation/index.tsx index 8387a05fb1feafc4d0cb989423fee683cc910e2a..bc504cb99e03676e7c9912491b404db6c04460cb 100644 --- a/superset-frontend/src/pages/MitMDatasetCreation/index.tsx +++ b/superset-frontend/src/pages/MitMDatasetCreation/index.tsx @@ -1,16 +1,15 @@ import {useParams} from "react-router-dom"; -import {Reducer, useEffect, useReducer, useState} from "react"; -import Header from "../../features/datasets/AddDataset/Header"; +import {FunctionComponent, Reducer, useEffect, useReducer, useState} from "react"; import { MDSActionType, MDSReducerActionType, } from '../../features/mitmDatasets/AddMitMDataset/types'; -import {datasetReducer} from "../DatasetCreation"; -import useDatasetsList from "../../features/datasets/hooks/useDatasetLists"; import {t} from "@superset-ui/core"; import {MitMDatasetObject} from "../../features/mitmDatasets/types"; +import withToasts from 'src/components/MessageToasts/withToasts'; +// @ts-ignore const prevUrl = '/mitm_dataset/list/?pageIndex=0&sortColumn=changed_on_delta_humanized&sortOrder=desc'; @@ -39,13 +38,19 @@ export function mitmDatasetReducer( } } -export default function AddMitMDataset() { +interface AddMitMDatasetProps { + addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string) => void; +} + +const AddMitMDataset: FunctionComponent<AddMitMDatasetProps> = ({ addDangerToast, + addSuccessToast }: AddMitMDatasetProps) => { + // @ts-ignore const [dataset, setDataset] = useReducer< Reducer<Partial<MitMDatasetObject> | null, MDSReducerActionType> >(mitmDatasetReducer, null); const [editPageIsVisible, setEditPageIsVisible] = useState(false); - const { datasetId: id } = useParams<{ datasetId: string }>(); useEffect(() => { if (!Number.isNaN(parseInt(id, 10))) { @@ -53,13 +58,15 @@ export default function AddMitMDataset() { } }, [id]); const CREATE_DATASET_TEXT = t('You are trying to add a MitM Dataset.'); - + return ( <div> <h2>AddMitMDatasetPage</h2> <p>{dataset}</p> <p>{CREATE_DATASET_TEXT}</p> - {"editpageisvisible: " + editPageIsVisible} + {'editpageisvisible: ' + editPageIsVisible} </div> ); -} +}; + +export default withToasts(AddMitMDataset); diff --git a/superset-frontend/src/pages/MitMDatasetList/index.tsx b/superset-frontend/src/pages/MitMDatasetList/index.tsx index 9646caf3f078815dc17ab542db140bf4233661fb..a455f621adc9f97e08c7dd541174175ffbbfde2a 100644 --- a/superset-frontend/src/pages/MitMDatasetList/index.tsx +++ b/superset-frontend/src/pages/MitMDatasetList/index.tsx @@ -49,6 +49,7 @@ import { } from 'src/features/mitmDatasets/constants'; import { ModifiedInfo } from 'src/components/AuditInfo'; import MitMDatasetModal from "../../features/mitmDatasets/MitMDatasetModal"; +import UploadMitMDatasetModal from "../../features/mitmDatasets/UploadMitMDatasetModal"; const Actions = styled.div` color: ${({ theme }) => theme.colors.grayscale.base}; @@ -115,7 +116,10 @@ const MitMDatasetList: FunctionComponent<MitMDatasetListProps> = ({ const [datasetCurrentlyEditing, setDatasetCurrentlyEditing] = useState<MitMDataset | null>(null); + + const [importingDataset, showImportModal] = useState<boolean>(false); + const [uploadModalOpen, setUploadModalOpen] = useState<boolean>(false); const [preparingExport, setPreparingExport] = useState<boolean>(false); const openDatasetImportModal = () => { @@ -420,6 +424,20 @@ const MitMDatasetList: FunctionComponent<MitMDatasetListProps> = ({ buttonStyle: 'primary', }); + buttonArr.push({ + name: ( + <Tooltip + id="upload-tooltip" + title={t('Upload dataset')} + placement="bottomRight" + > + <Icons.Import data-test="upload-button" /> + </Tooltip> + ), + buttonStyle: 'link', + onClick: () => setUploadModalOpen(true), + }) + buttonArr.push({ name: ( <Tooltip @@ -486,6 +504,12 @@ const MitMDatasetList: FunctionComponent<MitMDatasetListProps> = ({ return ( <> <SubMenu {...menuData} /> + <UploadMitMDatasetModal + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + show={uploadModalOpen} + onHide={() => setUploadModalOpen(false)} + /> {datasetCurrentlyDeleting && ( <DeleteModal description={ @@ -573,6 +597,7 @@ const MitMDatasetList: FunctionComponent<MitMDatasetListProps> = ({ show={importingDataset} onHide={closeDatasetImportModal} /> + {preparingExport && <Loading />} </> ); diff --git a/superset/customization/external_service_support/forwardable_request.py b/superset/customization/external_service_support/forwardable_request.py index 45aec016911cc660c5db1985a1717b2e6179522f..da737e990d527938f754abc9b38305de44b9db4c 100644 --- a/superset/customization/external_service_support/forwardable_request.py +++ b/superset/customization/external_service_support/forwardable_request.py @@ -38,6 +38,7 @@ class ForwardableRequest(pydantic.BaseModel): if request.content_encoding == 'multipart/form-data': form_data = request.form.to_dict() files = {} + td = None if len(request.files) > 0 and save_files: td = tempfile.mkdtemp(prefix='forwarded_request') for n, f in request.files.items(): diff --git a/superset/views/mitm/views.py b/superset/views/mitm/views.py index b7b92d530f44ef6e562c7ecd149decf901873df8..d2badb42ded863e8c020a3b72e75212c44f2c1c2 100644 --- a/superset/views/mitm/views.py +++ b/superset/views/mitm/views.py @@ -20,3 +20,4 @@ class MitMDatasetView(BaseSupersetView): def list(self) -> FlaskResponse: return super().render_app_template() +