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