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 }