github.com/grafana/pyroscope@v1.18.0/public/app/components/ExportData.tsx (about) 1 import Button from '@pyroscope/ui/Button'; 2 import handleError from '@pyroscope/util/handleError'; 3 import OutsideClickHandler from 'react-outside-click-handler'; 4 import React, { useState } from 'react'; 5 import saveAs from 'file-saver'; 6 import showModalWithInput from '@pyroscope/components/Modals/ModalWithInput'; 7 import styles from './ExportData.module.scss'; 8 import { ContinuousState } from '@pyroscope/redux/reducers/continuous'; 9 import { 10 convertPresetsToDate, 11 formatAsOBject, 12 } from '@pyroscope/util/formatDate'; 13 import { createBiggestInterval } from '@pyroscope/util/timerange'; 14 import { downloadWithOrgID } from '@pyroscope/services/base'; 15 import { faShareSquare } from '@fortawesome/free-solid-svg-icons/faShareSquare'; 16 import { Field, Message } from 'protobufjs/light'; 17 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 18 import { format } from 'date-fns'; 19 import { Profile } from '@pyroscope/legacy/models'; 20 import { Tooltip } from '@pyroscope/ui/Tooltip'; 21 import { useAppDispatch, useAppSelector } from '@pyroscope/redux/hooks'; 22 import 'compression-streams-polyfill'; 23 24 /* eslint-disable react/destructuring-assignment */ 25 26 // These are modeled individually since each condition may have different values 27 // For example, a exportPprof: true may accept a custom export function 28 // For cases like grafana 29 type exportJSON = { 30 exportJSON?: boolean; 31 flamebearer: Profile; 32 }; 33 34 type exportPprof = { 35 exportPprof?: boolean; 36 flamebearer: Profile; 37 }; 38 39 type exportHTML = { 40 exportHTML?: boolean; 41 fetchUrlFunc?: () => string; 42 flamebearer: Profile; 43 }; 44 45 type exportFlamegraphDotCom = { 46 exportFlamegraphDotCom?: boolean; 47 exportFlamegraphDotComFn?: (name?: string) => Promise<string | null>; 48 flamebearer: Profile; 49 }; 50 51 type exportPNG = { 52 exportPNG?: boolean; 53 flamebearer: Profile; 54 }; 55 56 export class PprofRequest extends Message<PprofRequest> { 57 constructor( 58 profile_typeID: string, 59 label_selector: string, 60 start: number, 61 end: number 62 ) { 63 super(); 64 this.profile_typeID = profile_typeID; 65 this.label_selector = label_selector; 66 this.start = start; 67 this.end = end; 68 } 69 70 @Field.d(1, 'string') 71 profile_typeID: string; 72 73 @Field.d(2, 'string') 74 label_selector: string; 75 76 @Field.d(3, 'int64') 77 start: number; 78 79 @Field.d(4, 'int64') 80 end: number; 81 } 82 83 export type ExportDataProps = { 84 buttonEl?: React.ComponentType<{ 85 onClick: (event: React.MouseEvent<HTMLButtonElement>) => void; 86 }>; 87 } & exportPprof & 88 exportHTML & 89 exportFlamegraphDotCom & 90 exportPNG & 91 exportJSON; 92 93 function biggestTimeRangeInUnixMs(state: ContinuousState) { 94 return createBiggestInterval({ 95 from: [state.from, state.leftFrom, state.rightFrom] 96 .map(formatAsOBject) 97 .map((d) => d.valueOf()), 98 until: [state.until, state.leftUntil, state.leftUntil] 99 .map(formatAsOBject) 100 .map((d) => d.valueOf()), 101 }); 102 } 103 104 function buildPprofQuery(state: ContinuousState) { 105 const { from, until } = biggestTimeRangeInUnixMs(state); 106 const labelsIndex = state.query.indexOf('{'); 107 const profileTypeID = state.query.substring(0, labelsIndex); 108 const label_selector = state.query.substring(labelsIndex); 109 const message = new PprofRequest(profileTypeID, label_selector, from, until); 110 return PprofRequest.encode(message).finish(); 111 } 112 113 function ExportData(props: ExportDataProps) { 114 const { exportJSON = false, exportFlamegraphDotCom = true } = props; 115 let { exportPprof } = props; 116 const exportPNG = true; 117 const exportHTML = false; 118 const dispatch = useAppDispatch(); 119 const pprofQuery = useAppSelector((state: { continuous: ContinuousState }) => 120 buildPprofQuery(state.continuous) 121 ); 122 123 if ( 124 !exportPNG && 125 !exportJSON && 126 !exportPprof && 127 !exportHTML && 128 !exportFlamegraphDotCom 129 ) { 130 throw new Error('At least one export button should be enabled'); 131 } 132 133 const [toggleMenu, setToggleMenu] = useState(false); 134 135 const downloadJSON = async () => { 136 if (!props.exportJSON) { 137 return; 138 } 139 140 // TODO additional check this won't be needed once we use strictNullChecks 141 if (props.exportJSON) { 142 const { flamebearer } = props; 143 144 const defaultExportName = getFilename( 145 flamebearer.metadata.appName, 146 flamebearer.metadata.startTime, 147 flamebearer.metadata.endTime 148 ); 149 // get user input from modal 150 const customExportName = await getCustomExportName(defaultExportName); 151 // return if user cancels the modal 152 if (!customExportName) { 153 return; 154 } 155 156 const filename = `${customExportName}.json`; 157 158 const dataStr = `data:text/json;charset=utf-8,${encodeURIComponent( 159 JSON.stringify(flamebearer) 160 )}`; 161 162 saveAs(dataStr, filename); 163 } 164 }; 165 166 const downloadPNG = async () => { 167 if (exportPNG) { 168 const { flamebearer } = props; 169 170 const defaultExportName = getFilename( 171 flamebearer.metadata.appName, 172 flamebearer.metadata.startTime, 173 flamebearer.metadata.endTime 174 ); 175 // get user input from modal 176 const customExportName = await getCustomExportName(defaultExportName); 177 // return if user cancels the modal 178 if (!customExportName) { 179 return; 180 } 181 182 const filename = `${customExportName}.png`; 183 184 // TODO use ref 185 // this won't work for comparison side by side 186 const canvasElement = document.querySelector( 187 '.flamegraph-canvas' 188 ) as HTMLCanvasElement; 189 canvasElement.toBlob(function (blob) { 190 if (!blob) { 191 return; 192 } 193 saveAs(blob, filename); 194 }); 195 } 196 }; 197 198 const handleToggleMenu = (event: React.MouseEvent<HTMLButtonElement>) => { 199 event.preventDefault(); 200 setToggleMenu(!toggleMenu); 201 }; 202 203 const downloadPprof = async function () { 204 if (!exportPprof) { 205 return; 206 } 207 208 if (props.exportPprof) { 209 // get user input from modal 210 const customExportName = await getCustomExportName('profile.pb.gz'); 211 // return if user cancels the modal 212 if (!customExportName) { 213 return; 214 } 215 const response = await downloadWithOrgID( 216 '/querier.v1.QuerierService/SelectMergeProfile', 217 { 218 headers: { 219 'content-type': 'application/proto', 220 }, 221 method: 'POST', 222 body: pprofQuery, 223 } 224 ); 225 if (response.isErr) { 226 handleError(dispatch, 'Failed to export to pprof', response.error); 227 return; 228 } 229 const data = await new Response( 230 response.value.body?.pipeThrough(new CompressionStream('gzip')) 231 ).blob(); 232 saveAs(data, customExportName); 233 } 234 }; 235 236 const downloadHTML = async function () {}; 237 238 async function getCustomExportName(defaultExportName: string) { 239 return showModalWithInput({ 240 title: 'Enter export name', 241 confirmButtonText: 'Export', 242 input: 'text', 243 inputValue: defaultExportName, 244 inputPlaceholder: 'Export name', 245 type: 'normal', 246 validationMessage: 'Name must not be empty', 247 onConfirm: (value: ShamefulAny) => value, 248 }); 249 } 250 251 return ( 252 <div className={styles.dropdownContainer}> 253 <OutsideClickHandler onOutsideClick={() => setToggleMenu(false)}> 254 {props.buttonEl ? ( 255 <props.buttonEl onClick={handleToggleMenu} /> 256 ) : ( 257 <Tooltip placement="top" title="Export Data"> 258 <Button 259 className={styles.toggleMenuButton} 260 onClick={handleToggleMenu} 261 > 262 <FontAwesomeIcon icon={faShareSquare} /> 263 </Button> 264 </Tooltip> 265 )} 266 <div className={toggleMenu ? styles.menuShow : styles.menuHide}> 267 {exportPNG && ( 268 <button 269 className={styles.dropdownMenuItem} 270 onClick={downloadPNG} 271 onKeyPress={downloadPNG} 272 type="button" 273 > 274 png 275 </button> 276 )} 277 {exportJSON && ( 278 <button 279 className={styles.dropdownMenuItem} 280 type="button" 281 onClick={downloadJSON} 282 > 283 json 284 </button> 285 )} 286 {exportPprof && ( 287 <button 288 className={styles.dropdownMenuItem} 289 type="button" 290 onClick={downloadPprof} 291 > 292 pprof 293 </button> 294 )} 295 {exportHTML && ( 296 <button 297 className={styles.dropdownMenuItem} 298 type="button" 299 onClick={downloadHTML} 300 > 301 {' '} 302 html 303 </button> 304 )} 305 </div> 306 </OutsideClickHandler> 307 </div> 308 ); 309 } 310 311 const dateFormat = 'yyyy-MM-dd_HHmm'; 312 313 function dateForExportFilename(from: string, until: string) { 314 let start = new Date(Math.round(parseInt(from, 10) * 1000)); 315 let end = new Date(Math.round(parseInt(until, 10) * 1000)); 316 317 if (/^now-/.test(from) && until === 'now') { 318 const { _from } = convertPresetsToDate(from); 319 320 start = new Date(Math.round(parseInt(_from.toString(), 10) * 1000)); 321 end = new Date(); 322 } 323 324 return `${format(start, dateFormat)}-to-${format(end, dateFormat)}`; 325 } 326 327 export function getFilename( 328 appName?: string, 329 startTime?: number, 330 endTime?: number 331 ) { 332 // const appname = flamebearer.metadata.appName; 333 let date = ''; 334 335 if (startTime && endTime) { 336 date = dateForExportFilename(startTime.toString(), endTime.toString()); 337 } 338 339 // both name and date are available 340 if (appName && date) { 341 return [appName, date].join('_'); 342 } 343 344 // only fullname 345 if (appName) { 346 return appName; 347 } 348 349 // only date 350 if (date) { 351 return ['flamegraph', date].join('_'); 352 } 353 354 // nothing is available, use a generic name 355 return `flamegraph`; 356 } 357 358 export default ExportData;