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);