github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/web/src/OverviewTableColumns.tsx (about) 1 import React, { ChangeEvent, useCallback, useMemo, useState } from "react" 2 import { CellProps, Column, HeaderProps, Row } from "react-table" 3 import TimeAgo from "react-timeago" 4 import styled from "styled-components" 5 import { AnalyticsAction, AnalyticsType, incr, Tags } from "./analytics" 6 import { ApiButton, ApiIcon, ButtonSet } from "./ApiButton" 7 import { ReactComponent as CheckmarkSvg } from "./assets/svg/checkmark.svg" 8 import { ReactComponent as CopySvg } from "./assets/svg/copy.svg" 9 import { ReactComponent as LinkSvg } from "./assets/svg/link.svg" 10 import { ReactComponent as StarSvg } from "./assets/svg/star.svg" 11 import { linkToTiltDocs, TiltDocsPage } from "./constants" 12 import { Hold } from "./Hold" 13 import { 14 InstrumentedButton, 15 InstrumentedCheckbox, 16 } from "./instrumentedComponents" 17 import { displayURL, resolveURL } from "./links" 18 import { OverviewButtonMixin } from "./OverviewButton" 19 import { OverviewTableBuildButton } from "./OverviewTableBuildButton" 20 import OverviewTableStarResourceButton from "./OverviewTableStarResourceButton" 21 import OverviewTableStatus from "./OverviewTableStatus" 22 import OverviewTableTriggerModeToggle from "./OverviewTableTriggerModeToggle" 23 import { useResourceNav } from "./ResourceNav" 24 import { useResourceSelection } from "./ResourceSelectionContext" 25 import { disabledResourceStyleMixin } from "./ResourceStatus" 26 import { useStarredResources } from "./StarredResourcesContext" 27 import { 28 Color, 29 FontSize, 30 mixinResetButtonStyle, 31 SizeUnit, 32 } from "./style-helpers" 33 import { timeAgoFormatter } from "./timeFormatters" 34 import TiltTooltip, { TiltInfoTooltip } from "./Tooltip" 35 import { startBuild } from "./trigger" 36 import { ResourceStatus, TriggerMode, UIButton, UILink } from "./types" 37 38 /** 39 * Types 40 */ 41 type OverviewTableBuildButtonStatus = { 42 isBuilding: boolean 43 hasBuilt: boolean 44 hasPendingChanges: boolean 45 isQueued: boolean 46 } 47 48 type OverviewTableResourceStatus = { 49 buildStatus: ResourceStatus 50 buildAlertCount: number 51 lastBuildDur: moment.Duration | null 52 runtimeStatus: ResourceStatus 53 runtimeAlertCount: number 54 hold?: Hold | null 55 } 56 57 export type RowValues = { 58 lastDeployTime: string 59 trigger: OverviewTableBuildButtonStatus 60 name: string 61 resourceTypeLabel: string 62 statusLine: OverviewTableResourceStatus 63 podId: string 64 endpoints: UILink[] 65 mode: TriggerMode 66 buttons: ButtonSet 67 analyticsTags: Tags 68 selectable: boolean 69 } 70 71 /** 72 * Styles 73 */ 74 75 export const SelectionCheckbox = styled(InstrumentedCheckbox)` 76 &.MuiCheckbox-root, 77 &.Mui-checked { 78 color: ${Color.gray60}; 79 } 80 81 &.Mui-disabled { 82 opacity: 0.25; 83 cursor: not-allowed; 84 } 85 ` 86 87 const TableHeaderStarIcon = styled(StarSvg)` 88 fill: ${Color.gray70}; 89 height: 13px; 90 width: 13px; 91 ` 92 93 export const Name = styled.button` 94 ${mixinResetButtonStyle}; 95 color: ${Color.offWhite}; 96 font-size: ${FontSize.small}; 97 padding-top: ${SizeUnit(1 / 3)}; 98 padding-bottom: ${SizeUnit(1 / 3)}; 99 text-align: left; 100 cursor: pointer; 101 102 &:hover { 103 text-decoration: underline; 104 text-underline-position: under; 105 } 106 107 &.has-error { 108 color: ${Color.red}; 109 } 110 111 &.isDisabled { 112 ${disabledResourceStyleMixin} 113 color: ${Color.gray60}; 114 } 115 ` 116 117 const Endpoint = styled.a` 118 display: flex; 119 align-items: center; 120 max-width: 150px; 121 ` 122 const DetailText = styled.div` 123 overflow: hidden; 124 text-overflow: ellipsis; 125 white-space: nowrap; 126 ` 127 128 const StyledLinkSvg = styled(LinkSvg)` 129 fill: ${Color.gray50}; 130 flex-shrink: 0; 131 margin-right: ${SizeUnit(0.2)}; 132 ` 133 134 const PodId = styled.div` 135 display: flex; 136 align-items: center; 137 ` 138 const PodIdInput = styled.input` 139 background-color: transparent; 140 color: ${Color.gray60}; 141 font-family: inherit; 142 font-size: inherit; 143 border: 1px solid ${Color.gray10}; 144 border-radius: 2px; 145 padding: ${SizeUnit(0.1)} ${SizeUnit(0.2)}; 146 width: 100px; 147 text-overflow: ellipsis; 148 overflow: auto; 149 150 &::selection { 151 background-color: ${Color.gray30}; 152 } 153 ` 154 const PodIdCopy = styled(InstrumentedButton)` 155 ${mixinResetButtonStyle}; 156 padding-top: ${SizeUnit(0.5)}; 157 padding: ${SizeUnit(0.25)}; 158 flex-shrink: 0; 159 160 svg { 161 fill: ${Color.gray60}; 162 } 163 ` 164 const CustomActionButton = styled(ApiButton)` 165 button { 166 ${OverviewButtonMixin}; 167 } 168 ` 169 const WidgetCell = styled.span` 170 display: flex; 171 flex-wrap: wrap; 172 max-width: ${SizeUnit(8)}; 173 174 .MuiButtonGroup-root { 175 margin-bottom: ${SizeUnit(0.125)}; 176 margin-right: ${SizeUnit(0.125)}; 177 margin-top: ${SizeUnit(0.125)}; 178 } 179 ` 180 181 /** 182 * Table data helpers 183 */ 184 185 export function rowIsDisabled(row: Row<RowValues>): boolean { 186 // If a resource is disabled, both runtime and build statuses should 187 // be `disabled` and it won't matter which one we look at 188 return row.original.statusLine.runtimeStatus === ResourceStatus.Disabled 189 } 190 191 async function copyTextToClipboard(text: string, cb: () => void) { 192 await navigator.clipboard.writeText(text) 193 cb() 194 } 195 196 function statusSortKey(row: RowValues): string { 197 const status = row.statusLine 198 let order 199 if ( 200 status.buildStatus == ResourceStatus.Unhealthy || 201 status.runtimeStatus === ResourceStatus.Unhealthy 202 ) { 203 order = 0 204 } else if (status.buildAlertCount || status.runtimeAlertCount) { 205 order = 1 206 } else if ( 207 status.runtimeStatus === ResourceStatus.Disabled || 208 status.buildStatus === ResourceStatus.Disabled 209 ) { 210 // Disabled resources should appear last 211 order = 3 212 } else { 213 order = 2 214 } 215 // add name after order just to keep things stable when orders are equal 216 return `${order}${row.name}` 217 } 218 219 /** 220 * Header components 221 */ 222 export function ResourceSelectionHeader({ 223 rows, 224 column, 225 }: HeaderProps<RowValues>) { 226 const { selected, isSelected, select, deselect } = useResourceSelection() 227 228 const selectableResourcesInTable = useMemo(() => { 229 const resources: string[] = [] 230 rows.forEach(({ original }) => { 231 if (original.selectable) { 232 resources.push(original.name) 233 } 234 }) 235 236 return resources 237 }, [rows]) 238 239 function getSelectionState(resourcesInTable: string[]): { 240 indeterminate: boolean 241 checked: boolean 242 } { 243 let anySelected = false 244 let anyUnselected = false 245 for (let i = 0; i < resourcesInTable.length; i++) { 246 if (isSelected(resourcesInTable[i])) { 247 anySelected = true 248 } else { 249 anyUnselected = true 250 } 251 252 if (anySelected && anyUnselected) { 253 break 254 } 255 } 256 257 return { 258 indeterminate: anySelected && anyUnselected, 259 checked: !anyUnselected, 260 } 261 } 262 263 const { indeterminate, checked } = useMemo( 264 () => getSelectionState(selectableResourcesInTable), 265 [selectableResourcesInTable, selected] 266 ) 267 268 // If no resources in the table are selectable, don't render 269 if (selectableResourcesInTable.length === 0) { 270 return null 271 } 272 273 const onChange = (_e: ChangeEvent<HTMLInputElement>) => { 274 if (!checked) { 275 select(...selectableResourcesInTable) 276 } else { 277 deselect(...selectableResourcesInTable) 278 } 279 } 280 281 const analyticsTags: Tags = { 282 type: AnalyticsType.Grid, 283 } 284 285 return ( 286 <SelectionCheckbox 287 aria-label="Resource group selection" 288 analyticsName={"ui.web.checkbox.resourceGroupSelection"} 289 analyticsTags={analyticsTags} 290 checked={checked} 291 aria-checked={checked} 292 indeterminate={indeterminate} 293 onChange={onChange} 294 size="small" 295 /> 296 ) 297 } 298 299 /** 300 * Column components 301 */ 302 export function TableStarColumn({ row }: CellProps<RowValues>) { 303 let ctx = useStarredResources() 304 return ( 305 <OverviewTableStarResourceButton 306 resourceName={row.values.name} 307 analyticsName="ui.web.overviewStarButton" 308 analyticsTags={row.values.analyticsTags} 309 ctx={ctx} 310 /> 311 ) 312 } 313 314 export function TableUpdateColumn({ row }: CellProps<RowValues>) { 315 if (!row.values.lastDeployTime) { 316 return null 317 } 318 return ( 319 <TimeAgo date={row.values.lastDeployTime} formatter={timeAgoFormatter} /> 320 ) 321 } 322 323 export function TableSelectionColumn({ row }: CellProps<RowValues>) { 324 const selections = useResourceSelection() 325 const resourceName = row.original.name 326 const checked = selections.isSelected(resourceName) 327 328 const onChange = useCallback( 329 (_e: ChangeEvent<HTMLInputElement>) => { 330 if (!checked) { 331 selections.select(resourceName) 332 } else { 333 selections.deselect(resourceName) 334 } 335 }, 336 [checked, selections] 337 ) 338 339 const analyticsTags = useMemo(() => { 340 return { 341 ...row.original.analyticsTags, 342 type: AnalyticsType.Grid, 343 } 344 }, [row.original.analyticsTags]) 345 346 let disabled = !row.original.selectable 347 let label = row.original.selectable 348 ? "Select resource" 349 : "Cannot select resource" 350 351 return ( 352 <SelectionCheckbox 353 analyticsName={"ui.web.checkbox.resourceSelection"} 354 analyticsTags={analyticsTags} 355 checked={checked} 356 aria-checked={checked} 357 onChange={onChange} 358 size="small" 359 disabled={disabled} 360 aria-label={label} 361 /> 362 ) 363 } 364 365 let TableBuildButtonColumnRoot = styled.div` 366 display: flex; 367 align-items: center; 368 ` 369 370 export function TableBuildButtonColumn({ row }: CellProps<RowValues>) { 371 // If resource is disabled, don't display build button 372 if (rowIsDisabled(row)) { 373 return null 374 } 375 376 const trigger = row.original.trigger 377 let onStartBuild = useCallback( 378 () => startBuild(row.values.name), 379 [row.values.name] 380 ) 381 return ( 382 <TableBuildButtonColumnRoot> 383 <OverviewTableBuildButton 384 hasPendingChanges={trigger.hasPendingChanges} 385 hasBuilt={trigger.hasBuilt} 386 isBuilding={trigger.isBuilding} 387 triggerMode={row.values.mode} 388 isQueued={trigger.isQueued} 389 analyticsTags={row.values.analyticsTags} 390 onStartBuild={onStartBuild} 391 stopBuildButton={row.original.buttons.stopBuild} 392 /> 393 </TableBuildButtonColumnRoot> 394 ) 395 } 396 397 export function TableNameColumn({ row }: CellProps<RowValues>) { 398 let nav = useResourceNav() 399 let hasError = 400 row.original.statusLine.buildStatus === ResourceStatus.Unhealthy || 401 row.original.statusLine.runtimeStatus === ResourceStatus.Unhealthy 402 const errorClass = hasError ? "has-error" : "" 403 const disabledClass = rowIsDisabled(row) ? "isDisabled" : "" 404 return ( 405 <Name 406 className={`${errorClass} ${disabledClass}`} 407 onClick={(e) => nav.openResource(row.values.name)} 408 > 409 {row.values.name} 410 </Name> 411 ) 412 } 413 414 let TableStatusColumnRoot = styled.div` 415 display: flex; 416 flex-direction: column; 417 align-items: start; 418 justify-content: space-around; 419 min-height: 4em; 420 ` 421 422 export function TableStatusColumn({ row }: CellProps<RowValues>) { 423 const status = row.original.statusLine 424 const runtimeStatus = ( 425 <OverviewTableStatus 426 status={status.runtimeStatus} 427 resourceName={row.values.name} 428 /> 429 ) 430 431 // If a resource is disabled, only one status needs to be displayed 432 if (rowIsDisabled(row)) { 433 return <TableStatusColumnRoot>{runtimeStatus}</TableStatusColumnRoot> 434 } 435 436 return ( 437 <TableStatusColumnRoot> 438 <OverviewTableStatus 439 status={status.buildStatus} 440 lastBuildDur={status.lastBuildDur} 441 isBuild={true} 442 resourceName={row.values.name} 443 hold={status.hold} 444 /> 445 {runtimeStatus} 446 </TableStatusColumnRoot> 447 ) 448 } 449 450 export function TablePodIDColumn({ row }: CellProps<RowValues>) { 451 let [showCopySuccess, setShowCopySuccess] = useState(false) 452 453 let copyClick = () => { 454 copyTextToClipboard(row.values.podId, () => { 455 setShowCopySuccess(true) 456 457 setTimeout(() => { 458 setShowCopySuccess(false) 459 }, 3000) 460 }) 461 } 462 463 // If resource is disabled, don't display pod information 464 if (rowIsDisabled(row)) { 465 return null 466 } 467 468 let icon = showCopySuccess ? ( 469 <CheckmarkSvg width="15" height="15" /> 470 ) : ( 471 <CopySvg width="15" height="15" /> 472 ) 473 474 function selectPodIdInput() { 475 const input = document.getElementById( 476 `pod-${row.values.podId}` 477 ) as HTMLInputElement 478 input && input.select() 479 } 480 481 if (!row.values.podId) return null 482 return ( 483 <PodId> 484 <PodIdInput 485 id={`pod-${row.values.podId}`} 486 value={row.values.podId} 487 readOnly={true} 488 onClick={() => selectPodIdInput()} 489 /> 490 <PodIdCopy 491 onClick={copyClick} 492 analyticsName="ui.web.overview.copyPodID" 493 title="Copy Pod ID" 494 > 495 {icon} 496 </PodIdCopy> 497 </PodId> 498 ) 499 } 500 501 export function TableEndpointColumn({ row }: CellProps<RowValues>) { 502 // If a resource is disabled, don't display any endpoints 503 if (rowIsDisabled(row)) { 504 return null 505 } 506 507 let endpoints = row.original.endpoints.map((ep: any) => { 508 let url = resolveURL(ep.url || "") 509 return ( 510 <Endpoint 511 onClick={() => 512 void incr("ui.web.endpoint", { action: AnalyticsAction.Click }) 513 } 514 href={url} 515 // We use ep.url as the target, so that clicking the link re-uses the tab. 516 target={url} 517 key={url} 518 > 519 <StyledLinkSvg /> 520 <DetailText title={ep.name || displayURL(url)}> 521 {ep.name || displayURL(url)} 522 </DetailText> 523 </Endpoint> 524 ) 525 }) 526 return <>{endpoints}</> 527 } 528 529 export function TableTriggerModeColumn({ row }: CellProps<RowValues>) { 530 let isTiltfile = row.values.name == "(Tiltfile)" 531 const isDisabled = rowIsDisabled(row) 532 533 if (isTiltfile || isDisabled) return null 534 return ( 535 <OverviewTableTriggerModeToggle 536 resourceName={row.values.name} 537 triggerMode={row.values.mode} 538 /> 539 ) 540 } 541 542 export function TableWidgetsColumn({ row }: CellProps<RowValues>) { 543 // If a resource is disabled, don't display any buttons 544 if (rowIsDisabled(row)) { 545 return null 546 } 547 548 const buttons = row.original.buttons.default.map((b: UIButton) => { 549 let content = ( 550 <CustomActionButton key={b.metadata?.name} uiButton={b}> 551 <ApiIcon 552 iconName={b.spec?.iconName || "smart_button"} 553 iconSVG={b.spec?.iconSVG} 554 /> 555 </CustomActionButton> 556 ) 557 558 if (b.spec?.text) { 559 content = ( 560 <TiltTooltip title={b.spec.text}> 561 <span>{content}</span> 562 </TiltTooltip> 563 ) 564 } 565 566 return ( 567 <React.Fragment key={b.metadata?.name || ""}>{content}</React.Fragment> 568 ) 569 }) 570 return <WidgetCell>{buttons}</WidgetCell> 571 } 572 573 /** 574 * Column tooltips 575 */ 576 const modeColumn: Column<RowValues> = { 577 Header: "Mode", 578 id: "mode", 579 accessor: "mode", 580 Cell: TableTriggerModeColumn, 581 width: "auto", 582 } 583 584 const widgetsColumn: Column<RowValues> = { 585 Header: "Widgets", 586 id: "widgets", 587 accessor: (row: any) => row.buttons.default.length, 588 Cell: TableWidgetsColumn, 589 width: "auto", 590 } 591 592 const columnNameToInfoTooltip: { 593 [key: string]: NonNullable<React.ReactNode> 594 } = { 595 [modeColumn.id as string]: ( 596 <> 597 Trigger mode can be toggled through the UI. To set it persistently, see{" "} 598 <a 599 href={linkToTiltDocs(TiltDocsPage.TriggerMode)} 600 target="_blank" 601 rel="noopener noreferrer" 602 > 603 Tiltfile docs 604 </a> 605 . 606 </> 607 ), 608 [widgetsColumn.id as string]: ( 609 <> 610 Buttons can be added to resources to easily perform custom actions. See{" "} 611 <a 612 href={linkToTiltDocs(TiltDocsPage.CustomButtons)} 613 target="_blank" 614 rel="noopener noreferrer" 615 > 616 buttons docs 617 </a> 618 . 619 </> 620 ), 621 } 622 623 export function ResourceTableHeaderTip(props: { id?: string }) { 624 if (!props.id) { 625 return null 626 } 627 628 const tooltipContent = columnNameToInfoTooltip[props.id] 629 if (!tooltipContent) { 630 return null 631 } 632 633 return ( 634 <TiltInfoTooltip 635 title={tooltipContent} 636 dismissId={`table-header-${props.id}`} 637 /> 638 ) 639 } 640 641 // https://react-table.tanstack.com/docs/api/useTable#column-options 642 // The docs on this are not very clear! 643 // `accessor` should return a primitive, and that primitive is used for sorting and filtering 644 // the Cell function can get whatever it needs to render via row.original 645 // best evidence I've (Matt) found: https://github.com/tannerlinsley/react-table/discussions/2429#discussioncomment-25582 646 // (from the author) 647 export const COLUMNS: Column<RowValues>[] = [ 648 { 649 Header: (props) => <ResourceSelectionHeader {...props} />, 650 id: "selection", 651 disableSortBy: true, 652 width: "70px", 653 Cell: TableSelectionColumn, 654 }, 655 { 656 Header: () => <TableHeaderStarIcon title="Starred" />, 657 id: "starred", 658 disableSortBy: true, 659 width: "40px", 660 Cell: TableStarColumn, 661 }, 662 { 663 Header: "Updated", 664 accessor: "lastDeployTime", 665 width: "100px", 666 Cell: TableUpdateColumn, 667 }, 668 { 669 Header: "Trigger", 670 accessor: "trigger", 671 disableSortBy: true, 672 Cell: TableBuildButtonColumn, 673 width: "80px", 674 }, 675 { 676 Header: "Resource Name", 677 accessor: "name", 678 Cell: TableNameColumn, 679 width: "400px", 680 }, 681 { 682 Header: "Type", 683 accessor: "resourceTypeLabel", 684 width: "auto", 685 }, 686 { 687 Header: "Status", 688 accessor: (row) => statusSortKey(row), 689 Cell: TableStatusColumn, 690 width: "auto", 691 }, 692 { 693 Header: "Pod ID", 694 accessor: "podId", 695 width: "auto", 696 Cell: TablePodIDColumn, 697 }, 698 widgetsColumn, 699 { 700 Header: "Endpoints", 701 id: "endpoints", 702 accessor: (row) => row.endpoints.length, 703 sortType: "basic", 704 Cell: TableEndpointColumn, 705 width: "auto", 706 }, 707 modeColumn, 708 ]