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;