github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/web/src/ResourceStatusSummary.tsx (about)

     1  import React, { useEffect } from "react"
     2  import { Link } from "react-router-dom"
     3  import styled from "styled-components"
     4  import { ReactComponent as CheckmarkSmallSvg } from "./assets/svg/checkmark-small.svg"
     5  import { ReactComponent as CloseSvg } from "./assets/svg/close.svg"
     6  import { ReactComponent as DisabledSvg } from "./assets/svg/not-allowed.svg"
     7  import { ReactComponent as PendingSvg } from "./assets/svg/pending.svg"
     8  import { ReactComponent as WarningSvg } from "./assets/svg/warning.svg"
     9  import { linkToTiltAsset } from "./constants"
    10  import { FilterLevel } from "./logfilters"
    11  import { useLogStore } from "./LogStore"
    12  import { RowValues } from "./OverviewTableColumns"
    13  import { usePathBuilder } from "./PathBuilder"
    14  import SidebarItem from "./SidebarItem"
    15  import { buildStatus, combinedStatus, runtimeStatus } from "./status"
    16  import {
    17    Color,
    18    Font,
    19    FontSize,
    20    mixinResetListStyle,
    21    SizeUnit,
    22    spin,
    23  } from "./style-helpers"
    24  import Tooltip from "./Tooltip"
    25  import { ResourceName, ResourceStatus, UIResource } from "./types"
    26  
    27  const ResourceGroupStatusLabel = styled.p`
    28    text-transform: uppercase;
    29    margin-right: ${SizeUnit(0.5)};
    30  `
    31  const ResourceGroupStatusSummaryList = styled.ul`
    32    display: flex;
    33    ${mixinResetListStyle}
    34  `
    35  const ResourceGroupStatusSummaryItemRoot = styled.li`
    36    display: flex;
    37    align-items: center;
    38  
    39    & + & {
    40      margin-left: ${SizeUnit(0.25)};
    41      border-left: 1px solid ${Color.gray40};
    42      padding-left: ${SizeUnit(0.25)};
    43    }
    44    &.is-highlightError {
    45      color: ${Color.red};
    46      .fillStd {
    47        fill: ${Color.red};
    48      }
    49    }
    50    &.is-highlightWarning {
    51      color: ${Color.yellow};
    52      .fillStd {
    53        fill: ${Color.yellow};
    54      }
    55    }
    56    &.is-highlightPending {
    57      color: ${Color.gray70};
    58      stroke: ${Color.gray70};
    59      .fillStd {
    60        fill: ${Color.gray70};
    61      }
    62    }
    63    &.is-highlightHealthy {
    64      color: ${Color.green};
    65      .fillStd {
    66        fill: ${Color.green};
    67      }
    68    }
    69  `
    70  export const ResourceGroupStatusSummaryItemCount = styled.span`
    71    font-weight: bold;
    72    padding-left: 4px;
    73    padding-right: 4px;
    74  `
    75  export const ResourceStatusSummaryRoot = styled.aside`
    76    display: flex;
    77    font-family: ${Font.sansSerif};
    78    font-size: ${FontSize.smallest};
    79    align-items: center;
    80    color: ${Color.grayLightest};
    81  
    82    .fillStd {
    83      fill: ${Color.gray40};
    84    }
    85  
    86    & + & {
    87      margin-left: ${SizeUnit(1.5)};
    88    }
    89  `
    90  export const PendingIcon = styled(PendingSvg)`
    91    animation: ${spin} 4s linear infinite;
    92  `
    93  
    94  const DisabledIcon = styled(DisabledSvg)`
    95    .fillStd {
    96      fill: ${Color.gray60};
    97    }
    98  `
    99  
   100  type ResourceGroupStatusItemProps = {
   101    label: string
   102    icon: JSX.Element
   103    className: string
   104    count: number
   105    countOutOf?: number
   106    href?: string
   107  }
   108  export function ResourceGroupStatusItem(props: ResourceGroupStatusItemProps) {
   109    const count = (
   110      <>
   111        <ResourceGroupStatusSummaryItemCount aria-label={`${props.label} count`}>
   112          {props.count}
   113        </ResourceGroupStatusSummaryItemCount>
   114        {props.countOutOf && (
   115          <>
   116            /
   117            <ResourceGroupStatusSummaryItemCount aria-label="Out of total resource count">
   118              {props.countOutOf}
   119            </ResourceGroupStatusSummaryItemCount>
   120          </>
   121        )}
   122      </>
   123    )
   124  
   125    const summaryContent = props.href ? (
   126      <Link to={props.href}>{count}</Link>
   127    ) : (
   128      <>{count}</>
   129    )
   130  
   131    return (
   132      <Tooltip title={props.label}>
   133        <ResourceGroupStatusSummaryItemRoot className={props.className}>
   134          {props.icon}
   135          {summaryContent}
   136        </ResourceGroupStatusSummaryItemRoot>
   137      </Tooltip>
   138    )
   139  }
   140  
   141  type ResourceGroupStatusProps = {
   142    counts: StatusCounts
   143    displayText?: string
   144    labelText: string // Used for a11y markup, should be a descriptive title.
   145    healthyLabel: string
   146    unhealthyLabel: string
   147    warningLabel: string
   148    linkToLogFilters: boolean
   149  }
   150  
   151  export function ResourceGroupStatus(props: ResourceGroupStatusProps) {
   152    if (props.counts.totalEnabled === 0 && props.counts.disabled === 0) {
   153      return null
   154    }
   155    let pb = usePathBuilder()
   156  
   157    let items = new Array<JSX.Element>()
   158  
   159    if (props.counts.unhealthy) {
   160      const errorHref = props.linkToLogFilters
   161        ? pb.encpath`/r/${ResourceName.all}/overview?level=${FilterLevel.error}`
   162        : undefined
   163      items.push(
   164        <ResourceGroupStatusItem
   165          key={props.unhealthyLabel}
   166          label={props.unhealthyLabel}
   167          count={props.counts.unhealthy}
   168          href={errorHref}
   169          className="is-highlightError"
   170          icon={<CloseSvg role="presentation" width="11" key="icon" />}
   171        />
   172      )
   173    }
   174  
   175    if (props.counts.warning) {
   176      const warningHref = props.linkToLogFilters
   177        ? pb.encpath`/r/${ResourceName.all}/overview?level=${FilterLevel.warn}`
   178        : undefined
   179      items.push(
   180        <ResourceGroupStatusItem
   181          key={props.warningLabel}
   182          label={props.warningLabel}
   183          count={props.counts.warning}
   184          href={warningHref}
   185          className="is-highlightWarning"
   186          icon={<WarningSvg role="presentation" width="7" key="icon" />}
   187        />
   188      )
   189    }
   190  
   191    if (props.counts.pending) {
   192      items.push(
   193        <ResourceGroupStatusItem
   194          key="pending"
   195          label="pending"
   196          count={props.counts.pending}
   197          className="is-highlightPending"
   198          icon={<PendingIcon role="presentation" width="8" key="icon" />}
   199        />
   200      )
   201    }
   202  
   203    // There might not always be enabled resources
   204    // if all resources are disabled
   205    if (props.counts.totalEnabled) {
   206      items.push(
   207        <ResourceGroupStatusItem
   208          key={props.healthyLabel}
   209          label={props.healthyLabel}
   210          count={props.counts.healthy}
   211          countOutOf={props.counts.totalEnabled}
   212          className="is-highlightHealthy"
   213          icon={<CheckmarkSmallSvg role="presentation" key="icon" />}
   214        />
   215      )
   216    }
   217  
   218    if (props.counts.disabled) {
   219      items.push(
   220        <ResourceGroupStatusItem
   221          key="disabled"
   222          label="disabled"
   223          count={props.counts.disabled}
   224          className="is-highlightDisabled"
   225          icon={<DisabledIcon role="presentation" width="15" key="icon" />}
   226        />
   227      )
   228    }
   229  
   230    const displayLabel = props.displayText ? (
   231      <ResourceGroupStatusLabel>{props.displayText}</ResourceGroupStatusLabel>
   232    ) : null
   233  
   234    return (
   235      <>
   236        {displayLabel}
   237        <ResourceGroupStatusSummaryList>{items}</ResourceGroupStatusSummaryList>
   238      </>
   239    )
   240  }
   241  
   242  export type StatusCounts = {
   243    totalEnabled: number
   244    healthy: number
   245    unhealthy: number
   246    pending: number
   247    warning: number
   248    disabled: number
   249  }
   250  
   251  function statusCounts(statuses: ResourceStatus[]): StatusCounts {
   252    let allEnabledStatusCount = 0
   253    let healthyStatusCount = 0
   254    let unhealthyStatusCount = 0
   255    let pendingStatusCount = 0
   256    let warningCount = 0
   257    let disabledCount = 0
   258    statuses.forEach((status) => {
   259      switch (status) {
   260        case ResourceStatus.Warning:
   261          allEnabledStatusCount++
   262          healthyStatusCount++
   263          warningCount++
   264          break
   265        case ResourceStatus.Healthy:
   266          allEnabledStatusCount++
   267          healthyStatusCount++
   268          break
   269        case ResourceStatus.Unhealthy:
   270          allEnabledStatusCount++
   271          unhealthyStatusCount++
   272          break
   273        case ResourceStatus.Pending:
   274        case ResourceStatus.Building:
   275          allEnabledStatusCount++
   276          pendingStatusCount++
   277          break
   278        case ResourceStatus.Disabled:
   279          disabledCount++
   280          break
   281        default:
   282        // Don't count None status in the overall resource count.
   283        // These might be manual tasks we haven't run yet.
   284      }
   285    })
   286  
   287    return {
   288      totalEnabled: allEnabledStatusCount,
   289      healthy: healthyStatusCount,
   290      unhealthy: unhealthyStatusCount,
   291      pending: pendingStatusCount,
   292      warning: warningCount,
   293      disabled: disabledCount,
   294    }
   295  }
   296  
   297  export function getDocumentTitle(
   298    counts: StatusCounts,
   299    isSnapshot: boolean,
   300    isSocketConnected: boolean
   301  ) {
   302    const { totalEnabled, healthy, pending, unhealthy } = counts
   303    let faviconHref = "/static/ico/favicon-green.ico"
   304    let title = `✔︎ ${healthy}/${totalEnabled} ┊ Tilt`
   305    if (!isSocketConnected && !isSnapshot) {
   306      title = "Disconnected ┊ Tilt"
   307      // Use a publicly-hosted favicon since Tilt is disconnected
   308      // and it's not guaranteed that the favicon will be cached
   309      faviconHref = linkToTiltAsset("ico", "dashboard-favicon-gray.ico")
   310    } else if (unhealthy > 0) {
   311      title = `✖︎ ${unhealthy} ┊ Tilt`
   312      faviconHref = "/static/ico/favicon-red.ico"
   313    } else if (pending) {
   314      title = `… ${healthy}/${totalEnabled} ┊ Tilt`
   315      faviconHref = "/static/ico/favicon-gray.ico"
   316    } else if (totalEnabled === 0) {
   317      title = `✔︎ 0/0 ┊ Tilt`
   318      faviconHref = "/static/ico/favicon-gray.ico"
   319    }
   320  
   321    if (isSnapshot) {
   322      title = `Snapshot: ${title}`
   323    }
   324  
   325    return { title, faviconHref }
   326  }
   327  
   328  function ResourceMetadata(props: {
   329    counts: StatusCounts
   330    isSocketConnected?: boolean
   331  }) {
   332    let { totalEnabled, healthy, pending, unhealthy } = props.counts
   333    const pb = usePathBuilder()
   334    const isSnapshot = pb.isSnapshot()
   335  
   336    useEffect(() => {
   337      // Determine the document title and favicon based
   338      // on Tilt's connection, resource statuses, and whether
   339      // or not Tilt is displaying a snapshot
   340      const existingFavicon =
   341        document.head.querySelector<HTMLLinkElement>("#favicon")
   342      const { title, faviconHref } = getDocumentTitle(
   343        props.counts,
   344        isSnapshot,
   345        props.isSocketConnected ?? true
   346      )
   347      document.title = title
   348      if (existingFavicon) {
   349        existingFavicon.href = faviconHref
   350      }
   351    }, [totalEnabled, healthy, pending, unhealthy, props.isSocketConnected])
   352    return <></>
   353  }
   354  
   355  type ResourceStatusSummaryOptions = {
   356    displayText?: string
   357    labelText?: string
   358    updateMetadata?: boolean
   359    linkToLogFilters?: boolean
   360  }
   361  
   362  type ResourceStatusSummaryProps = {
   363    statuses: ResourceStatus[]
   364    isSocketConnected?: boolean
   365  } & ResourceStatusSummaryOptions
   366  
   367  function ResourceStatusSummary(props: ResourceStatusSummaryProps) {
   368    // Default the display options if no option is provided
   369    const updateMetadata = props.updateMetadata ?? true
   370    const linkToLogFilters = props.linkToLogFilters ?? true
   371    const labelText = props.labelText ?? "Resource status summary"
   372  
   373    return (
   374      <ResourceStatusSummaryRoot aria-label={labelText}>
   375        {updateMetadata && (
   376          <ResourceMetadata
   377            counts={statusCounts(props.statuses)}
   378            isSocketConnected={props.isSocketConnected}
   379          />
   380        )}
   381        <ResourceGroupStatus
   382          counts={statusCounts(props.statuses)}
   383          displayText={props.displayText}
   384          labelText={labelText}
   385          healthyLabel={"healthy"}
   386          unhealthyLabel={"err"}
   387          warningLabel={"warn"}
   388          linkToLogFilters={linkToLogFilters}
   389        />
   390      </ResourceStatusSummaryRoot>
   391    )
   392  }
   393  
   394  // The generic StatusSummaryProps takes a template type
   395  // for the resources it will summarize, so that it can be used
   396  // throughout the app with different data types.
   397  
   398  type StatusSummaryProps<T> = {
   399    resources: readonly T[]
   400    isSocketConnected?: boolean
   401  } & ResourceStatusSummaryOptions
   402  
   403  export function SidebarGroupStatusSummary(
   404    props: StatusSummaryProps<SidebarItem>
   405  ) {
   406    const allStatuses = props.resources.map((item: SidebarItem) =>
   407      combinedStatus(item.buildStatus, item.runtimeStatus)
   408    )
   409  
   410    return (
   411      <ResourceStatusSummary
   412        statuses={allStatuses}
   413        linkToLogFilters={false}
   414        updateMetadata={false}
   415        {...props}
   416      />
   417    )
   418  }
   419  
   420  export function TableGroupStatusSummary(props: StatusSummaryProps<RowValues>) {
   421    const allStatuses = props.resources.map((r: RowValues) =>
   422      combinedStatus(r.statusLine.buildStatus, r.statusLine.runtimeStatus)
   423    )
   424  
   425    return (
   426      <ResourceStatusSummary
   427        statuses={allStatuses}
   428        linkToLogFilters={false}
   429        updateMetadata={false}
   430        {...props}
   431      />
   432    )
   433  }
   434  
   435  export function AllResourceStatusSummary(
   436    props: StatusSummaryProps<UIResource>
   437  ) {
   438    const logStore = useLogStore()
   439    const allStatuses = props.resources.map((r: UIResource) =>
   440      combinedStatus(buildStatus(r, logStore), runtimeStatus(r, logStore))
   441    )
   442  
   443    return <ResourceStatusSummary statuses={allStatuses} {...props} />
   444  }