github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/web/src/OverviewActionBar.tsx (about) 1 import { debounce, InputAdornment, InputProps } from "@material-ui/core" 2 import Menu from "@material-ui/core/Menu" 3 import MenuItem from "@material-ui/core/MenuItem" 4 import { PopoverOrigin } from "@material-ui/core/Popover" 5 import { makeStyles } from "@material-ui/core/styles" 6 import ExpandMoreIcon from "@material-ui/icons/ExpandMore" 7 import { History } from "history" 8 import React, { ChangeEvent, useEffect, useState } from "react" 9 import { useHistory, useLocation } from "react-router" 10 import styled from "styled-components" 11 import { Alert } from "./alerts" 12 import { AnalyticsAction, incr } from "./analytics" 13 import { ApiButton, ButtonSet } from "./ApiButton" 14 import { ReactComponent as AlertSvg } from "./assets/svg/alert.svg" 15 import { ReactComponent as CheckmarkSvg } from "./assets/svg/checkmark.svg" 16 import { ReactComponent as CloseSvg } from "./assets/svg/close.svg" 17 import { ReactComponent as CopySvg } from "./assets/svg/copy.svg" 18 import { ReactComponent as FilterSvg } from "./assets/svg/filter.svg" 19 import { ReactComponent as LinkSvg } from "./assets/svg/link.svg" 20 import { 21 InstrumentedButton, 22 InstrumentedTextField, 23 } from "./instrumentedComponents" 24 import { displayURL, resolveURL } from "./links" 25 import LogActions from "./LogActions" 26 import { 27 EMPTY_TERM, 28 FilterLevel, 29 FilterSet, 30 FilterSource, 31 FilterTerm, 32 isErrorTerm, 33 TermState, 34 } from "./logfilters" 35 import { useLogStore } from "./LogStore" 36 import OverviewActionBarKeyboardShortcuts from "./OverviewActionBarKeyboardShortcuts" 37 import { OverviewButtonMixin } from "./OverviewButton" 38 import { usePathBuilder } from "./PathBuilder" 39 import { useResourceNav } from "./ResourceNav" 40 import { resourceIsDisabled } from "./ResourceStatus" 41 import { useSidebarContext } from "./SidebarContext" 42 import SrOnly from "./SrOnly" 43 import { 44 AnimDuration, 45 Color, 46 Font, 47 FontSize, 48 mixinResetButtonStyle, 49 SizeUnit, 50 } from "./style-helpers" 51 import { TiltInfoTooltip } from "./Tooltip" 52 import { ResourceName, UIButton, UIResource } from "./types" 53 54 type OverviewActionBarProps = { 55 // The current resource. May be null if there is no resource. 56 resource?: UIResource 57 58 // All the alerts for the current resource. 59 alerts?: Alert[] 60 61 // The current log filter. 62 filterSet: FilterSet 63 64 // buttons for this resource 65 buttons?: ButtonSet 66 } 67 68 type FilterSourceMenuProps = { 69 id: string 70 open: boolean 71 anchorEl: Element | null 72 onClose: () => void 73 74 // The level button that this menu belongs to. 75 level: FilterLevel 76 77 // The current filter set. 78 filterSet: FilterSet 79 80 // The alerts for the current resource. 81 alerts?: Alert[] 82 } 83 84 let useMenuStyles = makeStyles((theme: any) => ({ 85 root: { 86 fontFamily: Font.sansSerif, 87 fontSize: FontSize.smallest, 88 }, 89 })) 90 91 // Menu to filter logs by source (e.g., build-only, runtime-only). 92 function FilterSourceMenu(props: FilterSourceMenuProps) { 93 let { id, anchorEl, level, open, onClose } = props 94 let alerts = props.alerts || [] 95 96 let classes = useMenuStyles() 97 let history = useHistory() 98 let l = useLocation() 99 let onClick = (e: any) => { 100 let source = e.currentTarget.getAttribute("data-filter") 101 const search = createLogSearch(l.search, { source, level }) 102 history.push({ 103 pathname: l.pathname, 104 search: search.toString(), 105 }) 106 onClose() 107 } 108 109 let anchorOrigin: PopoverOrigin = { 110 vertical: "bottom", 111 horizontal: "right", 112 } 113 let transformOrigin: PopoverOrigin = { 114 vertical: "top", 115 horizontal: "right", 116 } 117 118 let allCount: null | number = null 119 let buildCount: null | number = null 120 let runtimeCount: null | number = null 121 if (level != FilterLevel.all) { 122 allCount = alerts.reduce( 123 (acc, alert) => (alert.level == level ? acc + 1 : acc), 124 0 125 ) 126 buildCount = alerts.reduce( 127 (acc, alert) => 128 alert.level == level && alert.source == FilterSource.build 129 ? acc + 1 130 : acc, 131 0 132 ) 133 runtimeCount = alerts.reduce( 134 (acc, alert) => 135 alert.level == level && alert.source == FilterSource.runtime 136 ? acc + 1 137 : acc, 138 0 139 ) 140 } 141 return ( 142 <Menu 143 id={id} 144 anchorEl={anchorEl} 145 open={open} 146 onClose={onClose} 147 disableScrollLock={true} 148 keepMounted={true} 149 anchorOrigin={anchorOrigin} 150 transformOrigin={transformOrigin} 151 getContentAnchorEl={null} 152 > 153 <MenuItem 154 data-filter={FilterSource.all} 155 classes={classes} 156 onClick={onClick} 157 > 158 All Sources{allCount === null ? "" : ` (${allCount})`} 159 </MenuItem> 160 <MenuItem 161 data-filter={FilterSource.build} 162 classes={classes} 163 onClick={onClick} 164 > 165 Build Only{buildCount === null ? "" : ` (${buildCount})`} 166 </MenuItem> 167 <MenuItem 168 data-filter={FilterSource.runtime} 169 classes={classes} 170 onClick={onClick} 171 > 172 Runtime Only{runtimeCount === null ? "" : ` (${runtimeCount})`} 173 </MenuItem> 174 </Menu> 175 ) 176 } 177 178 const CustomActionButton = styled(ApiButton)` 179 button { 180 ${OverviewButtonMixin}; 181 } 182 183 & + & { 184 margin-left: ${SizeUnit(0.25)}; 185 } 186 ` 187 188 const DisableButton = styled(ApiButton)` 189 margin-right: ${SizeUnit(0.5)}; 190 191 button { 192 ${OverviewButtonMixin}; 193 background-color: ${Color.gray20}; 194 195 &:hover { 196 background-color: ${Color.gray20}; 197 } 198 } 199 200 button:first-child { 201 width: 100%; 202 } 203 204 // hardcode a width to workaround this bug: 205 // https://app.shortcut.com/windmill/story/12912/uibuttons-created-by-togglebuttons-have-different-sizes-when-toggled 206 width: ${SizeUnit(4.4)}; 207 ` 208 209 const ButtonRoot = styled(InstrumentedButton)` 210 ${OverviewButtonMixin} 211 ` 212 213 const WidgetRoot = styled.div` 214 display: flex; 215 ${ButtonRoot} + ${ButtonRoot} { 216 margin-left: ${SizeUnit(0.125)}; 217 } 218 ` 219 220 let ButtonPill = styled.div` 221 display: flex; 222 margin-right: ${SizeUnit(0.5)}; 223 224 &.isCentered { 225 margin-left: auto; 226 } 227 ` 228 229 export let ButtonLeftPill = styled(ButtonRoot)` 230 border-radius: 4px 0 0 4px; 231 border-right: 0; 232 233 &:hover + button { 234 border-left-color: ${Color.blue}; 235 } 236 ` 237 export let ButtonRightPill = styled(ButtonRoot)` 238 border-radius: 0 4px 4px 0; 239 ` 240 241 const FilterTermTextField = styled(InstrumentedTextField)` 242 & .MuiOutlinedInput-root { 243 background-color: ${Color.gray20}; 244 position: relative; 245 width: ${SizeUnit(9)}; 246 247 & fieldset { 248 border: 1px solid ${Color.gray40}; 249 border-radius: ${SizeUnit(0.125)}; 250 transition: border-color ${AnimDuration.default} ease; 251 } 252 &:hover:not(.Mui-focused, .Mui-error) fieldset { 253 border: 1px solid ${Color.blue}; 254 } 255 &.Mui-focused fieldset { 256 border: 1px solid ${Color.grayLightest}; 257 } 258 &.Mui-error fieldset { 259 border: 1px solid ${Color.red}; 260 } 261 & .MuiOutlinedInput-input { 262 padding: ${SizeUnit(0.2)}; 263 } 264 } 265 266 & .MuiInputBase-input { 267 color: ${Color.gray70}; 268 font-family: ${Font.monospace}; 269 font-size: ${FontSize.small}; 270 } 271 ` 272 273 const FieldErrorTooltip = styled.span` 274 align-items: center; 275 background-color: ${Color.gray20}; 276 box-sizing: border-box; 277 color: ${Color.red}; 278 display: flex; 279 font-family: ${Font.monospace}; 280 font-size: ${FontSize.smallest}; 281 left: 0; 282 line-height: 1.4; 283 margin: ${SizeUnit(0.25)} 0 0 0; 284 padding: ${SizeUnit(0.25)}; 285 position: absolute; 286 right: 0; 287 top: 100%; 288 z-index: 1; 289 290 ::before { 291 border-bottom: 8px solid ${Color.gray20}; 292 border-left: 8px solid transparent; 293 border-right: 8px solid transparent; 294 content: ""; 295 height: 0; 296 left: 20px; 297 position: absolute; 298 top: -8px; 299 width: 0; 300 } 301 ` 302 303 const AlertIcon = styled(AlertSvg)` 304 padding-right: ${SizeUnit(0.25)}; 305 ` 306 307 const ClearFilterTermTextButton = styled(InstrumentedButton)` 308 ${mixinResetButtonStyle} 309 align-items: center; 310 display: flex; 311 ` 312 313 type FilterRadioButtonProps = { 314 // The level that this button toggles. 315 level: FilterLevel 316 317 // The current filter set. 318 filterSet: FilterSet 319 320 // All the alerts for the current resource. 321 alerts?: Alert[] 322 323 className?: string 324 } 325 326 export function createLogSearch( 327 currentSearch: string, 328 { 329 level, 330 source, 331 term, 332 }: { level?: FilterLevel; source?: FilterSource; term?: string } 333 ) { 334 // Start with the existing search params 335 const newSearch = new URLSearchParams(currentSearch) 336 337 if (level !== undefined) { 338 if (level) { 339 newSearch.set("level", level) 340 } else { 341 newSearch.delete("level") 342 } 343 } 344 345 if (source !== undefined) { 346 if (source) { 347 newSearch.set("source", source) 348 } else { 349 newSearch.delete("source") 350 } 351 } 352 353 if (term !== undefined) { 354 if (term) { 355 newSearch.set("term", term) 356 } else { 357 newSearch.delete("term") 358 } 359 } 360 361 return newSearch 362 } 363 364 export function FilterRadioButton(props: FilterRadioButtonProps) { 365 let { level, filterSet } = props 366 let alerts = props.alerts || [] 367 let leftText = "All Levels" 368 let count = alerts.reduce( 369 (acc, alert) => (alert.level == level ? acc + 1 : acc), 370 0 371 ) 372 if (level === FilterLevel.warn) { 373 leftText = `Warnings (${count})` 374 } else if (level === FilterLevel.error) { 375 leftText = `Errors (${count})` 376 } 377 378 let isEnabled = level === props.filterSet.level 379 let rightText = ( 380 <ExpandMoreIcon 381 style={{ width: "16px", height: "16px" }} 382 key="right-text" 383 role="presentation" 384 /> 385 ) 386 let rightStyle = { paddingLeft: "4px", paddingRight: "4px" } as any 387 if (isEnabled) { 388 if (filterSet.source == FilterSource.build) { 389 rightText = <span key="right-text">Build</span> 390 rightStyle = null 391 } else if (filterSet.source == FilterSource.runtime) { 392 rightText = <span key="right-text">Runtime</span> 393 rightStyle = null 394 } 395 } 396 397 // isRadio indicates that clicking the button again won't turn it off, 398 // behaving like a radio button. 399 let leftClassName = "isRadio" 400 let rightClassName = "" 401 if (isEnabled) { 402 leftClassName += " isEnabled" 403 rightClassName += " isEnabled" 404 } 405 406 let history = useHistory() 407 let l = useLocation() 408 let onClick = () => { 409 const search = createLogSearch(l.search, { 410 level, 411 source: FilterSource.all, 412 }) 413 history.push({ 414 pathname: l.pathname, 415 search: search.toString(), 416 }) 417 } 418 419 let [sourceMenuAnchor, setSourceMenuAnchor] = useState(null) 420 let onMenuOpen = (e: any) => { 421 setSourceMenuAnchor(e.currentTarget) 422 } 423 let sourceMenuOpen = !!sourceMenuAnchor 424 425 return ( 426 <ButtonPill className={props.className}> 427 <ButtonLeftPill 428 className={leftClassName} 429 onClick={onClick} 430 analyticsName="ui.web.filterLevel" 431 analyticsTags={{ level: level, source: props.filterSet.source }} 432 > 433 {leftText} 434 </ButtonLeftPill> 435 <ButtonRightPill 436 style={rightStyle} 437 className={rightClassName} 438 onClick={onMenuOpen} 439 analyticsName="ui.web.filterSourceMenu" 440 aria-label={`Select ${level} log sources`} 441 > 442 {rightText} 443 </ButtonRightPill> 444 <FilterSourceMenu 445 id={`filterSource-${level}`} 446 open={sourceMenuOpen} 447 anchorEl={sourceMenuAnchor} 448 filterSet={filterSet} 449 level={level} 450 alerts={alerts} 451 onClose={() => setSourceMenuAnchor(null)} 452 /> 453 </ButtonPill> 454 ) 455 } 456 457 export const FILTER_INPUT_DEBOUNCE = 500 // in ms 458 export const FILTER_FIELD_ID = "FilterTermTextInput" 459 export const FILTER_FIELD_TOOLTIP_ID = "FilterTermInfoTooltip" 460 461 function FilterTermFieldError({ error }: { error: string }) { 462 return ( 463 <FieldErrorTooltip> 464 <AlertIcon width="20" height="20" role="presentation" /> 465 {error} 466 </FieldErrorTooltip> 467 ) 468 } 469 470 const filterTermTooltipContent = ( 471 <> 472 RegExp should be wrapped in forward slashes, is case-insensitive, and is{" "} 473 <a 474 href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions" 475 target="_blank" 476 > 477 parsed in JavaScript 478 </a> 479 . 480 </> 481 ) 482 483 const debounceFilterLogs = debounce((history: History, search: string) => { 484 // Navigate to filtered logs with search query 485 history.push({ search }) 486 }, FILTER_INPUT_DEBOUNCE) 487 488 export function FilterTermField({ termFromUrl }: { termFromUrl: FilterTerm }) { 489 const { input: initialTerm, state } = termFromUrl 490 const location = useLocation() 491 const history = useHistory() 492 493 const [filterTerm, setFilterTerm] = useState(initialTerm ?? EMPTY_TERM) 494 495 // If the location changes, reset the value of the input field based on url 496 useEffect(() => { 497 setFilterTerm(initialTerm) 498 }, [location.pathname]) 499 500 /** 501 * Note about term updates: 502 * Debouncing allows us to wait to execute log filtration until a set 503 * amount of time has passed without the filter term changing. To implement 504 * debouncing, it's necessary to separate the term field's value from the url 505 * search params, otherwise the field that a user types in doesn't update. 506 * The term field updates without any debouncing, while the url search params 507 * (which actually triggers log filtering) updates with the debounce delay. 508 */ 509 const setTerm = (term: string, withDebounceDelay = true) => { 510 setFilterTerm(term) 511 512 const search = createLogSearch(location.search, { term }) 513 514 if (withDebounceDelay) { 515 debounceFilterLogs(history, search.toString()) 516 } else { 517 history.push({ search: search.toString() }) 518 } 519 } 520 521 const onChange = ( 522 event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement> 523 ) => { 524 const term = event.target.value ?? EMPTY_TERM 525 setTerm(term) 526 } 527 528 const inputProps: InputProps = { 529 startAdornment: ( 530 <InputAdornment position="start" disablePointerEvents={true}> 531 <FilterSvg fill={Color.gray20} role="presentation" /> 532 </InputAdornment> 533 ), 534 } 535 536 // If there's a search term, add a button to clear that term 537 if (filterTerm) { 538 const endAdornment = ( 539 <InputAdornment position="end"> 540 <ClearFilterTermTextButton 541 analyticsName="ui.web.clearFilterTerm" 542 onClick={() => setTerm(EMPTY_TERM, false)} 543 > 544 <SrOnly>Clear filter term</SrOnly> 545 <CloseSvg fill={Color.grayLightest} role="presentation" /> 546 </ClearFilterTermTextButton> 547 </InputAdornment> 548 ) 549 550 inputProps.endAdornment = endAdornment 551 } 552 553 return ( 554 <> 555 <FilterTermTextField 556 aria-describedby={FILTER_FIELD_TOOLTIP_ID} 557 error={state === TermState.Error} 558 id={FILTER_FIELD_ID} 559 helperText={ 560 isErrorTerm(termFromUrl) ? ( 561 <FilterTermFieldError error={termFromUrl.error} /> 562 ) : ( 563 "" 564 ) 565 } 566 InputProps={inputProps} 567 onChange={onChange} 568 placeholder="Filter by text or /regexp/" 569 value={filterTerm} 570 variant="outlined" 571 analyticsName="ui.web.filterTerm" 572 /> 573 <SrOnly component="label" htmlFor={FILTER_FIELD_ID}> 574 Filter resource logs by text or /regexp/ 575 </SrOnly> 576 <TiltInfoTooltip 577 id={FILTER_FIELD_TOOLTIP_ID} 578 dismissId="log-filter-term" 579 title={filterTermTooltipContent} 580 placement="right-end" 581 /> 582 </> 583 ) 584 } 585 586 type CopyButtonProps = { 587 podId: string 588 } 589 590 async function copyTextToClipboard(text: string, cb: () => void) { 591 await navigator.clipboard.writeText(text) 592 cb() 593 } 594 595 let TruncateText = styled.div` 596 overflow: hidden; 597 text-overflow: ellipsis; 598 white-space: nowrap; 599 max-width: 250px; 600 ` 601 602 export function CopyButton(props: CopyButtonProps) { 603 let [showCopySuccess, setShowCopySuccess] = useState(false) 604 605 let copyClick = () => { 606 copyTextToClipboard(props.podId, () => { 607 setShowCopySuccess(true) 608 609 setTimeout(() => { 610 setShowCopySuccess(false) 611 }, 5000) 612 }) 613 } 614 615 let icon = showCopySuccess ? ( 616 <CheckmarkSvg width="20" height="20" /> 617 ) : ( 618 <CopySvg width="20" height="20" /> 619 ) 620 621 return ( 622 <ButtonRoot onClick={copyClick} analyticsName="ui.web.actionBar.copyPodID"> 623 {icon} 624 <TruncateText style={{ marginLeft: "8px" }}> 625 {props.podId} Pod ID 626 </TruncateText> 627 </ButtonRoot> 628 ) 629 } 630 631 let ActionBarRoot = styled.div` 632 background-color: ${Color.gray10}; 633 ` 634 635 const actionBarRowMixin = ` 636 display: flex; 637 align-items: center; 638 justify-content: space-between; 639 border-bottom: 1px solid ${Color.gray40}; 640 padding: ${SizeUnit(0.25)} ${SizeUnit(0.5)}; 641 color: ${Color.gray70}; 642 ` 643 644 export let ResourceNameTitleRow = styled.div` 645 ${actionBarRowMixin} 646 ` 647 648 export let ActionBarTopRow = styled.div` 649 ${actionBarRowMixin} 650 ` 651 652 export let ActionBarBottomRow = styled.div` 653 display: flex; 654 flex-wrap: wrap; 655 align-items: center; 656 border-bottom: 1px solid ${Color.gray40}; 657 min-height: ${SizeUnit(1)}; 658 padding-left: ${SizeUnit(0.5)}; 659 padding-right: ${SizeUnit(0.5)}; 660 padding-top: ${SizeUnit(0.35)}; 661 padding-bottom: ${SizeUnit(0.35)}; 662 ` 663 664 let EndpointSet = styled.div` 665 display: flex; 666 align-items: center; 667 flex-wrap: wrap; 668 font-family: ${Font.monospace}; 669 font-size: ${FontSize.small}; 670 671 & + ${WidgetRoot} { 672 margin-left: ${SizeUnit(1 / 4)}; 673 } 674 ` 675 676 export let Endpoint = styled.a` 677 color: ${Color.gray70}; 678 transition: color ${AnimDuration.default} ease; 679 680 &:hover { 681 color: ${Color.blue}; 682 } 683 ` 684 685 let EndpointIcon = styled(LinkSvg)` 686 fill: ${Color.gray70}; 687 margin-right: ${SizeUnit(0.25)}; 688 ` 689 690 // TODO(nick): Put this in a global React Context object with 691 // other page-level stuffs 692 function openEndpointUrl(url: string) { 693 // We deliberately don't use rel=noopener. These are trusted tabs, and we want 694 // to have a persistent link to them (so that clicking on the same link opens 695 // the same tab). 696 window.open(resolveURL(url), url) 697 } 698 699 export function OverviewWidgets(props: { buttons?: UIButton[] }) { 700 if (!props.buttons?.length) { 701 return null 702 } 703 704 return ( 705 <WidgetRoot key="widgets"> 706 {props.buttons?.map((b) => ( 707 <CustomActionButton uiButton={b} key={b.metadata?.name} /> 708 ))} 709 </WidgetRoot> 710 ) 711 } 712 713 function DisableButtonSection(props: { button?: UIButton }) { 714 if (!props.button) { 715 return null 716 } 717 718 return <DisableButton uiButton={props.button} /> 719 } 720 721 export default function OverviewActionBar(props: OverviewActionBarProps) { 722 let { resource, filterSet, alerts, buttons } = props 723 const logStore = useLogStore() 724 const isSnapshot = usePathBuilder().isSnapshot() 725 const isDisabled = resourceIsDisabled(resource) 726 727 let endpoints = resource?.status?.endpointLinks || [] 728 let podId = resource?.status?.k8sResourceInfo?.podName || "" 729 const resourceName = resource 730 ? resource.metadata?.name || "" 731 : ResourceName.all 732 733 let endpointEls: JSX.Element[] = [] 734 if (endpoints.length && !isDisabled) { 735 endpoints.forEach((ep, i) => { 736 if (i !== 0) { 737 endpointEls.push(<span key={`spacer-${i}`}>, </span>) 738 } 739 let url = resolveURL(ep.url || "") 740 endpointEls.push( 741 <Endpoint 742 onClick={() => 743 void incr("ui.web.endpoint", { action: AnalyticsAction.Click }) 744 } 745 href={url} 746 // We use ep.url as the target, so that clicking the link re-uses the tab. 747 target={url} 748 key={url} 749 > 750 <TruncateText>{ep.name || displayURL(url)}</TruncateText> 751 </Endpoint> 752 ) 753 }) 754 } 755 756 let topRowEls = new Array<JSX.Element>() 757 if (endpointEls.length) { 758 topRowEls.push( 759 <EndpointSet key="endpointSet"> 760 <EndpointIcon /> 761 {endpointEls} 762 </EndpointSet> 763 ) 764 } 765 if (podId && !isDisabled) { 766 topRowEls.push(<CopyButton podId={podId} key="copyPodId" />) 767 } 768 769 const widgets = OverviewWidgets({ buttons: buttons?.default }) 770 if (widgets && !isDisabled) { 771 topRowEls.push(widgets) 772 } 773 774 const topRow = topRowEls.length ? ( 775 <ActionBarTopRow 776 aria-label={`${resourceName} links and custom buttons`} 777 key="top" 778 > 779 {topRowEls} 780 </ActionBarTopRow> 781 ) : null 782 783 // By default, add the disable toggle button regardless of a resource's disabled status 784 const bottomRow: JSX.Element[] = [ 785 <DisableButtonSection 786 key="toggleDisable" 787 button={buttons?.toggleDisable} 788 />, 789 ] 790 const disableButtonVisible = !!buttons?.toggleDisable 791 const firstFilterButtonClass = disableButtonVisible ? "isCentered" : "" 792 793 // Only display log filter controls if a resource is enabled 794 if (!isDisabled) { 795 bottomRow.push( 796 <FilterRadioButton 797 key="filterLevelAll" 798 className={firstFilterButtonClass} 799 level={FilterLevel.all} 800 filterSet={filterSet} 801 alerts={alerts} 802 /> 803 ) 804 bottomRow.push( 805 <FilterRadioButton 806 key="filterLevelError" 807 level={FilterLevel.error} 808 filterSet={filterSet} 809 alerts={alerts} 810 /> 811 ) 812 bottomRow.push( 813 <FilterRadioButton 814 key="filterLevelWarn" 815 level={FilterLevel.warn} 816 filterSet={filterSet} 817 alerts={alerts} 818 /> 819 ) 820 bottomRow.push( 821 <FilterTermField key="filterTermField" termFromUrl={filterSet.term} /> 822 ) 823 bottomRow.push( 824 <LogActions 825 key="logActions" 826 resourceName={resourceName} 827 isSnapshot={isSnapshot} 828 /> 829 ) 830 } 831 832 const { isSidebarOpen } = useSidebarContext() 833 let nav = useResourceNav() 834 let name = nav.invalidResource || nav.selectedResource || "" 835 836 return ( 837 <ActionBarRoot> 838 <OverviewActionBarKeyboardShortcuts 839 logStore={logStore} 840 resourceName={resourceName} 841 endpoints={endpoints} 842 openEndpointUrl={openEndpointUrl} 843 /> 844 {!isSidebarOpen && ( 845 <ResourceNameTitleRow>Resource: {name}</ResourceNameTitleRow> 846 )} 847 {topRow} 848 <ActionBarBottomRow>{bottomRow}</ActionBarBottomRow> 849 </ActionBarRoot> 850 ) 851 }