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