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}`}>,&nbsp;</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  }