diff --git a/package.json b/package.json index b9668d426c..d354f0b59d 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,9 @@ "@react-hook/window-size": "^3.0.7", "@reduxjs/toolkit": "^1.6.2", "@szhsin/react-menu": "3.5.2", + "@types/file-saver": "^2.0.5", + "compression-streams-polyfill": "^0.1.4", + "file-saver": "^2.0.5", "graphviz-react": "^1.2.5", "jquery": "^3.6.4", "pyroscope-oss": "git+https://github.com/pyroscope-io/pyroscope.git#f710852", diff --git a/public/app/overrides/components/ExportData.tsx b/public/app/overrides/components/ExportData.tsx index 399f82a77a..3fd4bba17e 100644 --- a/public/app/overrides/components/ExportData.tsx +++ b/public/app/overrides/components/ExportData.tsx @@ -1,23 +1,27 @@ -/* eslint-disable react/destructuring-assignment */ -import React, { useState } from 'react'; -import { format } from 'date-fns'; -import OutsideClickHandler from 'react-outside-click-handler'; -import { Tooltip } from '@pyroscope/webapp/javascript/ui/Tooltip'; import Button from '@webapp/ui/Button'; -import { faShareSquare } from '@fortawesome/free-solid-svg-icons/faShareSquare'; -import { createBiggestInterval } from '@webapp/util/timerange'; -import { convertPresetsToDate, formatAsOBject } from '@webapp/util/formatDate'; -import { Profile } from '@pyroscope/models/src'; +import handleError from '@webapp/util/handleError'; +import OutsideClickHandler from 'react-outside-click-handler'; +import React, { useState } from 'react'; +import saveAs from 'file-saver'; +import showModalWithInput from '@pyroscope/webapp/javascript/components/Modals/ModalWithInput'; +import styles from '@pyroscope/webapp/javascript/components/ExportData.module.scss'; import { ContinuousState } from '@pyroscope/webapp/javascript/redux/reducers/continuous/state'; +import { convertPresetsToDate, formatAsOBject } from '@webapp/util/formatDate'; +import { createBiggestInterval } from '@webapp/util/timerange'; +import { downloadWithOrgID } from '@webapp/services/base'; +import { faShareSquare } from '@fortawesome/free-solid-svg-icons/faShareSquare'; +import { Field, Message } from 'protobufjs/light'; +import { flameGraphUpload } from '@phlare/services/flamegraphcom'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { format } from 'date-fns'; import { isRouteActive, ROUTES } from '@phlare/pages/routes'; +import { Profile } from '@pyroscope/models/src'; +import { Tooltip } from '@pyroscope/webapp/javascript/ui/Tooltip'; +import { useAppDispatch, useAppSelector } from '@webapp/redux/hooks'; import { useLocation } from 'react-router-dom'; -import showModalWithInput from '@pyroscope/webapp/javascript/components/Modals/ModalWithInput'; -import styles from '@pyroscope/webapp/javascript/components/ExportData.module.scss'; -import { downloadWithOrgID } from '@webapp/services/base'; -import { useAppSelector, useAppDispatch } from '@webapp/redux/hooks'; -import { Message, Field } from 'protobufjs/light'; -import handleError from '@webapp/util/handleError'; +import 'compression-streams-polyfill'; + +/* eslint-disable react/destructuring-assignment */ // These are modeled individually since each condition may have different values // For example, a exportPprof: true may accept a custom export function @@ -101,7 +105,7 @@ function buildPprofQuery(state: ContinuousState) { function ExportData(props: ExportDataProps) { const { exportJSON = false } = props; let exportPprof = props.exportPprof; - let exportFlamegraphDotCom = false; // todo: add support for flamegraph.com + let exportFlamegraphDotCom = true; let exportPNG = true; let exportHTML = false; const { pathname } = useLocation(); @@ -154,12 +158,8 @@ function ExportData(props: ExportDataProps) { const dataStr = `data:text/json;charset=utf-8,${encodeURIComponent( JSON.stringify(flamebearer) )}`; - const downloadAnchorNode = document.createElement('a'); - downloadAnchorNode.setAttribute('href', dataStr); - downloadAnchorNode.setAttribute('download', filename); - document.body.appendChild(downloadAnchorNode); // required for firefox - downloadAnchorNode.click(); - downloadAnchorNode.remove(); + + saveAs(dataStr, filename); } }; @@ -181,39 +181,20 @@ function ExportData(props: ExportDataProps) { if (!customExportName) { return; } - // todo CORS - const response = await fetch('https://flamegraph.com/upload/v1', { - method: 'POST', - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ - fileTypeData: { - units: flamebearer.metadata.units, - spyName: flamebearer.metadata.spyName, - }, - name: customExportName, - profile: btoa(JSON.stringify(flamebearer)), - type: 'json', - }), - }); - const data = await response.json(); - console.log(data); - // props.exportFlamegraphDotComFn(customExportName).then((url) => { - // // there has been an error which should've been handled - // // so we just ignore it - // if (!url) { - // return; - // } - - // const dlLink = document.createElement('a'); - // dlLink.target = '_blank'; - // dlLink.href = url; - - // document.body.appendChild(dlLink); - // dlLink.click(); - // document.body.removeChild(dlLink); - // }); + + const url = await flameGraphUpload(customExportName, flamebearer); + if (url.isErr) { + handleError(dispatch, 'Failed to export to flamegraph.com', url.error); + return; + } + + const dlLink = document.createElement('a'); + dlLink.target = '_blank'; + dlLink.href = url.value; + + document.body.appendChild(dlLink); + dlLink.click(); + document.body.removeChild(dlLink); }; const downloadPNG = async () => { @@ -234,28 +215,17 @@ function ExportData(props: ExportDataProps) { const filename = `${customExportName}.png`; - const mimeType = 'png'; // TODO use ref // this won't work for comparison side by side const canvasElement = document.querySelector( '.flamegraph-canvas' ) as HTMLCanvasElement; - const MIME_TYPE = `image/${mimeType}`; - const imgURL = canvasElement.toDataURL(); - const dlLink = document.createElement('a'); - - dlLink.download = filename; - dlLink.href = imgURL; - dlLink.dataset.downloadurl = [ - MIME_TYPE, - dlLink.download, - dlLink.href, - ].join(':'); - - document.body.appendChild(dlLink); - dlLink.click(); - document.body.removeChild(dlLink); - setToggleMenu(!toggleMenu); + canvasElement.toBlob(function (blob) { + if (!blob) { + return; + } + saveAs(blob, filename); + }); } }; @@ -288,18 +258,13 @@ function ExportData(props: ExportDataProps) { ); if (response.isErr) { handleError(dispatch, 'Failed to export to pprof', response.error); - return null; + return; } let data = await new Response( response.value.body?.pipeThrough(new CompressionStream('gzip')) ).blob(); - let element = document.createElement('a'); - element.setAttribute('href', window.URL.createObjectURL(data)); - element.setAttribute('download', customExportName); - element.style.display = 'none'; - document.body.appendChild(element); - - element.click(); + saveAs(data, customExportName); + return; } }; diff --git a/public/app/services/flamegraphcom.ts b/public/app/services/flamegraphcom.ts new file mode 100644 index 0000000000..6f8f3d5b6e --- /dev/null +++ b/public/app/services/flamegraphcom.ts @@ -0,0 +1,52 @@ +import { + parseResponse, + RequestNotOkError, +} from '@pyroscope/webapp/javascript/services/base'; +import { z, ZodError } from 'zod'; +import { Result } from '@webapp/util/fp'; +import type { RequestError } from '@webapp/services/base'; +import { Profile } from '@pyroscope/models/src'; + +export async function flameGraphUpload( + name: string, + flamebearer: Profile +): Promise> { + const response = await fetch('https://flamegraph.com/api/upload/v1', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + fileTypeData: { + units: flamebearer.metadata.units, + spyName: flamebearer.metadata.spyName, + }, + name: name, + profile: btoa(JSON.stringify(flamebearer)), + type: 'json', + }), + }); + if (!response.ok) { + return Result.err( + new RequestNotOkError( + response.status, + `Failed to upload to flamegraph.com: ${response.statusText}` + ) + ); + } + const body = await response.text(); + return parseResponse( + Result.ok(JSON.parse(body)), + z + .preprocess( + (arg) => { + return arg; + }, + z.object({ + key: z.string(), + url: z.string(), + }) + ) + .transform((arg) => arg.url) + ); +} diff --git a/yarn.lock b/yarn.lock index 2ccb117706..88e7223b49 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3576,6 +3576,11 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/file-saver@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@types/file-saver/-/file-saver-2.0.5.tgz#9ee342a5d1314bb0928375424a2f162f97c310c7" + integrity sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ== + "@types/flot@^0.0.32": version "0.0.32" resolved "https://registry.yarnpkg.com/@types/flot/-/flot-0.0.32.tgz#2ab260f2958dcab1acfb5c24b87898f1d22417d8" @@ -5725,6 +5730,13 @@ compressible@~2.0.16: dependencies: mime-db ">= 1.43.0 < 2" +compression-streams-polyfill@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/compression-streams-polyfill/-/compression-streams-polyfill-0.1.4.tgz#c683c471e869a03a100f80613e54aa71c70b2a85" + integrity sha512-PL9Yz8Nss9QdllwQ/XGRW/MJqaE90ngKLu4Mm42Z45a4qwWuYctgLkJRVqURdrzSWh4R/X68x0k8KGRTYclVhA== + dependencies: + fflate "^0.8.0" + compression@^1.7.4: version "1.7.4" resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f" @@ -7832,6 +7844,11 @@ fd-slicer@~1.1.0: dependencies: pend "~1.2.0" +fflate@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.0.tgz#f93ad1dcbe695a25ae378cf2386624969a7cda32" + integrity sha512-FAdS4qMuFjsJj6XHbBaZeXOgaypXp8iw/Tpyuq/w3XA41jjLHT8NPA+n7czH/DDhdncq0nAyDZmPeWXh2qmdIg== + figures@^3.0.0, figures@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" @@ -7854,6 +7871,11 @@ file-loader@^6.2.0, file-loader@~6.2.0: loader-utils "^2.0.0" schema-utils "^3.0.0" +file-saver@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.5.tgz#d61cfe2ce059f414d899e9dd6d4107ee25670c38" + integrity sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA== + file-selector@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.4.0.tgz#59ec4f27aa5baf0841e9c6385c8386bef4d18b17"