From 662088820b430abb2e4dcb9270cbaf62771ca601 Mon Sep 17 00:00:00 2001 From: eduardo aleixo Date: Wed, 3 Aug 2022 11:18:57 -0300 Subject: [PATCH] feat: upload any arbitrary data (collapsed/pprof/json) via adhoc ui (#1327) Allow "uploading"[0] pprof/collapsed (aka folded) via adhoc UI. * migrate to redux toolkit * migrate to typescript * fixes #1333 (Flamegraph is squeezed in adhoc comparison UI #1333) * have that same logic continuous have of being able to easily change from single/comparison/diff and keeping the same profile in memory --- pkg/adhoc/server/server.go | 2 +- webapp/javascript/components/FileList.jsx | 132 ------- .../components/FileList.module.scss | 1 - webapp/javascript/components/FileList.tsx | 141 +++++++ webapp/javascript/components/FileUploader.tsx | 126 ------ webapp/javascript/index.jsx | 6 +- webapp/javascript/models/adhoc.ts | 12 + webapp/javascript/pages/AdhocComparison.tsx | 173 -------- webapp/javascript/pages/AdhocDiff.tsx | 120 ------ webapp/javascript/pages/AdhocSingle.tsx | 102 ----- .../pages/{ => adhoc}/Adhoc.module.scss | 0 .../{ => adhoc}/AdhocComparison.module.scss | 0 .../pages/adhoc/AdhocComparison.tsx | 169 ++++++++ webapp/javascript/pages/adhoc/AdhocDiff.tsx | 143 +++++++ webapp/javascript/pages/adhoc/AdhocSingle.tsx | 96 +++++ .../components/FileUploader.module.scss | 0 .../pages/adhoc/components/FileUploader.tsx | 69 ++++ webapp/javascript/redux/actions.ts | 308 --------------- webapp/javascript/redux/hooks.ts | 8 - webapp/javascript/redux/reducers/adhoc.ts | 267 +++++++++++++ webapp/javascript/redux/reducers/filters.ts | 372 ------------------ webapp/javascript/redux/reducers/index.ts | 3 - webapp/javascript/redux/store.ts | 15 +- webapp/javascript/services/adhoc.ts | 123 ++++++ 24 files changed, 1036 insertions(+), 1352 deletions(-) delete mode 100644 webapp/javascript/components/FileList.jsx create mode 100644 webapp/javascript/components/FileList.tsx delete mode 100644 webapp/javascript/components/FileUploader.tsx create mode 100644 webapp/javascript/models/adhoc.ts delete mode 100644 webapp/javascript/pages/AdhocComparison.tsx delete mode 100644 webapp/javascript/pages/AdhocDiff.tsx delete mode 100644 webapp/javascript/pages/AdhocSingle.tsx rename webapp/javascript/pages/{ => adhoc}/Adhoc.module.scss (100%) rename webapp/javascript/pages/{ => adhoc}/AdhocComparison.module.scss (100%) create mode 100644 webapp/javascript/pages/adhoc/AdhocComparison.tsx create mode 100644 webapp/javascript/pages/adhoc/AdhocDiff.tsx create mode 100644 webapp/javascript/pages/adhoc/AdhocSingle.tsx rename webapp/javascript/{ => pages/adhoc}/components/FileUploader.module.scss (100%) create mode 100644 webapp/javascript/pages/adhoc/components/FileUploader.tsx delete mode 100644 webapp/javascript/redux/actions.ts create mode 100644 webapp/javascript/redux/reducers/adhoc.ts delete mode 100644 webapp/javascript/redux/reducers/filters.ts delete mode 100644 webapp/javascript/redux/reducers/index.ts create mode 100644 webapp/javascript/services/adhoc.ts diff --git a/pkg/adhoc/server/server.go b/pkg/adhoc/server/server.go index 3c6dc9d0f2..490a6ea8cd 100644 --- a/pkg/adhoc/server/server.go +++ b/pkg/adhoc/server/server.go @@ -55,7 +55,7 @@ func (s *server) AddRoutes(r *mux.Router) http.HandlerFunc { r.HandleFunc("/v1/profiles", s.Profiles) r.HandleFunc("/v1/profile/{id:[0-9a-f]+}", s.Profile) r.HandleFunc("/v1/diff/{left:[0-9a-f]+}/{right:[0-9a-f]+}", s.Diff) - r.HandleFunc("/v1/upload/", s.Upload) + r.HandleFunc("/v1/upload", s.Upload) r.HandleFunc("/v1/upload-diff/", s.UploadDiff) } return r.ServeHTTP diff --git a/webapp/javascript/components/FileList.jsx b/webapp/javascript/components/FileList.jsx deleted file mode 100644 index f29d054fd6..0000000000 --- a/webapp/javascript/components/FileList.jsx +++ /dev/null @@ -1,132 +0,0 @@ -import React, { useState, useMemo } from 'react'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; - -import Spinner from 'react-svg-spinner'; - -import classNames from 'classnames'; -import clsx from 'clsx'; -import styles from './FileList.module.scss'; -import CheckIcon from './CheckIcon'; - -const dateModifiedColName = 'updatedAt'; -const fileNameColName = 'name'; -const tableFormat = [ - { name: fileNameColName, label: 'Filename' }, - { name: dateModifiedColName, label: 'Date Modified' }, -]; - -function FileList(props) { - const { areProfilesLoading, profiles, profile, setProfile, className } = - props; - - const [sortBy, updateSortBy] = useState(dateModifiedColName); - const [sortByDirection, setSortByDirection] = useState('desc'); - - const isRowSelected = (id) => { - return profile === id; - }; - - const updateSortParams = (newSortBy) => { - let dir = sortByDirection; - if (sortBy === newSortBy) { - dir = dir === 'asc' ? 'desc' : 'asc'; - } else { - dir = 'desc'; - } - - updateSortBy(newSortBy); - setSortByDirection(dir); - }; - - const sortedProfilesIds = useMemo(() => { - const m = sortByDirection === 'asc' ? 1 : -1; - - let sorted = []; - - if (profiles) { - const filesInfo = Object.values(profiles); - - switch (sortBy) { - case fileNameColName: - sorted = filesInfo.sort( - (a, b) => m * a[sortBy].localeCompare(b[sortBy]) - ); - break; - case dateModifiedColName: - sorted = filesInfo.sort( - (a, b) => m * (new Date(a[sortBy]) - new Date(b[sortBy])) - ); - break; - default: - sorted = filesInfo; - } - } - - return sorted.reduce((acc, { id }) => [...acc, id], []); - }, [profiles, sortBy, sortByDirection]); - - return ( - <> - {areProfilesLoading && ( -
- -
- )} - {!areProfilesLoading && ( -
- - - - {tableFormat.map(({ name, label }) => ( - - ))} - - - - {profiles && - sortedProfilesIds.map((id) => ( - setProfile(id)} - className={`${isRowSelected(id) && styles.rowSelected}`} - > - - - - ))} - -
updateSortParams(name)} - > - {label} - -
- {profiles[id].name} - - {isRowSelected(id) && ( - - )} - {profiles[id].updatedAt}
-
- )} - - ); -} - -const mapStateToProps = (state) => ({ - ...state.root, -}); - -const mapDispatchToProps = (dispatch) => ({ - actions: bindActionCreators({}, dispatch), -}); - -export default connect(mapStateToProps, mapDispatchToProps)(FileList); diff --git a/webapp/javascript/components/FileList.module.scss b/webapp/javascript/components/FileList.module.scss index bee65aebfb..a2844cf064 100644 --- a/webapp/javascript/components/FileList.module.scss +++ b/webapp/javascript/components/FileList.module.scss @@ -31,7 +31,6 @@ $tdHeight: 25px; text-align: right; padding: 0px 10px; - border-left: 1px solid var(--ps-ui-border); border-bottom: 1px solid var(--ps-ui-border); width: 400px; min-width: 400px; diff --git a/webapp/javascript/components/FileList.tsx b/webapp/javascript/components/FileList.tsx new file mode 100644 index 0000000000..2c7e30e0d2 --- /dev/null +++ b/webapp/javascript/components/FileList.tsx @@ -0,0 +1,141 @@ +import React, { useState, useMemo } from 'react'; +import { Maybe } from '@webapp/util/fp'; +import { AllProfiles } from '@webapp/models/adhoc'; +import clsx from 'clsx'; +// eslint-disable-next-line css-modules/no-unused-class +import styles from './FileList.module.scss'; +import CheckIcon from './CheckIcon'; + +const dateModifiedColName = 'updatedAt'; +const fileNameColName = 'name'; +const tableFormat = [ + { name: fileNameColName, label: 'Filename' }, + { name: dateModifiedColName, label: 'Date Modified' }, +]; + +interface FileListProps { + className?: string; + profilesList: AllProfiles; + onProfileSelected: (id: string) => void; + selectedProfileId: Maybe; +} + +function FileList(props: FileListProps) { + const { + profilesList: profiles, + onProfileSelected, + className, + selectedProfileId, + } = props; + + const [sortBy, updateSortBy] = useState(dateModifiedColName); + const [sortByDirection, setSortByDirection] = useState<'desc' | 'asc'>( + 'desc' + ); + + const isRowSelected = (id: string) => { + return selectedProfileId.mapOr(false, (profId) => profId === id); + }; + + const updateSortParams = (newSortBy: typeof tableFormat[number]['name']) => { + let dir = sortByDirection; + + if (sortBy === newSortBy) { + dir = dir === 'asc' ? 'desc' : 'asc'; + } else { + dir = 'asc'; + } + + updateSortBy(newSortBy); + setSortByDirection(dir); + }; + + const sortedProfilesIds = useMemo(() => { + const m = sortByDirection === 'asc' ? 1 : -1; + + let sorted: AllProfiles[number][] = []; + + if (profiles) { + const filesInfo = Object.values(profiles); + + switch (sortBy) { + case fileNameColName: + sorted = filesInfo.sort( + (a, b) => m * a[sortBy].localeCompare(b[sortBy]) + ); + break; + case dateModifiedColName: + sorted = filesInfo.sort( + (a, b) => + m * + (new Date(a[sortBy]).getTime() - new Date(b[sortBy]).getTime()) + ); + break; + default: + sorted = filesInfo; + } + } + + return sorted; + }, [profiles, sortBy, sortByDirection]); + + return ( + <> +
+ + + + {tableFormat.map(({ name, label }) => ( + + ))} + + + + {profiles && + sortedProfilesIds.map((profile) => ( + { + // Optimize to not reload the same one + if ( + selectedProfileId.isJust && + selectedProfileId.value === profile.id + ) { + return; + } + onProfileSelected(profile.id); + }} + className={`${ + isRowSelected(profile.id) && styles.rowSelected + }`} + > + + + + ))} + +
updateSortParams(name)} + > + {label} + +
+ {profile.name} + + {isRowSelected(profile.id) && ( + + )} + {profile.updatedAt}
+
+ + ); +} + +export default FileList; diff --git a/webapp/javascript/components/FileUploader.tsx b/webapp/javascript/components/FileUploader.tsx deleted file mode 100644 index 8eed655864..0000000000 --- a/webapp/javascript/components/FileUploader.tsx +++ /dev/null @@ -1,126 +0,0 @@ -/* eslint-disable react/jsx-props-no-spreading */ -import React, { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; -import { useDropzone } from 'react-dropzone'; -import { faTrash } from '@fortawesome/free-solid-svg-icons/faTrash'; - -// Note: I wanted to use https://fontawesome.com/v6.0/icons/arrow-up-from-bracket?s=solid -// but it is in fontawesome v6 which is in beta and not released yet. -import { faArrowAltCircleUp } from '@fortawesome/free-regular-svg-icons/faArrowAltCircleUp'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import Button from '@webapp/ui/Button'; -import { addNotification } from '@webapp/redux/reducers/notifications'; -import styles from './FileUploader.module.scss'; - -interface Props { - file: File; - setFile: ( - file: File | null, - flamebearer: Record | null - ) => void; - - className?: string; -} -export default function FileUploader({ file, setFile, className }: Props) { - const dispatch = useDispatch(); - - const onDrop = useCallback((acceptedFiles) => { - if (acceptedFiles.length > 1) { - throw new Error('Only a single file at a time is accepted.'); - } - - acceptedFiles.forEach((file: ShamefulAny) => { - const reader = new FileReader(); - - reader.onabort = () => console.log('file reading was aborted'); - reader.onerror = () => console.log('file reading has failed'); - reader.onload = () => { - const binaryStr = reader.result; - - if (typeof binaryStr === 'string') { - throw new Error('Expecting file in binary format but got a string'); - } - if (binaryStr === null) { - throw new Error('Expecting file in binary format but got null'); - } - - try { - // ArrayBuffer -> JSON - const s = JSON.parse( - String.fromCharCode.apply( - null, - new Uint8Array(binaryStr) as ShamefulAny - ) - ); - // Only check for flamebearer fields, the rest of the file format is checked on decoding. - const fields = ['names', 'levels', 'numTicks', 'maxSelf']; - fields.forEach((field) => { - if (!(field in s.flamebearer)) - throw new Error( - `Unable to parse uploaded file: field ${field} missing` - ); - }); - setFile(file, s); - } catch (e: ShamefulAny) { - dispatch( - addNotification({ - message: e.message, - type: 'danger', - dismiss: { - duration: 0, - showIcon: true, - }, - }) - ); - } - }; - reader.readAsArrayBuffer(file); - }); - }, []); - const { getRootProps, getInputProps } = useDropzone({ - multiple: false, - onDrop, - accept: 'application/json', - }); - - const onRemove = () => { - setFile(null, null); - }; - - return ( -
-
- - {file ? ( -
-
- To analyze another file, drag and drop pyroscope JSON files here - or click to select a file -
-
{file.name}
-
- -
-
- ) : ( -
-

- Drag and drop Flamegraph files here -

-
- -
-

- Or click to select a file from your device -

-
- )} -
-
- ); -} diff --git a/webapp/javascript/index.jsx b/webapp/javascript/index.jsx index 49725aef19..ee7746326c 100644 --- a/webapp/javascript/index.jsx +++ b/webapp/javascript/index.jsx @@ -17,9 +17,9 @@ import TagExplorerView from './pages/TagExplorerView'; import Continuous from './components/Continuous'; import Settings from './components/Settings'; import Sidebar from './components/Sidebar'; -import AdhocSingle from './pages/AdhocSingle'; -import AdhocComparison from './pages/AdhocComparison'; -import AdhocDiff from './pages/AdhocDiff'; +import AdhocSingle from './pages/adhoc/AdhocSingle'; +import AdhocComparison from './pages/adhoc/AdhocComparison'; +import AdhocDiff from './pages/adhoc/AdhocDiff'; import ServiceDiscoveryApp from './pages/ServiceDiscovery'; import ServerNotifications from './components/ServerNotifications'; import Protected from './components/Protected'; diff --git a/webapp/javascript/models/adhoc.ts b/webapp/javascript/models/adhoc.ts new file mode 100644 index 0000000000..01e3df2e62 --- /dev/null +++ b/webapp/javascript/models/adhoc.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const AllProfilesSchema = z.record( + z.object({ + // TODO(eh-am): in practice it's a UUID + id: z.string(), + name: z.string(), + updatedAt: z.string(), + }) +); + +export type AllProfiles = z.infer; diff --git a/webapp/javascript/pages/AdhocComparison.tsx b/webapp/javascript/pages/AdhocComparison.tsx deleted file mode 100644 index 8e829830e0..0000000000 --- a/webapp/javascript/pages/AdhocComparison.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import React, { useEffect } from 'react'; -import 'react-dom'; - -import { useAppDispatch, useOldRootSelector } from '@webapp/redux/hooks'; -import Box from '@webapp/ui/Box'; -import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; -import Spinner from 'react-svg-spinner'; -import { FlamegraphRenderer } from '@pyroscope/flamegraph/src/FlamegraphRenderer'; -import classNames from 'classnames'; -import { Profile } from '@pyroscope/models/src'; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import FileList from '@webapp/components/FileList'; -import FileUploader from '@webapp/components/FileUploader'; -import { - fetchAdhocProfiles, - fetchAdhocLeftProfile, - fetchAdhocRightProfile, - setAdhocLeftFile, - setAdhocLeftProfile, - setAdhocRightFile, - setAdhocRightProfile, - abortFetchAdhocLeftProfile, - abortFetchAdhocProfiles, - abortFetchAdhocRightProfile, -} from '@webapp/redux/actions'; -import 'react-tabs/style/react-tabs.css'; -import useExportToFlamegraphDotCom from '@webapp/components/exportToFlamegraphDotCom.hook'; -import adhocStyles from './Adhoc.module.scss'; -import adhocComparisonStyles from './AdhocComparison.module.scss'; -import ExportData from '../components/ExportData'; - -function AdhocComparison() { - const dispatch = useAppDispatch(); - - const { left, right } = useOldRootSelector((state) => state.adhocComparison); - const { left: leftShared, right: rightShared } = useOldRootSelector( - (state) => state.adhocShared - ); - - const exportToFlamegraphDotComLeftFn = useExportToFlamegraphDotCom(left.raw); - const exportToFlamegraphDotComRightFn = useExportToFlamegraphDotCom( - right.raw - ); - - useEffect(() => { - dispatch(fetchAdhocProfiles()); - return () => { - dispatch(abortFetchAdhocProfiles()); - }; - }, [dispatch]); - - useEffect(() => { - if (leftShared.profile) { - dispatch(fetchAdhocLeftProfile(leftShared.profile)); - } - return () => { - dispatch(abortFetchAdhocLeftProfile()); - }; - }, [dispatch, leftShared.profile]); - - useEffect(() => { - if (rightShared.profile) { - dispatch(fetchAdhocRightProfile(rightShared.profile)); - } - return () => { - dispatch(abortFetchAdhocRightProfile()); - }; - }, [dispatch, rightShared.profile]); - - return ( -
-
-
- - - - Upload - Pyroscope data - - - dispatch(setAdhocLeftFile(f, flame))} - /> - - - { - dispatch(setAdhocLeftProfile(p)); - }} - /> - - - {left.isProfileLoading && ( -
- -
- )} - {!left.isProfileLoading && ( - - } - /> - )} -
- {/* Right side */} - - - - Upload - Pyroscope data - - - dispatch(setAdhocRightFile(f, flame))} - /> - - - { - dispatch(setAdhocRightProfile(p)); - }} - /> - - - {right.isProfileLoading && ( -
- -
- )} - {!right.isProfileLoading && ( - - } - /> - )} -
-
-
-
- ); -} - -export default AdhocComparison; diff --git a/webapp/javascript/pages/AdhocDiff.tsx b/webapp/javascript/pages/AdhocDiff.tsx deleted file mode 100644 index cd6687b40b..0000000000 --- a/webapp/javascript/pages/AdhocDiff.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import React, { useEffect } from 'react'; -import 'react-dom'; - -import { useAppDispatch, useOldRootSelector } from '@webapp/redux/hooks'; -import Box from '@webapp/ui/Box'; -import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; -import Spinner from 'react-svg-spinner'; -import classNames from 'classnames'; -import { FlamegraphRenderer } from '@pyroscope/flamegraph/src/FlamegraphRenderer'; -import { Profile } from '@pyroscope/models/src'; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import FileList from '@webapp/components/FileList'; -import { - fetchAdhocProfiles, - fetchAdhocProfileDiff, - setAdhocLeftProfile, - setAdhocRightProfile, - abortFetchAdhocProfileDiff, - abortFetchAdhocProfiles, -} from '@webapp/redux/actions'; -import 'react-tabs/style/react-tabs.css'; -import useExportToFlamegraphDotCom from '@webapp/components/exportToFlamegraphDotCom.hook'; -import ExportData from '@webapp/components/ExportData'; -import adhocStyles from './Adhoc.module.scss'; -import adhocComparisonStyles from './AdhocComparison.module.scss'; - -function AdhocDiff() { - const dispatch = useAppDispatch(); - const { flamebearer, isProfileLoading, raw } = useOldRootSelector( - (state) => state.adhocComparisonDiff - ); - const { left: leftShared, right: rightShared } = useOldRootSelector( - (state) => state.adhocShared - ); - const exportToFlamegraphDotComFn = useExportToFlamegraphDotCom(raw); - - useEffect(() => { - dispatch(fetchAdhocProfiles()); - return () => { - return dispatch(abortFetchAdhocProfiles()); - }; - }, [dispatch]); - - useEffect(() => { - if (leftShared.profile && rightShared.profile) { - dispatch(fetchAdhocProfileDiff(leftShared.profile, rightShared.profile)); - } - return () => { - dispatch(abortFetchAdhocProfileDiff()); - }; - }, [dispatch, leftShared.profile, rightShared.profile]); - - return ( -
-
-
- - - - Pyroscope data - Upload - - - dispatch(setAdhocLeftProfile(p))} - /> - - - - - - - - Pyroscope data - Upload - - - dispatch(setAdhocRightProfile(p))} - /> - - - - -
- - {isProfileLoading && ( -
- -
- )} - {!isProfileLoading && ( - - } - /> - )} -
-
-
- ); -} - -export default AdhocDiff; diff --git a/webapp/javascript/pages/AdhocSingle.tsx b/webapp/javascript/pages/AdhocSingle.tsx deleted file mode 100644 index 7a90f3d11c..0000000000 --- a/webapp/javascript/pages/AdhocSingle.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React, { useEffect } from 'react'; -import 'react-dom'; - -import { useAppDispatch, useOldRootSelector } from '@webapp/redux/hooks'; -import Box from '@webapp/ui/Box'; -import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; -import Spinner from 'react-svg-spinner'; -import { FlamegraphRenderer } from '@pyroscope/flamegraph/src/FlamegraphRenderer'; -import { Profile } from '@pyroscope/models/src'; -import classNames from 'classnames'; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import FileList from '@webapp/components/FileList'; -import FileUploader from '@webapp/components/FileUploader'; - -import { - fetchAdhocProfiles, - fetchAdhocProfile, - setAdhocFile, - setAdhocProfile, - abortFetchAdhocProfiles, - abortFetchAdhocProfile, -} from '@webapp/redux/actions'; -import 'react-tabs/style/react-tabs.css'; -import useExportToFlamegraphDotCom from '@webapp/components/exportToFlamegraphDotCom.hook'; -import ExportData from '@webapp/components/ExportData'; -import adhocStyles from './Adhoc.module.scss'; - -function AdhocSingle() { - const dispatch = useAppDispatch(); - - const { file, profile, flamebearer, isProfileLoading, raw } = - useOldRootSelector((state) => state.adhocSingle); - const exportToFlamegraphDotComFn = useExportToFlamegraphDotCom(raw); - - useEffect(() => { - dispatch(fetchAdhocProfiles()); - - return () => { - dispatch(abortFetchAdhocProfiles()); - }; - }, [dispatch]); - - useEffect(() => { - if (profile) { - dispatch(fetchAdhocProfile(profile)); - } - return () => { - dispatch(abortFetchAdhocProfile()); - }; - }, [profile, dispatch]); - - return ( -
-
- - - - Upload - Pyroscope data - - - dispatch(setAdhocFile(f, flame))} - /> - - - dispatch(setAdhocProfile(p))} - /> - - - {isProfileLoading && ( -
- -
- )} - {!isProfileLoading && ( - - } - /> - )} -
-
-
- ); -} - -export default AdhocSingle; diff --git a/webapp/javascript/pages/Adhoc.module.scss b/webapp/javascript/pages/adhoc/Adhoc.module.scss similarity index 100% rename from webapp/javascript/pages/Adhoc.module.scss rename to webapp/javascript/pages/adhoc/Adhoc.module.scss diff --git a/webapp/javascript/pages/AdhocComparison.module.scss b/webapp/javascript/pages/adhoc/AdhocComparison.module.scss similarity index 100% rename from webapp/javascript/pages/AdhocComparison.module.scss rename to webapp/javascript/pages/adhoc/AdhocComparison.module.scss diff --git a/webapp/javascript/pages/adhoc/AdhocComparison.tsx b/webapp/javascript/pages/adhoc/AdhocComparison.tsx new file mode 100644 index 0000000000..b1ef17fc5e --- /dev/null +++ b/webapp/javascript/pages/adhoc/AdhocComparison.tsx @@ -0,0 +1,169 @@ +import React, { useEffect } from 'react'; +import 'react-dom'; + +import { Maybe } from '@webapp/util/fp'; +import { useAppDispatch, useAppSelector } from '@webapp/redux/hooks'; +import Box from '@webapp/ui/Box'; +import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; +import { FlamegraphRenderer } from '@pyroscope/flamegraph/src/FlamegraphRenderer'; +import { Profile } from '@pyroscope/models/src'; +import FileList from '@webapp/components/FileList'; +import 'react-tabs/style/react-tabs.css'; +import useExportToFlamegraphDotCom from '@webapp/components/exportToFlamegraphDotCom.hook'; +import ExportData from '@webapp/components/ExportData'; +import { + fetchAllProfiles, + fetchProfile, + selectAdhocUploadedFilename, + selectedSelectedProfileId, + selectProfile, + selectShared, + uploadFile, +} from '@webapp/redux/reducers/adhoc'; +import adhocStyles from './Adhoc.module.scss'; +import adhocComparisonStyles from './AdhocComparison.module.scss'; +import FileUploader from './components/FileUploader'; + +function AdhocComparison() { + const dispatch = useAppDispatch(); + + const leftFilename = useAppSelector(selectAdhocUploadedFilename('left')); + const rightFilename = useAppSelector(selectAdhocUploadedFilename('right')); + + const leftProfile = useAppSelector(selectProfile('left')); + const rightProfile = useAppSelector(selectProfile('right')); + + const selectedProfileIdLeft = useAppSelector( + selectedSelectedProfileId('left') + ); + const selectedProfileIdRight = useAppSelector( + selectedSelectedProfileId('right') + ); + + const exportToFlamegraphDotComLeftFn = useExportToFlamegraphDotCom( + leftProfile.unwrapOr(undefined) + ); + const exportToFlamegraphDotComRightFn = useExportToFlamegraphDotCom( + rightProfile.unwrapOr(undefined) + ); + + const { profilesList } = useAppSelector(selectShared); + + useEffect(() => { + dispatch(fetchAllProfiles()); + }, [dispatch]); + + const flamegraph = ( + profile: Maybe, + exportToFn: typeof exportToFlamegraphDotComLeftFn + ) => { + if (profile.isNothing) { + return <>; + } + + return ( + + } + /> + ); + }; + + const leftFlamegraph = flamegraph( + leftProfile, + exportToFlamegraphDotComLeftFn + ); + const rightFlamegraph = flamegraph( + rightProfile, + exportToFlamegraphDotComRightFn + ); + + return ( +
+
+
+ + + + Upload + Pyroscope data + + + { + dispatch(uploadFile({ file, side: 'left' })); + }} + /> + + + {profilesList.type === 'loaded' && ( + { + dispatch(fetchProfile({ id, side: 'left' })); + }} + /> + )} + + + {leftFlamegraph} + + {/* Right side */} + + + + Upload + Pyroscope data + + + { + dispatch( + uploadFile({ + file, + side: 'right', + }) + ); + }} + /> + + + {profilesList.type === 'loaded' && ( + { + dispatch(fetchProfile({ id, side: 'right' })); + }} + /> + )} + + + {rightFlamegraph} + +
+
+
+ ); +} + +export default AdhocComparison; diff --git a/webapp/javascript/pages/adhoc/AdhocDiff.tsx b/webapp/javascript/pages/adhoc/AdhocDiff.tsx new file mode 100644 index 0000000000..e81f17a170 --- /dev/null +++ b/webapp/javascript/pages/adhoc/AdhocDiff.tsx @@ -0,0 +1,143 @@ +import React, { useEffect } from 'react'; +import 'react-dom'; + +import { Maybe } from '@webapp/util/fp'; +import { useAppDispatch, useAppSelector } from '@webapp/redux/hooks'; +import Box from '@webapp/ui/Box'; +import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; +import { FlamegraphRenderer } from '@pyroscope/flamegraph/src/FlamegraphRenderer'; +import { Profile } from '@pyroscope/models/src'; +import FileList from '@webapp/components/FileList'; +import 'react-tabs/style/react-tabs.css'; +import useExportToFlamegraphDotCom from '@webapp/components/exportToFlamegraphDotCom.hook'; +import ExportData from '@webapp/components/ExportData'; +import { + fetchAllProfiles, + fetchDiffProfile, + fetchProfile, + selectedSelectedProfileId, + selectProfileId, + selectShared, + selectDiffProfile, +} from '@webapp/redux/reducers/adhoc'; +import adhocStyles from './Adhoc.module.scss'; +import adhocComparisonStyles from './AdhocComparison.module.scss'; + +function AdhocDiff() { + const dispatch = useAppDispatch(); + const leftProfileId = useAppSelector(selectProfileId('left')); + const rightProfileId = useAppSelector(selectProfileId('right')); + + const selectedProfileIdLeft = useAppSelector( + selectedSelectedProfileId('left') + ); + const selectedProfileIdRight = useAppSelector( + selectedSelectedProfileId('right') + ); + const { profilesList } = useAppSelector(selectShared); + const diffProfile = useAppSelector(selectDiffProfile); + const exportToFlamegraphDotComFn = useExportToFlamegraphDotCom( + diffProfile.unwrapOr(undefined) + ); + + useEffect(() => { + dispatch(fetchAllProfiles()); + }, [dispatch]); + + useEffect(() => { + if (leftProfileId.isJust && rightProfileId.isJust) { + dispatch( + fetchDiffProfile({ + leftId: leftProfileId.value, + rightId: rightProfileId.value, + }) + ); + } + }, [ + dispatch, + leftProfileId.unwrapOr(undefined), + rightProfileId.unwrapOr(undefined), + ]); + + const flamegraph = ( + profile: Maybe, + exportToFn: ReturnType + ) => { + if (profile.isNothing) { + return <>; + } + + return ( + + } + /> + ); + }; + + return ( +
+
+
+ + + + Pyroscope data + Upload + + + {profilesList.type === 'loaded' && ( + { + dispatch(fetchProfile({ id, side: 'left' })); + }} + /> + )} + + + + + + + + Pyroscope data + Upload + + + {profilesList.type === 'loaded' && ( + { + dispatch(fetchProfile({ id, side: 'right' })); + }} + /> + )} + + + + +
+ {flamegraph(diffProfile, exportToFlamegraphDotComFn)} +
+
+ ); +} + +export default AdhocDiff; diff --git a/webapp/javascript/pages/adhoc/AdhocSingle.tsx b/webapp/javascript/pages/adhoc/AdhocSingle.tsx new file mode 100644 index 0000000000..41340e7526 --- /dev/null +++ b/webapp/javascript/pages/adhoc/AdhocSingle.tsx @@ -0,0 +1,96 @@ +import React, { useEffect } from 'react'; +import 'react-dom'; + +import { useAppDispatch, useAppSelector } from '@webapp/redux/hooks'; +import Box from '@webapp/ui/Box'; +import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; +import { FlamegraphRenderer } from '@pyroscope/flamegraph/src/FlamegraphRenderer'; +import FileList from '@webapp/components/FileList'; +import 'react-tabs/style/react-tabs.css'; +import useExportToFlamegraphDotCom from '@webapp/components/exportToFlamegraphDotCom.hook'; +import ExportData from '@webapp/components/ExportData'; +import { + uploadFile, + selectAdhocUploadedFilename, + fetchProfile, + selectShared, + fetchAllProfiles, + selectedSelectedProfileId, + selectProfile, +} from '@webapp/redux/reducers/adhoc'; +import FileUploader from './components/FileUploader'; +import adhocStyles from './Adhoc.module.scss'; + +function AdhocSingle() { + const dispatch = useAppDispatch(); + const filename = useAppSelector(selectAdhocUploadedFilename('left')); + const { profilesList } = useAppSelector(selectShared); + const selectedProfileId = useAppSelector(selectedSelectedProfileId('left')); + const profile = useAppSelector(selectProfile('left')); + + useEffect(() => { + dispatch(fetchAllProfiles()); + }, [dispatch]); + + const exportToFlamegraphDotComFn = useExportToFlamegraphDotCom( + profile.unwrapOr(undefined) + ); + + const flame = (() => { + if (profile.isNothing) { + return <>; + } + + return ( + + } + /> + ); + })(); + + return ( +
+ + + + Upload + Pyroscope data + + + { + dispatch(uploadFile({ file, side: 'left' })); + }} + /> + + + {profilesList.type === 'loaded' && ( + { + dispatch(fetchProfile({ id, side: 'left' })); + }} + /> + )} + + + {flame} + +
+ ); +} + +export default AdhocSingle; diff --git a/webapp/javascript/components/FileUploader.module.scss b/webapp/javascript/pages/adhoc/components/FileUploader.module.scss similarity index 100% rename from webapp/javascript/components/FileUploader.module.scss rename to webapp/javascript/pages/adhoc/components/FileUploader.module.scss diff --git a/webapp/javascript/pages/adhoc/components/FileUploader.tsx b/webapp/javascript/pages/adhoc/components/FileUploader.tsx new file mode 100644 index 0000000000..05a77916ab --- /dev/null +++ b/webapp/javascript/pages/adhoc/components/FileUploader.tsx @@ -0,0 +1,69 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import React, { useCallback } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { Maybe } from '@webapp/util/fp'; +import type { DropzoneOptions } from 'react-dropzone'; + +// Note: I wanted to use https://fontawesome.com/v6.0/icons/arrow-up-from-bracket?s=solid +// but it is in fontawesome v6 which is in beta and not released yet. +import { faArrowAltCircleUp } from '@fortawesome/free-regular-svg-icons/faArrowAltCircleUp'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import styles from './FileUploader.module.scss'; + +interface Props { + filename: Maybe; + setFile: (file: File) => void; + className?: string; +} +export default function FileUploader({ filename, setFile, className }: Props) { + type onDrop = Required['onDrop']; + const onDrop = useCallback( + (acceptedFiles) => { + if (acceptedFiles.length > 1) { + throw new Error('Only a single file at a time is accepted.'); + } + + acceptedFiles.forEach((f) => { + setFile(f); + }); + }, + [setFile] + ); + + const { getRootProps, getInputProps } = useDropzone({ + multiple: false, + onDrop, + }); + + return ( +
+
+ + {filename.isJust ? ( +
+
+ To analyze another file, drag and drop pprof, json, or collapsed + files here or click to select a file +
+
{filename.value}
+
+ ) : ( +
+

+ Drag and drop pprof, json, or collapsed files here +

+
+ +
+

+ Or click to select a file from your device +

+
+ )} +
+
+ ); +} diff --git a/webapp/javascript/redux/actions.ts b/webapp/javascript/redux/actions.ts deleted file mode 100644 index c19e1a3553..0000000000 --- a/webapp/javascript/redux/actions.ts +++ /dev/null @@ -1,308 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-nocheck -import { - SET_ADHOC_FILE, - SET_ADHOC_LEFT_FILE, - SET_ADHOC_RIGHT_FILE, - REQUEST_ADHOC_PROFILES, - RECEIVE_ADHOC_PROFILES, - CANCEL_ADHOC_PROFILES, - SET_ADHOC_PROFILE, - REQUEST_ADHOC_PROFILE, - RECEIVE_ADHOC_PROFILE, - CANCEL_ADHOC_PROFILE, - SET_ADHOC_LEFT_PROFILE, - SET_ADHOC_RIGHT_PROFILE, - REQUEST_ADHOC_LEFT_PROFILE, - REQUEST_ADHOC_RIGHT_PROFILE, - RECEIVE_ADHOC_LEFT_PROFILE, - RECEIVE_ADHOC_RIGHT_PROFILE, - CANCEL_ADHOC_LEFT_PROFILE, - CANCEL_ADHOC_RIGHT_PROFILE, - REQUEST_ADHOC_PROFILE_DIFF, - RECEIVE_ADHOC_PROFILE_DIFF, - CANCEL_ADHOC_PROFILE_DIFF, -} from './actionTypes'; -import { isAbortError } from '../util/abort'; -import { addNotification } from './reducers/notifications'; - -export const setAdhocFile = (file, flamebearer) => ({ - type: SET_ADHOC_FILE, - payload: { file, flamebearer }, -}); - -export const setAdhocLeftFile = (file, flamebearer) => ({ - type: SET_ADHOC_LEFT_FILE, - payload: { file, flamebearer }, -}); - -export const setAdhocRightFile = (file, flamebearer) => ({ - type: SET_ADHOC_RIGHT_FILE, - payload: { file, flamebearer }, -}); - -export const requestAdhocProfiles = () => ({ type: REQUEST_ADHOC_PROFILES }); - -export const receiveAdhocProfiles = (profiles) => ({ - type: RECEIVE_ADHOC_PROFILES, - payload: { profiles }, -}); - -export const cancelAdhocProfiles = () => ({ type: CANCEL_ADHOC_PROFILES }); - -export const setAdhocProfile = (profile) => ({ - type: SET_ADHOC_PROFILE, - payload: { profile }, -}); - -export const requestAdhocProfile = () => ({ type: REQUEST_ADHOC_PROFILE }); - -export const receiveAdhocProfile = (flamebearer) => ({ - type: RECEIVE_ADHOC_PROFILE, - payload: { flamebearer }, -}); - -export const cancelAdhocProfile = () => ({ type: CANCEL_ADHOC_PROFILE }); - -export const setAdhocLeftProfile = (profile) => ({ - type: SET_ADHOC_LEFT_PROFILE, - payload: { profile }, -}); - -export const requestAdhocLeftProfile = () => ({ - type: REQUEST_ADHOC_LEFT_PROFILE, -}); - -export const receiveAdhocLeftProfile = (flamebearer) => ({ - type: RECEIVE_ADHOC_LEFT_PROFILE, - payload: { flamebearer }, -}); - -export const cancelAdhocLeftProfile = () => ({ - type: CANCEL_ADHOC_LEFT_PROFILE, -}); - -export const setAdhocRightProfile = (profile) => ({ - type: SET_ADHOC_RIGHT_PROFILE, - payload: { profile }, -}); - -export const requestAdhocRightProfile = () => ({ - type: REQUEST_ADHOC_RIGHT_PROFILE, -}); - -export const receiveAdhocRightProfile = (flamebearer) => ({ - type: RECEIVE_ADHOC_RIGHT_PROFILE, - payload: { flamebearer }, -}); - -export const cancelAdhocRightProfile = () => ({ - type: CANCEL_ADHOC_RIGHT_PROFILE, -}); - -export const requestAdhocProfileDiff = () => ({ - type: REQUEST_ADHOC_PROFILE_DIFF, -}); - -export const receiveAdhocProfileDiff = (flamebearer) => ({ - type: RECEIVE_ADHOC_PROFILE_DIFF, - payload: { flamebearer }, -}); - -export const cancelAdhocProfileDiff = () => ({ - type: CANCEL_ADHOC_PROFILE_DIFF, -}); - -// ResponseNotOkError refers to when request is not ok -// ie when status code is not in the 2xx range -class ResponseNotOkError extends Error { - response: ShamefulAny; - - constructor(response: ShamefulAny, text: string) { - super(`Bad Response with code ${response.status}: ${text}`); - this.name = 'ResponseNotOkError'; - this.response = response; - } -} - -// dispatchNotificationByError dispatches a notification -// depending on the error passed -function handleError(dispatch, e) { - if (e instanceof ResponseNotOkError) { - dispatch( - addNotification({ - title: 'Request Failed', - message: e.message, - type: 'danger', - }) - ); - } else if (!isAbortError(e)) { - // AbortErrors are fine - - // Generic case, so we use as message whatever error we got - // It's not the best UX, but our users should be experienced enough - // to be able to decipher what's going on based on the message - dispatch( - addNotification({ - title: 'Error', - message: e.message, - type: 'danger', - }) - ); - } -} - -// handleResponse retrieves the JSON data on success or raises an ResponseNotOKError otherwise -function handleResponse(dispatch, response) { - if (response.ok) { - return response.json(); - } - return response.text().then((text) => { - throw new ResponseNotOkError(response, text); - }); -} - -let adhocProfilesController; -export function fetchAdhocProfiles() { - return (dispatch) => { - if (adhocProfilesController) { - adhocProfilesController.abort(); - } - - adhocProfilesController = new AbortController(); - dispatch(requestAdhocProfiles()); - return fetch('./api/adhoc/v1/profiles', { - signal: adhocProfilesController.signal, - }) - .then((response) => handleResponse(dispatch, response)) - .then((data) => dispatch(receiveAdhocProfiles(data))) - .catch((e) => { - handleError(dispatch, e); - dispatch(cancelAdhocProfiles()); - }) - .finally(); - }; -} -export function abortFetchAdhocProfiles() { - return () => { - if (adhocProfilesController) { - adhocProfilesController.abort(); - } - }; -} - -let adhocProfileController; -export function fetchAdhocProfile(profile) { - return (dispatch) => { - if (adhocProfileController) { - adhocProfileController.abort(); - } - - adhocProfileController = new AbortController(); - dispatch(requestAdhocProfile()); - return fetch(`./api/adhoc/v1/profile/${profile}`, { - signal: adhocProfileController.signal, - }) - .then((response) => handleResponse(dispatch, response)) - .then((data) => dispatch(receiveAdhocProfile(data))) - .catch((e) => { - handleError(dispatch, e); - dispatch(cancelAdhocProfile()); - }) - .finally(); - }; -} -export function abortFetchAdhocProfile() { - return () => { - if (adhocProfileController) { - adhocProfileController.abort(); - } - }; -} - -let adhocLeftProfileController; -export function fetchAdhocLeftProfile(profile) { - return (dispatch) => { - if (adhocLeftProfileController) { - adhocLeftProfileController.abort(); - } - - adhocLeftProfileController = new AbortController(); - dispatch(requestAdhocLeftProfile()); - return fetch(`./api/adhoc/v1/profile/${profile}`, { - signal: adhocLeftProfileController.signal, - }) - .then((response) => handleResponse(dispatch, response)) - .then((data) => dispatch(receiveAdhocLeftProfile(data))) - .catch((e) => { - handleError(dispatch, e); - dispatch(cancelAdhocLeftProfile()); - }) - .finally(); - }; -} -export function abortFetchAdhocLeftProfile() { - return () => { - if (adhocLeftProfileController) { - adhocLeftProfileController.abort(); - } - }; -} - -let adhocRightProfileController; -export function fetchAdhocRightProfile(profile) { - return (dispatch) => { - if (adhocRightProfileController) { - adhocRightProfileController.abort(); - } - - adhocRightProfileController = new AbortController(); - dispatch(requestAdhocRightProfile()); - return fetch(`./api/adhoc/v1/profile/${profile}`, { - signal: adhocRightProfileController.signal, - }) - .then((response) => handleResponse(dispatch, response)) - .then((data) => dispatch(receiveAdhocRightProfile(data))) - .catch((e) => { - handleError(dispatch, e); - dispatch(cancelAdhocRightProfile()); - }) - .finally(); - }; -} -export function abortFetchAdhocRightProfile() { - return () => { - if (adhocRightProfileController) { - adhocRightProfileController.abort(); - } - }; -} - -let adhocProfileDiffController; -export function fetchAdhocProfileDiff(left, right) { - return (dispatch) => { - if (adhocProfileDiffController) { - adhocProfileDiffController.abort(); - } - - adhocProfileDiffController = new AbortController(); - dispatch(requestAdhocProfileDiff()); - return fetch(`./api/adhoc/v1/diff/${left}/${right}`, { - signal: adhocProfileDiffController.signal, - }) - .then((response) => handleResponse(dispatch, response)) - .then((data) => dispatch(receiveAdhocProfileDiff(data))) - .catch((e) => { - handleError(dispatch, e); - dispatch(cancelAdhocProfileDiff()); - }) - .finally(); - }; -} -export function abortFetchAdhocProfileDiff() { - return () => { - if (adhocProfileDiffController) { - adhocProfileDiffController.abort(); - } - }; -} diff --git a/webapp/javascript/redux/hooks.ts b/webapp/javascript/redux/hooks.ts index 15a49fe50d..520e84ed52 100644 --- a/webapp/javascript/redux/hooks.ts +++ b/webapp/javascript/redux/hooks.ts @@ -1,14 +1,6 @@ import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; import type { RootState, AppDispatch } from './store'; -import rootReducer from './reducers'; // Use throughout your app instead of plain `useDispatch` and `useSelector` export const useAppDispatch = () => useDispatch(); export const useAppSelector: TypedUseSelectorHook = useSelector; - -// Until we migrate the old store to redux toolkit -// Let's use this to have some typing -export const useOldRootSelector: TypedUseSelectorHook< - ReturnType -> = (fn: (a: ReturnType) => ShamefulAny) => - useAppSelector((state) => fn(state.root)); diff --git a/webapp/javascript/redux/reducers/adhoc.ts b/webapp/javascript/redux/reducers/adhoc.ts new file mode 100644 index 0000000000..a14416cd3d --- /dev/null +++ b/webapp/javascript/redux/reducers/adhoc.ts @@ -0,0 +1,267 @@ +import { Profile } from '@pyroscope/models/src'; +import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { + upload, + retrieve, + retrieveAll, + retrieveDiff, +} from '@webapp/services/adhoc'; +import type { RootState } from '@webapp/redux/store'; +import { Maybe } from '@webapp/util/fp'; +import { AllProfiles } from '@webapp/models/adhoc'; +import { addNotification } from './notifications'; + +type Upload = { + left: { + type: 'pristine' | 'loading' | 'loaded'; + fileName?: string; + }; + right: { + type: 'pristine' | 'loading' | 'loaded'; + fileName?: string; + }; +}; + +type Shared = { + profilesList: + | { type: 'pristine' } + | { type: 'loading' } + | { type: 'loaded'; profilesList: AllProfiles }; + + left: { + type: 'pristine' | 'loading' | 'loaded'; + profile?: Profile; + id?: string; + }; + + right: { + type: 'pristine' | 'loading' | 'loaded'; + profile?: Profile; + id?: string; + }; +}; + +type DiffState = { + type: 'pristine' | 'loading' | 'loaded'; + profile?: Profile; +}; + +type side = 'left' | 'right'; + +interface AdhocState { + // Upload refers to the files being uploaded + upload: Upload; + // Shared refers to the list of already uploaded files + shared: Shared; + diff: DiffState; +} + +const initialState: AdhocState = { + shared: { + profilesList: { type: 'pristine' }, + left: { type: 'pristine' }, + right: { type: 'pristine' }, + }, + upload: { left: { type: 'pristine' }, right: { type: 'pristine' } }, + diff: { type: 'pristine' }, +}; + +export const uploadFile = createAsyncThunk( + 'adhoc/uploadFile', + async ({ file, ...args }: { file: File } & { side: side }, thunkAPI) => { + const res = await upload(file); + + if (res.isOk) { + return Promise.resolve({ profile: res.value, fileName: file.name }); + } + + thunkAPI.dispatch( + addNotification({ + type: 'danger', + title: 'Failed to upload adhoc file', + message: res.error.message, + }) + ); + + // Since the file is invalid, let's remove it + thunkAPI.dispatch(removeFile(args)); + + return Promise.reject(res.error); + } +); + +export const fetchAllProfiles = createAsyncThunk( + 'adhoc/fetchAllProfiles', + async (_, thunkAPI) => { + const res = await retrieveAll(); + if (res.isOk) { + return Promise.resolve(res.value); + } + + thunkAPI.dispatch( + addNotification({ + type: 'danger', + title: 'Failed to load list of adhoc files', + message: res.error.message, + }) + ); + + return Promise.reject(res.error); + } +); + +export const fetchProfile = createAsyncThunk( + 'adhoc/fetchProfile', + async ({ id, side }: { id: string; side: side }, thunkAPI) => { + const res = await retrieve(id); + + if (res.isOk) { + return Promise.resolve({ profile: res.value, side, id }); + } + + thunkAPI.dispatch( + addNotification({ + type: 'danger', + title: 'Failed to load adhoc file', + message: res.error.message, + }) + ); + + return Promise.reject(res.error); + } +); + +export const fetchDiffProfile = createAsyncThunk( + 'adhoc/fetchDiffProfile', + async ( + { leftId, rightId }: { leftId: string; rightId: string }, + thunkAPI + ) => { + const res = await retrieveDiff(leftId, rightId); + + if (res.isOk) { + return Promise.resolve({ profile: res.value }); + } + + thunkAPI.dispatch( + addNotification({ + type: 'danger', + title: 'Failed to load adhoc diff', + message: res.error.message, + }) + ); + + return Promise.reject(res.error); + } +); + +export const adhocSlice = createSlice({ + name: 'adhoc', + initialState, + reducers: { + removeFile(state, action: PayloadAction<{ side: side }>) { + state.upload[action.payload.side] = { + type: 'pristine', + fileName: undefined, + }; + }, + }, + extraReducers: (builder) => { + builder.addCase(uploadFile.pending, (state, action) => { + state.upload[action.meta.arg.side].type = 'loading'; + }); + builder.addCase(uploadFile.rejected, (state, action) => { + // Since the file is invalid, let's remove it + state.upload[action.meta.arg.side] = { + type: 'pristine', + fileName: undefined, + }; + }); + + builder.addCase(uploadFile.fulfilled, (state, action) => { + const s = action.meta.arg; + + state.upload[s.side] = { type: 'loaded', fileName: s.file.name }; + + state.shared[s.side] = { + type: 'loaded', + profile: action.payload.profile, + id: undefined, + }; + }); + + builder.addCase(fetchProfile.fulfilled, (state, action) => { + const { side } = action.meta.arg; + + // After loading a profile, there's no uploaded profile + state.upload[side] = { + type: 'pristine', + fileName: undefined, + }; + + state.shared[side] = { + type: 'loaded', + profile: action.payload.profile, + id: action.payload.id, + }; + }); + + builder.addCase(fetchAllProfiles.fulfilled, (state, action) => { + state.shared.profilesList = { + type: 'loaded', + profilesList: action.payload, + }; + }); + + builder.addCase(fetchDiffProfile.pending, (state) => { + state.diff = { + // Keep previous value + ...state.diff, + type: 'loading', + }; + }); + + builder.addCase(fetchDiffProfile.fulfilled, (state, action) => { + state.diff = { + type: 'loaded', + profile: action.payload.profile, + }; + }); + }, +}); + +const selectAdhocState = (state: RootState) => { + return state.adhoc; +}; + +export const selectAdhocUploadedFilename = + (side: side) => (state: RootState) => { + return Maybe.of(selectAdhocState(state).upload[side].fileName); + }; + +export const selectShared = (state: RootState) => { + return selectAdhocState(state).shared; +}; + +export const selectProfilesList = (state: RootState) => { + return selectShared(state).profilesList; +}; + +export const selectedSelectedProfileId = (side: side) => (state: RootState) => { + return Maybe.of(selectShared(state)[side].id); +}; + +export const selectProfile = (side: side) => (state: RootState) => { + return Maybe.of(selectShared(state)[side].profile); +}; + +export const selectDiffProfile = (state: RootState) => { + return Maybe.of(selectAdhocState(state).diff.profile); +}; + +export const selectProfileId = (side: side) => (state: RootState) => { + return Maybe.of(selectShared(state)[side].id); +}; + +export const { removeFile } = adhocSlice.actions; +export default adhocSlice.reducer; diff --git a/webapp/javascript/redux/reducers/filters.ts b/webapp/javascript/redux/reducers/filters.ts deleted file mode 100644 index daa3d8216a..0000000000 --- a/webapp/javascript/redux/reducers/filters.ts +++ /dev/null @@ -1,372 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-nocheck -import { - SET_ADHOC_FILE, - SET_ADHOC_LEFT_FILE, - SET_ADHOC_RIGHT_FILE, - REQUEST_ADHOC_PROFILES, - RECEIVE_ADHOC_PROFILES, - CANCEL_ADHOC_PROFILES, - SET_ADHOC_PROFILE, - REQUEST_ADHOC_PROFILE, - RECEIVE_ADHOC_PROFILE, - CANCEL_ADHOC_PROFILE, - SET_ADHOC_LEFT_PROFILE, - REQUEST_ADHOC_LEFT_PROFILE, - RECEIVE_ADHOC_LEFT_PROFILE, - CANCEL_ADHOC_LEFT_PROFILE, - SET_ADHOC_RIGHT_PROFILE, - REQUEST_ADHOC_RIGHT_PROFILE, - RECEIVE_ADHOC_RIGHT_PROFILE, - CANCEL_ADHOC_RIGHT_PROFILE, - REQUEST_ADHOC_PROFILE_DIFF, - RECEIVE_ADHOC_PROFILE_DIFF, - CANCEL_ADHOC_PROFILE_DIFF, -} from '../actionTypes'; - -import { deltaDiffWrapper } from '../../util/flamebearer'; - -const initialState = { - // TODO(eh-am): add proper types - adhocSingle: { - raw: null as ShamefulAny, - file: null as ShamefulAny, - profile: null as ShamefulAny, - flamebearer: null as ShamefulAny, - isProfileLoading: false, - }, - adhocShared: { - left: { - profile: null as ShamefulAny, - }, - right: { - profile: null as ShamefulAny, - }, - }, - adhocComparison: { - left: { - file: null as ShamefulAny, - raw: null as ShamefulAny, - flamebearer: null as ShamefulAny, - isProfileLoading: false, - }, - right: { - file: null as ShamefulAny, - raw: null as ShamefulAny, - flamebearer: null as ShamefulAny, - isProfileLoading: false, - }, - }, - adhocComparisonDiff: { - flamebearer: null as ShamefulAny, - raw: null as ShamefulAny, - isProfileLoading: false, - }, - serviceDiscovery: { - data: [], - }, -}; - -function decodeFlamebearer({ - flamebearer, - metadata, - leftTicks, - rightTicks, - version, -}) { - const fb = { - ...flamebearer, - format: metadata.format, - spyName: metadata.spyName, - sampleRate: metadata.sampleRate, - units: metadata.units, - }; - if (fb.format === 'double') { - fb.leftTicks = leftTicks; - fb.rightTicks = rightTicks; - } - fb.version = version || 0; - fb.levels = deltaDiffWrapper(fb.format, fb.levels); - return fb; -} - -export default function (state = initialState, action) { - const { type } = action; - let file; - let flamebearer; - let profile; - let profiles; - - switch (type) { - case SET_ADHOC_FILE: - ({ - payload: { file, flamebearer }, - } = action); - return { - ...state, - adhocSingle: { - ...state.adhocSingle, - profile: null, - file, - flamebearer: flamebearer ? decodeFlamebearer(flamebearer) : null, - }, - }; - case SET_ADHOC_LEFT_FILE: - ({ - payload: { file, flamebearer }, - } = action); - return { - ...state, - adhocShared: { - ...state.adhocShared, - left: { - ...state.adhocShared.left, - profile: null, - }, - }, - adhocComparison: { - ...state.adhocComparison, - left: { - ...state.adhocComparison.left, - file, - flamebearer: flamebearer ? decodeFlamebearer(flamebearer) : null, - }, - }, - }; - case SET_ADHOC_RIGHT_FILE: - ({ - payload: { file, flamebearer }, - } = action); - return { - ...state, - adhocShared: { - ...state.adhocShared, - right: { - ...state.adhocShared.right, - profile: null, - }, - }, - adhocComparison: { - ...state.adhocComparison, - right: { - ...state.adhocComparison.right, - file, - flamebearer: flamebearer ? decodeFlamebearer(flamebearer) : null, - }, - }, - }; - case REQUEST_ADHOC_PROFILES: - return { - ...state, - areProfilesLoading: true, - }; - case RECEIVE_ADHOC_PROFILES: - ({ - payload: { profiles }, - } = action); - return { - ...state, - areProfilesLoading: false, - profiles, - }; - case CANCEL_ADHOC_PROFILES: - return { - ...state, - areProfilesLoading: false, - }; - case SET_ADHOC_PROFILE: - ({ - payload: { profile }, - } = action); - return { - ...state, - adhocSingle: { - ...state.adhocSingle, - file: null, - profile, - }, - }; - case REQUEST_ADHOC_PROFILE: - return { - ...state, - adhocSingle: { - ...state.adhocSingle, - isProfileLoading: true, - }, - }; - case RECEIVE_ADHOC_PROFILE: - ({ - payload: { flamebearer }, - } = action); - return { - ...state, - adhocSingle: { - raw: JSON.parse(JSON.stringify(flamebearer)), - ...state.adhocSingle, - flamebearer: decodeFlamebearer(flamebearer), - isProfileLoading: false, - }, - }; - case CANCEL_ADHOC_PROFILE: - return { - ...state, - adhocSingle: { - ...state.adhocSingle, - isProfileLoading: false, - }, - }; - - /******************************/ - /* Adhoc Comparison */ - /******************************/ - case SET_ADHOC_LEFT_PROFILE: - ({ - payload: { profile }, - } = action); - return { - ...state, - adhocShared: { - ...state.adhocShared, - left: { - ...state.adhocShared.left, - profile, - }, - }, - adhocComparison: { - ...state.adhocComparison, - left: { - ...state.adhocComparison.left, - file: null, - }, - }, - }; - case REQUEST_ADHOC_LEFT_PROFILE: - return { - ...state, - adhocComparison: { - ...state.adhocComparison, - left: { - ...state.adhocComparison.left, - isProfileLoading: true, - }, - }, - }; - case RECEIVE_ADHOC_LEFT_PROFILE: - ({ - payload: { flamebearer }, - } = action); - return { - ...state, - adhocComparison: { - ...state.adhocComparison, - left: { - raw: JSON.parse(JSON.stringify(flamebearer)), - ...state.adhocComparison.left, - flamebearer: decodeFlamebearer(flamebearer), - isProfileLoading: false, - }, - }, - }; - case CANCEL_ADHOC_LEFT_PROFILE: - return { - ...state, - adhocComparison: { - ...state.adhocComparison, - left: { - ...state.adhocComparison.left, - isProfileLoading: false, - }, - }, - }; - case SET_ADHOC_RIGHT_PROFILE: - ({ - payload: { profile }, - } = action); - return { - ...state, - adhocShared: { - ...state.adhocShared, - right: { - ...state.adhocShared.right, - profile, - }, - }, - adhocComparison: { - ...state.adhocComparison, - right: { - ...state.adhocComparison.right, - file: null, - }, - }, - }; - case REQUEST_ADHOC_RIGHT_PROFILE: - return { - ...state, - adhocComparison: { - ...state.adhocComparison, - right: { - ...state.adhocComparison.right, - isProfileLoading: true, - }, - }, - }; - case RECEIVE_ADHOC_RIGHT_PROFILE: - ({ - payload: { flamebearer }, - } = action); - return { - ...state, - adhocComparison: { - ...state.adhocComparison, - right: { - raw: JSON.parse(JSON.stringify(flamebearer)), - ...state.adhocComparison.right, - flamebearer: decodeFlamebearer(flamebearer), - isProfileLoading: false, - }, - }, - }; - case CANCEL_ADHOC_RIGHT_PROFILE: - return { - ...state, - adhocComparison: { - ...state.adhocComparison, - right: { - ...state.adhocComparison.right, - isProfileLoading: false, - }, - }, - }; - case REQUEST_ADHOC_PROFILE_DIFF: - return { - ...state, - adhocComparisonDiff: { - ...state.adhocComparisonDiff, - isProfileLoading: true, - }, - }; - case RECEIVE_ADHOC_PROFILE_DIFF: - ({ - payload: { flamebearer }, - } = action); - return { - ...state, - adhocComparisonDiff: { - ...state.adhocComparisonDiff, - raw: JSON.parse(JSON.stringify(flamebearer)), - flamebearer: decodeFlamebearer(flamebearer), - isProfileLoading: false, - }, - }; - case CANCEL_ADHOC_PROFILE_DIFF: - return { - ...state, - adhocComparisonDiff: { - ...state.adhocComparisonDiff, - isProfileLoading: false, - }, - }; - default: - return state; - } -} diff --git a/webapp/javascript/redux/reducers/index.ts b/webapp/javascript/redux/reducers/index.ts deleted file mode 100644 index fef49d4b76..0000000000 --- a/webapp/javascript/redux/reducers/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import filters from './filters'; - -export default filters; diff --git a/webapp/javascript/redux/store.ts b/webapp/javascript/redux/store.ts index e9f3b6db2a..51f1e7b458 100644 --- a/webapp/javascript/redux/store.ts +++ b/webapp/javascript/redux/store.ts @@ -14,7 +14,6 @@ import { import ReduxQuerySync from 'redux-query-sync'; import { configureStore, combineReducers, Middleware } from '@reduxjs/toolkit'; -import rootReducer from './reducers'; import history from '../util/history'; import settingsReducer from './reducers/settings'; @@ -23,15 +22,16 @@ import continuousReducer, { actions as continuousActions, } from './reducers/continuous'; import serviceDiscoveryReducer from './reducers/serviceDiscovery'; +import adhocReducer from './reducers/adhoc'; import uiStore, { persistConfig as uiPersistConfig } from './reducers/ui'; const reducer = combineReducers({ - root: rootReducer, settings: settingsReducer, user: userReducer, serviceDiscovery: serviceDiscoveryReducer, ui: persistReducer(uiPersistConfig, uiStore), continuous: continuousReducer, + adhoc: adhocReducer, }); // Most times we will display a (somewhat) user friendly message toast @@ -53,7 +53,16 @@ const store = configureStore({ // Based on this issue: https://github.com/rt2zz/redux-persist/issues/988 // and this guide https://redux-toolkit.js.org/usage/usage-guide#use-with-redux-persist - ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], + ignoredActions: [ + FLUSH, + REHYDRATE, + PAUSE, + PERSIST, + PURGE, + REGISTER, + 'adhoc/uploadFile/pending', + 'adhoc/uploadFile/fulfilled', + ], }, }).concat([logErrorMiddleware]), }); diff --git a/webapp/javascript/services/adhoc.ts b/webapp/javascript/services/adhoc.ts new file mode 100644 index 0000000000..d7831ae716 --- /dev/null +++ b/webapp/javascript/services/adhoc.ts @@ -0,0 +1,123 @@ +import { Result } from '@webapp/util/fp'; +import { CustomError } from 'ts-custom-error'; +import { FlamebearerProfileSchema, Profile } from '@pyroscope/models/src'; +import { AllProfilesSchema, AllProfiles } from '@webapp/models/adhoc'; +import type { ZodError } from 'zod'; +import { request, parseResponse } from './base'; +import type { RequestError } from './base'; + +export async function upload( + file: File +): Promise> { + // prepare body + const b64 = await fileToBase64(file); + if (b64.isErr) { + return Result.err(b64.error); + } + + const response = await request('/api/adhoc/v1/upload', { + method: 'POST', + body: JSON.stringify({ + filename: file.name, + profile: b64.value, + }), + }); + return parseResponse(response, FlamebearerProfileSchema); +} + +export async function retrieve( + id: string +): Promise> { + const response = await request(`/api/adhoc/v1/profile/${id}`); + return parseResponse(response, FlamebearerProfileSchema); +} + +export async function retrieveDiff( + leftId: string, + rightId: string +): Promise> { + const response = await request(`/api/adhoc/v1/diff/${leftId}/${rightId}`); + return parseResponse(response, FlamebearerProfileSchema); +} + +export async function retrieveAll(): Promise< + Result +> { + const response = await request(`/api/adhoc/v1/profiles`); + return parseResponse(response, AllProfilesSchema); +} + +/** + * represents an error when trying to convert a File to base64 + */ +export class FileToBase64Error extends CustomError { + public constructor( + public filename: string, + public message: string, + public cause?: Error | DOMException + ) { + super(message); + } +} + +export default function fileToBase64( + file: File +): Promise> { + return new Promise((resolve) => { + const reader = new FileReader(); + + reader.onloadend = () => { + // this is always called, even on failures + if (!reader.error) { + if (!reader.result) { + return resolve( + Result.err(new FileToBase64Error(file.name, 'No result')) + ); + } + + // reader can be used with 'readAsArrayBuffer' which returns an ArrayBuffer + // therefore for the sake of the compiler we must check its value + if (typeof reader.result === 'string') { + // remove the prefix + const base64result = reader.result.split(';base64,')[1]; + if (!base64result) { + return resolve( + Result.err( + new FileToBase64Error(file.name, 'Failed to strip prefix') + ) + ); + } + + // split didn't work + if (base64result === reader.result) { + return resolve( + Result.err( + new FileToBase64Error(file.name, 'Failed to strip prefix') + ) + ); + } + + // the string is prefixed with + return resolve(Result.ok(base64result)); + } + } + + // should not happen + return resolve(Result.err(new FileToBase64Error(file.name, 'No result'))); + }; + + reader.onerror = () => { + resolve( + Result.err( + new FileToBase64Error( + file.name, + 'File reading has failed', + reader.error || undefined + ) + ) + ); + }; + + reader.readAsDataURL(file); + }); +}