github.com/wfusion/gofusion@v1.1.14/common/infra/asynq/asynqmon/ui/src/components/MetricsFetchControls.tsx (about)

     1  import React from "react";
     2  import { connect, ConnectedProps } from "react-redux";
     3  import { makeStyles, Theme } from "@material-ui/core/styles";
     4  import Button, { ButtonProps } from "@material-ui/core/Button";
     5  import ButtonGroup from "@material-ui/core/ButtonGroup";
     6  import IconButton from "@material-ui/core/IconButton";
     7  import Popover from "@material-ui/core/Popover";
     8  import Radio from "@material-ui/core/Radio";
     9  import RadioGroup from "@material-ui/core/RadioGroup";
    10  import Checkbox from "@material-ui/core/Checkbox";
    11  import FormControlLabel from "@material-ui/core/FormControlLabel";
    12  import FormControl from "@material-ui/core/FormControl";
    13  import FormGroup from "@material-ui/core/FormGroup";
    14  import FormLabel from "@material-ui/core/FormLabel";
    15  import TextField from "@material-ui/core/TextField";
    16  import Typography from "@material-ui/core/Typography";
    17  import ArrowLeftIcon from "@material-ui/icons/ArrowLeft";
    18  import ArrowRightIcon from "@material-ui/icons/ArrowRight";
    19  import FilterListIcon from "@material-ui/icons/FilterList";
    20  import dayjs from "dayjs";
    21  import { currentUnixtime, parseDuration } from "../utils";
    22  import { AppState } from "../store";
    23  import { isDarkTheme } from "../theme";
    24  
    25  function mapStateToProps(state: AppState) {
    26    return { pollInterval: state.settings.pollInterval };
    27  }
    28  
    29  const connector = connect(mapStateToProps);
    30  type ReduxProps = ConnectedProps<typeof connector>;
    31  
    32  interface Props extends ReduxProps {
    33    // Specifies the endtime in Unix time seconds.
    34    endTimeSec: number;
    35    onEndTimeChange: (t: number, isEndTimeFixed: boolean) => void;
    36  
    37    // Specifies the duration in seconds.
    38    durationSec: number;
    39    onDurationChange: (d: number, isEndTimeFixed: boolean) => void;
    40  
    41    // All available queues.
    42    queues: string[];
    43    // Selected queues.
    44    selectedQueues: string[];
    45    addQueue: (qname: string) => void;
    46    removeQueue: (qname: string) => void;
    47  }
    48  
    49  interface State {
    50    endTimeOption: EndTimeOption;
    51    durationOption: DurationOption;
    52    customEndTime: string; // text shown in input field
    53    customDuration: string; // text shown in input field
    54    customEndTimeError: string;
    55    customDurationError: string;
    56  }
    57  
    58  type EndTimeOption = "real_time" | "freeze_at_now" | "custom";
    59  type DurationOption = "1h" | "6h" | "1d" | "8d" | "30d" | "custom";
    60  
    61  const useStyles = makeStyles((theme) => ({
    62    root: {
    63      display: "flex",
    64      alignItems: "center",
    65    },
    66    endTimeCaption: {
    67      marginRight: theme.spacing(1),
    68    },
    69    shiftButtons: {
    70      marginLeft: theme.spacing(1),
    71    },
    72    buttonGroupRoot: {
    73      height: 29,
    74      position: "relative",
    75      top: 1,
    76    },
    77    endTimeShiftControls: {
    78      padding: theme.spacing(1),
    79      display: "flex",
    80      alignItems: "center",
    81      justifyContent: "center",
    82      borderBottomColor: theme.palette.divider,
    83      borderBottomWidth: 1,
    84      borderBottomStyle: "solid",
    85    },
    86    leftShiftButtons: {
    87      display: "flex",
    88      alignItems: "center",
    89      marginRight: theme.spacing(2),
    90    },
    91    rightShiftButtons: {
    92      display: "flex",
    93      alignItems: "center",
    94      marginLeft: theme.spacing(2),
    95    },
    96    controlsContainer: {
    97      display: "flex",
    98      justifyContent: "flex-end",
    99    },
   100    controlSelectorBox: {
   101      display: "flex",
   102      minWidth: 490,
   103      padding: theme.spacing(2),
   104    },
   105    controlEndTimeSelector: {
   106      width: "50%",
   107    },
   108    controlDurationSelector: {
   109      width: "50%",
   110    },
   111    radioButtonRoot: {
   112      paddingTop: theme.spacing(0.5),
   113      paddingBottom: theme.spacing(0.5),
   114      paddingLeft: theme.spacing(1),
   115      paddingRight: theme.spacing(1),
   116    },
   117    formControlLabel: {
   118      fontSize: 14,
   119    },
   120    buttonLabel: {
   121      textTransform: "none",
   122      fontSize: 12,
   123    },
   124    formControlRoot: {
   125      width: "100%",
   126      margin: 0,
   127    },
   128    formLabel: {
   129      fontSize: 14,
   130      fontWeight: 500,
   131      marginBottom: theme.spacing(1),
   132    },
   133    customInputField: {
   134      marginTop: theme.spacing(1),
   135    },
   136    filterButton: {
   137      marginLeft: theme.spacing(1),
   138    },
   139    queueFilters: {
   140      padding: theme.spacing(2),
   141      maxHeight: 400,
   142    },
   143    checkbox: {
   144      padding: 6,
   145    },
   146  }));
   147  
   148  // minute, hour, day in seconds
   149  const minute = 60;
   150  const hour = 60 * minute;
   151  const day = 24 * hour;
   152  
   153  function getInitialState(endTimeSec: number, durationSec: number): State {
   154    let endTimeOption: EndTimeOption = "real_time";
   155    let customEndTime = "";
   156    let durationOption: DurationOption = "1h";
   157    let customDuration = "";
   158  
   159    const now = currentUnixtime();
   160    // Account for 1s difference, may just happen to elapse 1s
   161    // between the parent component's render and this component's render.
   162    if (now <= endTimeSec && endTimeSec <= now + 1) {
   163      endTimeOption = "real_time";
   164    } else {
   165      endTimeOption = "custom";
   166      customEndTime = new Date(endTimeSec * 1000).toISOString();
   167    }
   168  
   169    switch (durationSec) {
   170      case 1 * hour:
   171        durationOption = "1h";
   172        break;
   173      case 6 * hour:
   174        durationOption = "6h";
   175        break;
   176      case 1 * day:
   177        durationOption = "1d";
   178        break;
   179      case 8 * day:
   180        durationOption = "8d";
   181        break;
   182      case 30 * day:
   183        durationOption = "30d";
   184        break;
   185      default:
   186        durationOption = "custom";
   187        customDuration = durationSec + "s";
   188    }
   189  
   190    return {
   191      endTimeOption,
   192      customEndTime,
   193      customEndTimeError: "",
   194      durationOption,
   195      customDuration,
   196      customDurationError: "",
   197    };
   198  }
   199  
   200  function MetricsFetchControls(props: Props) {
   201    const classes = useStyles();
   202  
   203    const [state, setState] = React.useState<State>(
   204      getInitialState(props.endTimeSec, props.durationSec)
   205    );
   206    const [timePopoverAnchorElem, setTimePopoverAnchorElem] =
   207      React.useState<HTMLButtonElement | null>(null);
   208  
   209    const [queuePopoverAnchorElem, setQueuePopoverAnchorElem] =
   210      React.useState<HTMLButtonElement | null>(null);
   211  
   212    const handleEndTimeOptionChange = (
   213      event: React.ChangeEvent<HTMLInputElement>
   214    ) => {
   215      const selectedOpt = (event.target as HTMLInputElement)
   216        .value as EndTimeOption;
   217      setState((prevState) => ({
   218        ...prevState,
   219        endTimeOption: selectedOpt,
   220        customEndTime: "",
   221        customEndTimeError: "",
   222      }));
   223      switch (selectedOpt) {
   224        case "real_time":
   225          props.onEndTimeChange(currentUnixtime(), /*isEndTimeFixed=*/ false);
   226          break;
   227        case "freeze_at_now":
   228          props.onEndTimeChange(currentUnixtime(), /*isEndTimeFixed=*/ true);
   229          break;
   230        case "custom":
   231        // No-op
   232      }
   233    };
   234  
   235    const handleDurationOptionChange = (
   236      event: React.ChangeEvent<HTMLInputElement>
   237    ) => {
   238      const selectedOpt = (event.target as HTMLInputElement)
   239        .value as DurationOption;
   240      setState((prevState) => ({
   241        ...prevState,
   242        durationOption: selectedOpt,
   243        customDuration: "",
   244        customDurationError: "",
   245      }));
   246      const isEndTimeFixed = state.endTimeOption !== "real_time";
   247      switch (selectedOpt) {
   248        case "1h":
   249          props.onDurationChange(1 * hour, isEndTimeFixed);
   250          break;
   251        case "6h":
   252          props.onDurationChange(6 * hour, isEndTimeFixed);
   253          break;
   254        case "1d":
   255          props.onDurationChange(1 * day, isEndTimeFixed);
   256          break;
   257        case "8d":
   258          props.onDurationChange(8 * day, isEndTimeFixed);
   259          break;
   260        case "30d":
   261          props.onDurationChange(30 * day, isEndTimeFixed);
   262          break;
   263        case "custom":
   264        // No-op
   265      }
   266    };
   267  
   268    const handleCustomDurationChange = (
   269      event: React.ChangeEvent<HTMLInputElement>
   270    ) => {
   271      event.persist(); // https://reactjs.org/docs/legacy-event-pooling.html
   272      setState((prevState) => ({
   273        ...prevState,
   274        customDuration: event.target.value,
   275      }));
   276    };
   277  
   278    const handleCustomEndTimeChange = (
   279      event: React.ChangeEvent<HTMLInputElement>
   280    ) => {
   281      event.persist(); // https://reactjs.org/docs/legacy-event-pooling.html
   282      setState((prevState) => ({
   283        ...prevState,
   284        customEndTime: event.target.value,
   285      }));
   286    };
   287  
   288    const handleCustomDurationKeyDown = (
   289      event: React.KeyboardEvent<HTMLInputElement>
   290    ) => {
   291      if (event.key === "Enter") {
   292        try {
   293          const d = parseDuration(state.customDuration);
   294          setState((prevState) => ({
   295            ...prevState,
   296            durationOption: "custom",
   297            customDurationError: "",
   298          }));
   299          props.onDurationChange(d, state.endTimeOption !== "real_time");
   300        } catch (error) {
   301          setState((prevState) => ({
   302            ...prevState,
   303            customDurationError: "Duration invalid",
   304          }));
   305        }
   306      }
   307    };
   308  
   309    const handleCustomEndTimeKeyDown = (
   310      event: React.KeyboardEvent<HTMLInputElement>
   311    ) => {
   312      if (event.key === "Enter") {
   313        const timeUsecOrNaN = Date.parse(state.customEndTime);
   314        if (isNaN(timeUsecOrNaN)) {
   315          setState((prevState) => ({
   316            ...prevState,
   317            customEndTimeError: "End time invalid",
   318          }));
   319          return;
   320        }
   321        setState((prevState) => ({
   322          ...prevState,
   323          endTimeOption: "custom",
   324          customEndTimeError: "",
   325        }));
   326        props.onEndTimeChange(
   327          Math.floor(timeUsecOrNaN / 1000),
   328          /* isEndTimeFixed= */ true
   329        );
   330      }
   331    };
   332  
   333    const handleOpenTimePopover = (
   334      event: React.MouseEvent<HTMLButtonElement>
   335    ) => {
   336      setTimePopoverAnchorElem(event.currentTarget);
   337    };
   338  
   339    const handleCloseTimePopover = () => {
   340      setTimePopoverAnchorElem(null);
   341    };
   342  
   343    const handleOpenQueuePopover = (
   344      event: React.MouseEvent<HTMLButtonElement>
   345    ) => {
   346      setQueuePopoverAnchorElem(event.currentTarget);
   347    };
   348  
   349    const handleCloseQueuePopover = () => {
   350      setQueuePopoverAnchorElem(null);
   351    };
   352  
   353    const isTimePopoverOpen = Boolean(timePopoverAnchorElem);
   354    const isQueuePopoverOpen = Boolean(queuePopoverAnchorElem);
   355  
   356    React.useEffect(() => {
   357      if (state.endTimeOption === "real_time") {
   358        const id = setInterval(() => {
   359          props.onEndTimeChange(currentUnixtime(), /*isEndTimeFixed=*/ false);
   360        }, props.pollInterval * 1000);
   361        return () => clearInterval(id);
   362      }
   363    });
   364  
   365    const shiftBy = (deltaSec: number) => {
   366      return () => {
   367        const now = currentUnixtime();
   368        const endTime = props.endTimeSec + deltaSec;
   369        if (now <= endTime) {
   370          setState((prevState) => ({
   371            ...prevState,
   372            customEndTime: "",
   373            endTimeOption: "real_time",
   374          }));
   375          props.onEndTimeChange(now, /*isEndTimeFixed=*/ false);
   376          return;
   377        }
   378        setState((prevState) => ({
   379          ...prevState,
   380          endTimeOption: "custom",
   381          customEndTime: new Date(endTime * 1000).toISOString(),
   382        }));
   383        props.onEndTimeChange(endTime, /*isEndTimeFixed=*/ true);
   384      };
   385    };
   386  
   387    return (
   388      <div className={classes.root}>
   389        <Typography
   390          variant="caption"
   391          color="textPrimary"
   392          className={classes.endTimeCaption}
   393        >
   394          {formatTime(props.endTimeSec)}
   395        </Typography>
   396        <div>
   397          <Button
   398            aria-describedby={isTimePopoverOpen ? "time-popover" : undefined}
   399            variant="outlined"
   400            color="primary"
   401            onClick={handleOpenTimePopover}
   402            size="small"
   403            classes={{
   404              label: classes.buttonLabel,
   405            }}
   406          >
   407            {state.endTimeOption === "real_time" ? "Realtime" : "Historical"}:{" "}
   408            {state.durationOption === "custom"
   409              ? state.customDuration
   410              : state.durationOption}
   411          </Button>
   412          <Popover
   413            id={isTimePopoverOpen ? "time-popover" : undefined}
   414            open={isTimePopoverOpen}
   415            anchorEl={timePopoverAnchorElem}
   416            onClose={handleCloseTimePopover}
   417            anchorOrigin={{
   418              vertical: "bottom",
   419              horizontal: "center",
   420            }}
   421            transformOrigin={{
   422              vertical: "top",
   423              horizontal: "center",
   424            }}
   425          >
   426            <div className={classes.endTimeShiftControls}>
   427              <div className={classes.leftShiftButtons}>
   428                <ShiftButton
   429                  direction="left"
   430                  text="2h"
   431                  onClick={shiftBy(-2 * hour)}
   432                  dense={true}
   433                />
   434                <ShiftButton
   435                  direction="left"
   436                  text="1h"
   437                  onClick={shiftBy(-1 * hour)}
   438                  dense={true}
   439                />
   440                <ShiftButton
   441                  direction="left"
   442                  text="30m"
   443                  onClick={shiftBy(-30 * minute)}
   444                  dense={true}
   445                />
   446                <ShiftButton
   447                  direction="left"
   448                  text="15m"
   449                  onClick={shiftBy(-15 * minute)}
   450                  dense={true}
   451                />
   452                <ShiftButton
   453                  direction="left"
   454                  text="5m"
   455                  onClick={shiftBy(-5 * minute)}
   456                  dense={true}
   457                />
   458              </div>
   459              <div className={classes.rightShiftButtons}>
   460                <ShiftButton
   461                  direction="right"
   462                  text="5m"
   463                  onClick={shiftBy(5 * minute)}
   464                  dense={true}
   465                />
   466                <ShiftButton
   467                  direction="right"
   468                  text="15m"
   469                  onClick={shiftBy(15 * minute)}
   470                  dense={true}
   471                />
   472                <ShiftButton
   473                  direction="right"
   474                  text="30m"
   475                  onClick={shiftBy(30 * minute)}
   476                  dense={true}
   477                />
   478                <ShiftButton
   479                  direction="right"
   480                  text="1h"
   481                  onClick={shiftBy(1 * hour)}
   482                  dense={true}
   483                />
   484                <ShiftButton
   485                  direction="right"
   486                  text="2h"
   487                  onClick={shiftBy(2 * hour)}
   488                  dense={true}
   489                />
   490              </div>
   491            </div>
   492            <div className={classes.controlSelectorBox}>
   493              <div className={classes.controlEndTimeSelector}>
   494                <FormControl
   495                  component="fieldset"
   496                  margin="dense"
   497                  classes={{ root: classes.formControlRoot }}
   498                >
   499                  <FormLabel className={classes.formLabel} component="legend">
   500                    End Time
   501                  </FormLabel>
   502                  <RadioGroup
   503                    aria-label="end_time"
   504                    name="end_time"
   505                    value={state.endTimeOption}
   506                    onChange={handleEndTimeOptionChange}
   507                  >
   508                    <RadioInput value="real_time" label="Real Time" />
   509                    <RadioInput value="freeze_at_now" label="Freeze at now" />
   510                    <RadioInput value="custom" label="Custom End Time" />
   511                  </RadioGroup>
   512                  <div className={classes.customInputField}>
   513                    <TextField
   514                      id="custom-endtime"
   515                      label="yyyy-mm-dd hh:mm:ssz"
   516                      variant="outlined"
   517                      size="small"
   518                      onChange={handleCustomEndTimeChange}
   519                      value={state.customEndTime}
   520                      onKeyDown={handleCustomEndTimeKeyDown}
   521                      error={state.customEndTimeError !== ""}
   522                      helperText={state.customEndTimeError}
   523                    />
   524                  </div>
   525                </FormControl>
   526              </div>
   527              <div className={classes.controlDurationSelector}>
   528                <FormControl
   529                  component="fieldset"
   530                  margin="dense"
   531                  classes={{ root: classes.formControlRoot }}
   532                >
   533                  <FormLabel className={classes.formLabel} component="legend">
   534                    Duration
   535                  </FormLabel>
   536                  <RadioGroup
   537                    aria-label="duration"
   538                    name="duration"
   539                    value={state.durationOption}
   540                    onChange={handleDurationOptionChange}
   541                  >
   542                    <RadioInput value="1h" label="1h" />
   543                    <RadioInput value="6h" label="6h" />
   544                    <RadioInput value="1d" label="1 day" />
   545                    <RadioInput value="8d" label="8 days" />
   546                    <RadioInput value="30d" label="30 days" />
   547                    <RadioInput value="custom" label="Custom Duration" />
   548                  </RadioGroup>
   549                  <div className={classes.customInputField}>
   550                    <TextField
   551                      id="custom-duration"
   552                      label="duration"
   553                      variant="outlined"
   554                      size="small"
   555                      onChange={handleCustomDurationChange}
   556                      value={state.customDuration}
   557                      onKeyDown={handleCustomDurationKeyDown}
   558                      error={state.customDurationError !== ""}
   559                      helperText={state.customDurationError}
   560                    />
   561                  </div>
   562                </FormControl>
   563              </div>
   564            </div>
   565          </Popover>
   566        </div>
   567        <div className={classes.shiftButtons}>
   568          <ButtonGroup
   569            classes={{ root: classes.buttonGroupRoot }}
   570            size="small"
   571            color="primary"
   572            aria-label="shift buttons"
   573          >
   574            <ShiftButton
   575              direction="left"
   576              text={
   577                state.durationOption === "custom" ? "1h" : state.durationOption
   578              }
   579              color="primary"
   580              onClick={
   581                state.durationOption === "custom"
   582                  ? shiftBy(-1 * hour)
   583                  : shiftBy(-props.durationSec)
   584              }
   585            />
   586            <ShiftButton
   587              direction="right"
   588              text={
   589                state.durationOption === "custom" ? "1h" : state.durationOption
   590              }
   591              color="primary"
   592              onClick={
   593                state.durationOption === "custom"
   594                  ? shiftBy(1 * hour)
   595                  : shiftBy(props.durationSec)
   596              }
   597            />
   598          </ButtonGroup>
   599        </div>
   600        <div className={classes.filterButton}>
   601          <IconButton
   602            aria-label="filter"
   603            size="small"
   604            onClick={handleOpenQueuePopover}
   605          >
   606            <FilterListIcon />
   607          </IconButton>
   608          <Popover
   609            id={isQueuePopoverOpen ? "queue-popover" : undefined}
   610            open={isQueuePopoverOpen}
   611            anchorEl={queuePopoverAnchorElem}
   612            onClose={handleCloseQueuePopover}
   613            anchorOrigin={{
   614              vertical: "bottom",
   615              horizontal: "center",
   616            }}
   617            transformOrigin={{
   618              vertical: "top",
   619              horizontal: "center",
   620            }}
   621          >
   622            <FormControl className={classes.queueFilters}>
   623              <FormLabel className={classes.formLabel} component="legend">
   624                Queues
   625              </FormLabel>
   626              <FormGroup>
   627                {props.queues.map((qname) => (
   628                  <FormControlLabel
   629                    key={qname}
   630                    control={
   631                      <Checkbox
   632                        size="small"
   633                        checked={props.selectedQueues.includes(qname)}
   634                        onChange={() => {
   635                          if (props.selectedQueues.includes(qname)) {
   636                            props.removeQueue(qname);
   637                          } else {
   638                            props.addQueue(qname);
   639                          }
   640                        }}
   641                        name={qname}
   642                        className={classes.checkbox}
   643                      />
   644                    }
   645                    label={qname}
   646                    classes={{ label: classes.formControlLabel }}
   647                  />
   648                ))}
   649              </FormGroup>
   650            </FormControl>
   651          </Popover>
   652        </div>
   653      </div>
   654    );
   655  }
   656  
   657  /****************** Helper functions/components *******************/
   658  
   659  function formatTime(unixtime: number): string {
   660    const tz = new Date(unixtime * 1000)
   661      .toLocaleTimeString("en-us", { timeZoneName: "short" })
   662      .split(" ")[2];
   663    return dayjs.unix(unixtime).format("ddd, DD MMM YYYY HH:mm:ss ") + tz;
   664  }
   665  
   666  interface RadioInputProps {
   667    value: string;
   668    label: string;
   669  }
   670  
   671  function RadioInput(props: RadioInputProps) {
   672    const classes = useStyles();
   673    return (
   674      <FormControlLabel
   675        classes={{ label: classes.formControlLabel }}
   676        value={props.value}
   677        control={
   678          <Radio size="small" classes={{ root: classes.radioButtonRoot }} />
   679        }
   680        label={props.label}
   681      />
   682    );
   683  }
   684  
   685  interface ShiftButtonProps extends ButtonProps {
   686    text: string;
   687    onClick: () => void;
   688    direction: "left" | "right";
   689    dense?: boolean;
   690  }
   691  
   692  const useShiftButtonStyles = makeStyles((theme: Theme) => ({
   693    root: {
   694      minWidth: 40,
   695      fontWeight: (props: ShiftButtonProps) => (props.dense ? 400 : 500),
   696    },
   697    label: { fontSize: 12, textTransform: "none" },
   698    iconRoot: {
   699      marginRight: (props: ShiftButtonProps) =>
   700        props.direction === "left" ? (props.dense ? -8 : -4) : 0,
   701      marginLeft: (props: ShiftButtonProps) =>
   702        props.direction === "right" ? (props.dense ? -8 : -4) : 0,
   703      color: (props: ShiftButtonProps) =>
   704        props.color
   705          ? props.color
   706          : theme.palette.grey[isDarkTheme(theme) ? 200 : 700],
   707    },
   708  }));
   709  
   710  function ShiftButton(props: ShiftButtonProps) {
   711    const classes = useShiftButtonStyles(props);
   712    return (
   713      <Button
   714        {...props}
   715        classes={{
   716          root: classes.root,
   717          label: classes.label,
   718        }}
   719        size="small"
   720      >
   721        {props.direction === "left" && (
   722          <ArrowLeftIcon classes={{ root: classes.iconRoot }} />
   723        )}
   724        {props.text}
   725        {props.direction === "right" && (
   726          <ArrowRightIcon classes={{ root: classes.iconRoot }} />
   727        )}
   728      </Button>
   729    );
   730  }
   731  
   732  ShiftButton.defaultProps = {
   733    dense: false,
   734  };
   735  
   736  export default connect(mapStateToProps)(MetricsFetchControls);