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;