github.com/pyroscope-io/pyroscope@v0.37.3-0.20230725203016-5f6947968bd0/webapp/javascript/components/ExportData.tsx (about) 1 /* eslint-disable react/destructuring-assignment */ 2 import React, { useState } from 'react'; 3 import { format } from 'date-fns'; 4 import OutsideClickHandler from 'react-outside-click-handler'; 5 import { Tooltip } from '@pyroscope/webapp/javascript/ui/Tooltip'; 6 import Button from '@webapp/ui/Button'; 7 import { faShareSquare } from '@fortawesome/free-solid-svg-icons/faShareSquare'; 8 import { buildRenderURL } from '@webapp/util/updateRequests'; 9 import { convertPresetsToDate } from '@webapp/util/formatDate'; 10 import { Profile } from '@pyroscope/models/src'; 11 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 12 import basename from '@webapp/util/baseurl'; 13 import showModalWithInput from './Modals/ModalWithInput'; 14 import styles from './ExportData.module.scss'; 15 16 // These are modeled individually since each condition may have different values 17 // For example, a exportPprof: true may accept a custom export function 18 // For cases like grafana 19 type exportJSON = { 20 exportJSON?: boolean; 21 flamebearer: Profile; 22 }; 23 24 type exportPprof = { 25 exportPprof?: boolean; 26 flamebearer: Profile; 27 }; 28 29 type exportHTML = { 30 exportHTML?: boolean; 31 fetchUrlFunc?: () => string; 32 flamebearer: Profile; 33 }; 34 35 type exportFlamegraphDotCom = { 36 exportFlamegraphDotCom?: boolean; 37 exportFlamegraphDotComFn?: (name?: string) => Promise<string | null>; 38 flamebearer: Profile; 39 }; 40 41 type exportPNG = { 42 exportPNG?: boolean; 43 flamebearer: Profile; 44 }; 45 46 type ExportDataProps = exportPprof & 47 exportHTML & 48 exportFlamegraphDotCom & 49 exportPNG & 50 exportJSON; 51 52 function ExportData(props: ExportDataProps) { 53 const { 54 exportPprof = false, 55 exportJSON = false, 56 exportPNG = false, 57 exportHTML = false, 58 exportFlamegraphDotCom = false, 59 } = props; 60 if ( 61 !exportPNG && 62 !exportJSON && 63 !exportPprof && 64 !exportHTML && 65 !exportFlamegraphDotCom 66 ) { 67 throw new Error('At least one export button should be enabled'); 68 } 69 70 const [toggleMenu, setToggleMenu] = useState(false); 71 72 const downloadJSON = async () => { 73 if (!props.exportJSON) { 74 return; 75 } 76 77 // TODO additional check this won't be needed once we use strictNullChecks 78 if (props.exportJSON) { 79 const { flamebearer } = props; 80 81 const defaultExportName = getFilename( 82 flamebearer.metadata.appName, 83 flamebearer.metadata.startTime, 84 flamebearer.metadata.endTime 85 ); 86 // get user input from modal 87 const customExportName = await getCustomExportName(defaultExportName); 88 // return if user cancels the modal 89 if (!customExportName) return; 90 91 const filename = `${customExportName}.json`; 92 93 const dataStr = `data:text/json;charset=utf-8,${encodeURIComponent( 94 JSON.stringify(flamebearer) 95 )}`; 96 const downloadAnchorNode = document.createElement('a'); 97 downloadAnchorNode.setAttribute('href', dataStr); 98 downloadAnchorNode.setAttribute('download', filename); 99 document.body.appendChild(downloadAnchorNode); // required for firefox 100 downloadAnchorNode.click(); 101 downloadAnchorNode.remove(); 102 } 103 }; 104 105 const downloadFlamegraphDotCom = async () => { 106 if (!props.exportFlamegraphDotCom) { 107 return; 108 } 109 110 // TODO additional check this won't be needed once we use strictNullChecks 111 if (props.exportFlamegraphDotCom && props.exportFlamegraphDotComFn) { 112 const { flamebearer } = props; 113 114 const defaultExportName = getFilename( 115 flamebearer.metadata.appName, 116 flamebearer.metadata.startTime, 117 flamebearer.metadata.endTime 118 ); 119 // get user input from modal 120 const customExportName = await getCustomExportName(defaultExportName); 121 // return if user cancels the modal 122 if (!customExportName) return; 123 124 props.exportFlamegraphDotComFn(customExportName).then((url) => { 125 // there has been an error which should've been handled 126 // so we just ignore it 127 if (!url) { 128 return; 129 } 130 131 const dlLink = document.createElement('a'); 132 dlLink.target = '_blank'; 133 dlLink.href = url; 134 135 document.body.appendChild(dlLink); 136 dlLink.click(); 137 document.body.removeChild(dlLink); 138 }); 139 } 140 }; 141 142 const downloadPNG = async () => { 143 if (props.exportPNG) { 144 const { flamebearer } = props; 145 146 const defaultExportName = getFilename( 147 flamebearer.metadata.appName, 148 flamebearer.metadata.startTime, 149 flamebearer.metadata.endTime 150 ); 151 // get user input from modal 152 const customExportName = await getCustomExportName(defaultExportName); 153 // return if user cancels the modal 154 if (!customExportName) return; 155 156 const filename = `${customExportName}.png`; 157 158 const mimeType = 'png'; 159 // TODO use ref 160 // this won't work for comparison side by side 161 const canvasElement = document.querySelector( 162 '.flamegraph-canvas' 163 ) as HTMLCanvasElement; 164 const MIME_TYPE = `image/${mimeType}`; 165 const imgURL = canvasElement.toDataURL(); 166 const dlLink = document.createElement('a'); 167 168 dlLink.download = filename; 169 dlLink.href = imgURL; 170 dlLink.dataset.downloadurl = [ 171 MIME_TYPE, 172 dlLink.download, 173 dlLink.href, 174 ].join(':'); 175 176 document.body.appendChild(dlLink); 177 dlLink.click(); 178 document.body.removeChild(dlLink); 179 setToggleMenu(!toggleMenu); 180 } 181 }; 182 183 const handleToggleMenu = (event: React.MouseEvent<HTMLButtonElement>) => { 184 event.preventDefault(); 185 setToggleMenu(!toggleMenu); 186 }; 187 188 const downloadPprof = function () { 189 if (!props.exportPprof) { 190 return; 191 } 192 193 if (props.exportPprof) { 194 const { flamebearer } = props; 195 196 if ( 197 !flamebearer.metadata.startTime || 198 !flamebearer.metadata.endTime || 199 !flamebearer.metadata.query || 200 !flamebearer.metadata.maxNodes 201 ) { 202 throw new Error( 203 'Missing one of the required parameters "flamebearer.metadata.startTime", "flamebearer.metadata.endTime", "flamebearer.metadata.query", "flamebearer.metadata.maxNodes"' 204 ); 205 } 206 207 // TODO 208 // This build url won't work in the following cases: 209 // * absence of a public server (grafana, standalone) 210 // * diff mode 211 let url = `${buildRenderURL({ 212 from: flamebearer.metadata.startTime.toString(), 213 until: flamebearer.metadata.endTime.toString(), 214 query: flamebearer.metadata.query, 215 maxNodes: flamebearer.metadata.maxNodes, 216 })}&format=pprof`; 217 url = baseURLCompatible(url); 218 const downloadAnchorNode = document.createElement('a'); 219 downloadAnchorNode.setAttribute('href', url); 220 document.body.appendChild(downloadAnchorNode); // required for firefox 221 downloadAnchorNode.click(); 222 downloadAnchorNode.remove(); 223 setToggleMenu(false); 224 } 225 }; 226 227 const downloadHTML = async function () { 228 if (props.exportHTML) { 229 const { flamebearer } = props; 230 231 if ( 232 !flamebearer.metadata.startTime || 233 !flamebearer.metadata.endTime || 234 !flamebearer.metadata.query || 235 !flamebearer.metadata.maxNodes 236 ) { 237 throw new Error( 238 'Missing one of the required parameters "flamebearer.metadata.startTime", "flamebearer.metadata.endTime", "flamebearer.metadata.query", "flamebearer.metadata.maxNodes"' 239 ); 240 } 241 242 const url = 243 typeof props.fetchUrlFunc === 'function' 244 ? props.fetchUrlFunc() 245 : buildRenderURL({ 246 from: flamebearer.metadata.startTime.toString(), 247 until: flamebearer.metadata.endTime.toString(), 248 query: flamebearer.metadata.query, 249 maxNodes: flamebearer.metadata.maxNodes, 250 }); 251 let urlWithFormat = `${url}&format=html`; 252 urlWithFormat = baseURLCompatible(urlWithFormat); 253 const defaultExportName = getFilename( 254 flamebearer.metadata.appName, 255 flamebearer.metadata.startTime, 256 flamebearer.metadata.endTime 257 ); 258 // get user input from modal 259 const customExportName = await getCustomExportName(defaultExportName); 260 // return if user cancels the modal 261 if (!customExportName) return; 262 263 const filename = `${customExportName}.html`; 264 265 const downloadAnchorNode = document.createElement('a'); 266 downloadAnchorNode.setAttribute('href', urlWithFormat); 267 downloadAnchorNode.setAttribute('download', filename); 268 document.body.appendChild(downloadAnchorNode); // required for firefox 269 downloadAnchorNode.click(); 270 downloadAnchorNode.remove(); 271 } 272 }; 273 274 async function getCustomExportName(defaultExportName: string) { 275 return showModalWithInput({ 276 title: 'Enter export name', 277 confirmButtonText: 'Export', 278 input: 'text', 279 inputValue: defaultExportName, 280 inputPlaceholder: 'Export name', 281 type: 'normal', 282 validationMessage: 'Name must not be empty', 283 onConfirm: (value: ShamefulAny) => value, 284 }); 285 } 286 287 return ( 288 <div className={styles.dropdownContainer}> 289 <OutsideClickHandler onOutsideClick={() => setToggleMenu(false)}> 290 <Tooltip placement="top" title="Export Data"> 291 <Button 292 className={styles.toggleMenuButton} 293 onClick={handleToggleMenu} 294 > 295 <FontAwesomeIcon icon={faShareSquare} /> 296 </Button> 297 </Tooltip> 298 <div className={toggleMenu ? styles.menuShow : styles.menuHide}> 299 {exportPNG && ( 300 <button 301 className={styles.dropdownMenuItem} 302 onClick={downloadPNG} 303 onKeyPress={downloadPNG} 304 type="button" 305 > 306 png 307 </button> 308 )} 309 {exportJSON && ( 310 <button 311 className={styles.dropdownMenuItem} 312 type="button" 313 onClick={downloadJSON} 314 > 315 json 316 </button> 317 )} 318 {exportPprof && ( 319 <button 320 className={styles.dropdownMenuItem} 321 type="button" 322 onClick={downloadPprof} 323 > 324 pprof 325 </button> 326 )} 327 {exportHTML && ( 328 <button 329 className={styles.dropdownMenuItem} 330 type="button" 331 onClick={downloadHTML} 332 > 333 {' '} 334 html 335 </button> 336 )} 337 {exportFlamegraphDotCom && ( 338 <button 339 className={styles.dropdownMenuItem} 340 type="button" 341 onClick={downloadFlamegraphDotCom} 342 > 343 {' '} 344 flamegraph.com 345 </button> 346 )} 347 </div> 348 </OutsideClickHandler> 349 </div> 350 ); 351 } 352 353 function baseURLCompatible(url: string) { 354 const base = basename(); 355 if (base) { 356 url = `${base}${url}`; 357 } 358 return url; 359 } 360 361 const dateFormat = 'yyyy-MM-dd_HHmm'; 362 363 function dateForExportFilename(from: string, until: string) { 364 let start = new Date(Math.round(parseInt(from, 10) * 1000)); 365 let end = new Date(Math.round(parseInt(until, 10) * 1000)); 366 367 if (/^now-/.test(from) && until === 'now') { 368 const { _from } = convertPresetsToDate(from); 369 370 start = new Date(Math.round(parseInt(_from.toString(), 10) * 1000)); 371 end = new Date(); 372 } 373 374 return `${format(start, dateFormat)}-to-${format(end, dateFormat)}`; 375 } 376 377 export function getFilename( 378 appName?: string, 379 startTime?: number, 380 endTime?: number 381 ) { 382 // const appname = flamebearer.metadata.appName; 383 let date = ''; 384 385 if (startTime && endTime) { 386 date = dateForExportFilename(startTime.toString(), endTime.toString()); 387 } 388 389 // both name and date are available 390 if (appName && date) { 391 return [appName, date].join('_'); 392 } 393 394 // only fullname 395 if (appName) { 396 return appName; 397 } 398 399 // only date 400 if (date) { 401 return ['flamegraph', date].join('_'); 402 } 403 404 // nothing is available, use a generic name 405 return `flamegraph`; 406 } 407 408 export default ExportData;