github.com/tilt-dev/tilt@v0.36.0/web/src/HUD.tsx (about)

     1  import { StylesProvider } from "@material-ui/core/styles"
     2  import React, { Component } from "react"
     3  import ReactOutlineManager from "react-outline-manager"
     4  import { useLocation, useNavigate, Location } from "react-router-dom"
     5  import { Route, Routes } from "react-router-dom"
     6  import AnalyticsNudge from "./AnalyticsNudge"
     7  import AppController from "./AppController"
     8  import { tiltfileKeyContext } from "./BrowserStorage"
     9  import ErrorModal from "./ErrorModal"
    10  import FatalErrorModal from "./FatalErrorModal"
    11  import { FeaturesProvider } from "./feature"
    12  import HeroScreen from "./HeroScreen"
    13  import "./HUD.scss"
    14  import { HudErrorContextProvider } from "./HudErrorContext"
    15  import HudState from "./HudState"
    16  import { InterfaceVersion, useInterfaceVersion } from "./InterfaceVersion"
    17  import LogStore, { LogStoreProvider } from "./LogStore"
    18  import OverviewResourcePane from "./OverviewResourcePane"
    19  import OverviewTablePane from "./OverviewTablePane"
    20  import PathBuilder, { PathBuilderProvider } from "./PathBuilder"
    21  import { ResourceGroupsContextProvider } from "./ResourceGroupsContext"
    22  import { ResourceListOptionsProvider } from "./ResourceListOptionsContext"
    23  import { ResourceNavProvider } from "./ResourceNav"
    24  import { ResourceSelectionProvider } from "./ResourceSelectionContext"
    25  import ShareSnapshotModal from "./ShareSnapshotModal"
    26  import { SidebarContextProvider } from "./SidebarContext"
    27  import { TiltSnackbarProvider } from "./Snackbar"
    28  import { SnapshotActionProvider, SnapshotProviderProps } from "./snapshot"
    29  import SocketBar, { isTiltSocketConnected } from "./SocketBar"
    30  import { StarredResourcesContextProvider } from "./StarredResourcesContext"
    31  import {
    32    ShowErrorModal,
    33    ShowFatalErrorModal,
    34    SocketState,
    35    UIResourceStatus,
    36  } from "./types"
    37  
    38  export type HudProps = {
    39    interfaceVersion: InterfaceVersion
    40    navigate: ReturnType<typeof useNavigate>
    41    location: Location
    42  }
    43  
    44  // Snapshot logs are capped to 1MB (max upload size is 4MB; this ensures between the rest of state and JSON overhead
    45  // that the snapshot should still fit)
    46  const MAX_SNAPSHOT_LOG_SIZE = 1000 * 1000
    47  
    48  // The Main HUD view, as specified in
    49  // https://docs.google.com/document/d/1VNIGfpC4fMfkscboW0bjYYFJl07um_1tsFrbN-Fu3FI/edit#heading=h.l8mmnclsuxl1
    50  export default class HUD extends Component<HudProps, HudState> {
    51    // The root of the HUD view, without the slash.
    52    private pathBuilder: PathBuilder
    53    private controller: AppController
    54    private navigate: ReturnType<typeof useNavigate>
    55    private location: Location
    56  
    57    constructor(props: HudProps) {
    58      super(props)
    59  
    60      this.pathBuilder = new PathBuilder(window.location)
    61      this.controller = new AppController(this.pathBuilder, this)
    62      this.navigate = props.navigate
    63      this.location = props.location
    64  
    65      this.state = {
    66        view: {},
    67        snapshotHighlight: undefined,
    68        snapshotDialogAnchor: null,
    69        snapshotStartTime: undefined,
    70        showSnapshotModal: false,
    71        showFatalErrorModal: ShowFatalErrorModal.Default,
    72        showCopySuccess: false,
    73        socketState: SocketState.Closed,
    74        showErrorModal: ShowErrorModal.Default,
    75        error: undefined,
    76        logStore: new LogStore(),
    77      }
    78  
    79      this.handleOpenModal = this.handleOpenModal.bind(this)
    80      this.handleShowCopySuccess = this.handleShowCopySuccess.bind(this)
    81      this.setError = this.setError.bind(this)
    82      this.snapshotFromState = this.snapshotFromState.bind(this)
    83      this.getSnapshotProviderProps = this.getSnapshotProviderProps.bind(this)
    84    }
    85  
    86    componentDidMount() {
    87      if (process.env.NODE_ENV === "test") {
    88        // we don't want to run any bootstrapping code in the test environment
    89        return
    90      }
    91      if (this.pathBuilder.isSnapshot()) {
    92        this.controller.setStateFromSnapshot()
    93      } else {
    94        this.controller.createNewSocket()
    95      }
    96    }
    97  
    98    componentWillUnmount() {
    99      this.controller.dispose()
   100    }
   101  
   102    onAppChange<K extends keyof HudState>(stateUpdates: Pick<HudState, K>) {
   103      this.setState((prevState) => mergeAppUpdate(prevState, stateUpdates))
   104    }
   105  
   106    setHistoryLocation(path: string) {
   107      this.props.navigate(path, { replace: true })
   108    }
   109  
   110    path(relPath: string) {
   111      return this.pathBuilder.path(relPath)
   112    }
   113  
   114    snapshotFromState(state: HudState): Proto.webviewSnapshot {
   115      let view: any = {}
   116      if (state.view) {
   117        Object.assign(view, state.view)
   118      }
   119      if (state.logStore) {
   120        view.logList = state.logStore.toLogList(MAX_SNAPSHOT_LOG_SIZE)
   121      }
   122      return {
   123        view: view,
   124        path: this.props.location.pathname,
   125        snapshotHighlight: state.snapshotHighlight,
   126        createdAt: new Date().toISOString(),
   127      }
   128    }
   129  
   130    handleShowCopySuccess() {
   131      this.setState(
   132        {
   133          showCopySuccess: true,
   134        },
   135        () => {
   136          setTimeout(() => {
   137            this.setState({
   138              showCopySuccess: false,
   139            })
   140          }, 1500)
   141        }
   142      )
   143    }
   144  
   145    private handleOpenModal(dialogAnchor?: HTMLElement | null) {
   146      this.setState({
   147        showSnapshotModal: true,
   148        snapshotDialogAnchor: dialogAnchor ?? null,
   149      })
   150    }
   151  
   152    private getSnapshotProviderProps(): SnapshotProviderProps {
   153      const providerProps: SnapshotProviderProps = {
   154        openModal: this.handleOpenModal,
   155      }
   156  
   157      if (this.pathBuilder.isSnapshot()) {
   158        providerProps.currentSnapshotTime = {
   159          tiltUpTime: this.state.view.tiltStartTime,
   160          createdAt: this.state.snapshotStartTime,
   161        }
   162      }
   163  
   164      return providerProps
   165    }
   166  
   167    render() {
   168      let view = this.state.view
   169      let session = this.state.view.uiSession?.status
   170  
   171      let needsNudge = session?.needsAnalyticsNudge ?? false
   172      let resources = view?.uiResources ?? []
   173      if (!resources?.length || !session?.tiltfileKey) {
   174        return (
   175          <HeroScreen>
   176            <SocketBar state={this.state.socketState} />
   177            <div>Loading…</div>
   178          </HeroScreen>
   179        )
   180      }
   181  
   182      let tiltfileKey = session?.tiltfileKey
   183      let shareSnapshotModal = this.renderShareSnapshotModal(view)
   184      let fatalErrorModal = this.renderFatalErrorModal(view)
   185      let errorModal = this.renderErrorModal()
   186  
   187      const isSnapshot = this.pathBuilder.isSnapshot()
   188      const hudClasses = ["HUD"]
   189      if (isSnapshot) {
   190        hudClasses.push("is-snapshot")
   191      }
   192  
   193      let validateResource = (name: string) =>
   194        resources.some((res) => res.metadata?.name === name)
   195      return (
   196        <tiltfileKeyContext.Provider value={tiltfileKey}>
   197          <StarredResourcesContextProvider>
   198            <ReactOutlineManager>
   199              <HudErrorContextProvider setError={this.setError}>
   200                <TiltSnackbarProvider>
   201                  <ResourceNavProvider validateResource={validateResource}>
   202                    <div className={hudClasses.join(" ")}>
   203                      <AnalyticsNudge needsNudge={needsNudge} />
   204                      <SocketBar state={this.state.socketState} />
   205                      {fatalErrorModal}
   206                      {errorModal}
   207                      {shareSnapshotModal}
   208                      {this.renderOverviewSwitch()}
   209                    </div>
   210                  </ResourceNavProvider>
   211                </TiltSnackbarProvider>
   212              </HudErrorContextProvider>
   213            </ReactOutlineManager>
   214          </StarredResourcesContextProvider>
   215        </tiltfileKeyContext.Provider>
   216      )
   217    }
   218  
   219    renderOverviewSwitch() {
   220      const isSocketConnected = isTiltSocketConnected(this.state.socketState)
   221      return (
   222        <FeaturesProvider
   223          featureFlags={this.state.view.uiSession?.status?.featureFlags || null}
   224        >
   225          <PathBuilderProvider value={this.pathBuilder}>
   226            <SnapshotActionProvider {...this.getSnapshotProviderProps()}>
   227              <LogStoreProvider value={this.state.logStore || new LogStore()}>
   228                <ResourceGroupsContextProvider>
   229                  <ResourceListOptionsProvider>
   230                    <ResourceSelectionProvider>
   231                      <Routes>
   232                        <Route
   233                          path={this.path("/r/:name/overview")}
   234                          element={
   235                            <SidebarContextProvider>
   236                              <OverviewResourcePane
   237                                view={this.state.view}
   238                                isSocketConnected={isSocketConnected}
   239                              />
   240                            </SidebarContextProvider>
   241                          }
   242                        />
   243                        <Route
   244                          path="*"
   245                          element={
   246                            <OverviewTablePane
   247                              view={this.state.view}
   248                              isSocketConnected={isSocketConnected}
   249                            />
   250                          }
   251                        />
   252                      </Routes>
   253                    </ResourceSelectionProvider>
   254                  </ResourceListOptionsProvider>
   255                </ResourceGroupsContextProvider>
   256              </LogStoreProvider>
   257            </SnapshotActionProvider>
   258          </PathBuilderProvider>
   259        </FeaturesProvider>
   260      )
   261    }
   262  
   263    renderShareSnapshotModal(view: Proto.webviewView | null) {
   264      let handleClose = () => this.setState({ showSnapshotModal: false })
   265      return (
   266        <ShareSnapshotModal
   267          getSnapshot={() => this.snapshotFromState(this.state)}
   268          handleClose={handleClose}
   269          isOpen={this.state.showSnapshotModal}
   270          dialogAnchor={this.state.snapshotDialogAnchor}
   271        />
   272      )
   273    }
   274  
   275    renderFatalErrorModal(view: Proto.webviewView | null) {
   276      let session = view?.uiSession?.status
   277      let error = session?.fatalError
   278      let handleClose = () =>
   279        this.setState({ showFatalErrorModal: ShowFatalErrorModal.Hide })
   280      return (
   281        <FatalErrorModal
   282          error={error}
   283          showFatalErrorModal={this.state.showFatalErrorModal}
   284          handleClose={handleClose}
   285        />
   286      )
   287    }
   288  
   289    renderErrorModal() {
   290      return (
   291        <ErrorModal
   292          error={this.state.error}
   293          showErrorModal={this.state.showErrorModal}
   294          handleClose={() =>
   295            this.setState({
   296              showErrorModal: ShowErrorModal.Default,
   297              error: undefined,
   298            })
   299          }
   300        />
   301      )
   302    }
   303  
   304    setError(error: string) {
   305      this.setState({ error: error })
   306    }
   307  }
   308  
   309  export function HUDFromContext(props: React.PropsWithChildren<{}>) {
   310    const navigate = useNavigate()
   311    const location = useLocation()
   312    const interfaceVersion = useInterfaceVersion()
   313  
   314    return (
   315      /* allow Styled Components to override MUI - https://material-ui.com/guides/interoperability/#controlling-priority-3*/
   316      <StylesProvider injectFirst>
   317        <HUD
   318          interfaceVersion={interfaceVersion}
   319          navigate={navigate}
   320          location={location}
   321        />
   322      </StylesProvider>
   323    )
   324  }
   325  
   326  function compareObjectsOrder<
   327    T extends { status?: any; metadata?: Proto.v1ObjectMeta }
   328  >(a: T, b: T): number {
   329    let aStatus = a.status as UIResourceStatus | null
   330    let bStatus = b.status as UIResourceStatus | null
   331    let aOrder = aStatus?.order || 0
   332    let bOrder = bStatus?.order || 0
   333    if (aOrder != bOrder) {
   334      return aOrder - bOrder
   335    }
   336  
   337    let aName = a.metadata?.name || ""
   338    let bName = b.metadata?.name || ""
   339    return aName < bName ? -1 : aName == bName ? 0 : 1
   340  }
   341  
   342  // returns a copy of `prev` that has the adds/updates/deletes from `updates` applied
   343  function mergeObjectUpdates<T extends { metadata?: Proto.v1ObjectMeta }>(
   344    updates: T[] | undefined,
   345    prev: T[] | undefined
   346  ): T[] {
   347    let next = Array.from(prev || [])
   348    if (updates) {
   349      updates.forEach((u) => {
   350        let index = next.findIndex((o) => o?.metadata?.name === u?.metadata?.name)
   351        if (index === -1) {
   352          next.push(u)
   353        } else {
   354          next[index] = u
   355        }
   356      })
   357      next = next.filter((o) => !o?.metadata?.deletionTimestamp)
   358    }
   359  
   360    next.sort(compareObjectsOrder)
   361  
   362    return next
   363  }
   364  
   365  export function mergeAppUpdate<K extends keyof HudState>(
   366    prevState: Readonly<HudState>,
   367    stateUpdates: Pick<HudState, K>
   368  ): null | Pick<HudState, K> {
   369    // All fields are optional on a HudState, so it's ok to pretent
   370    // a Pick<HudState> and a HudState are the same.
   371    let state = stateUpdates as HudState
   372  
   373    let oldStartTime = prevState.view?.tiltStartTime
   374    let newStartTime = state.view?.tiltStartTime
   375    if (oldStartTime && newStartTime && oldStartTime != newStartTime) {
   376      // If Tilt restarts, reload the page to get new JS.
   377      // https://github.com/tilt-dev/tilt/issues/4421
   378      window.location.reload()
   379      return prevState
   380    }
   381  
   382    let logListUpdate = state.view?.logList
   383    if (state.view?.isComplete) {
   384      // If this is a full state refresh, replace the view field
   385      // and the log store completely.
   386      let newState = { ...state } as any
   387      newState.view = state.view
   388      newState.logStore = new LogStore()
   389      newState.logStore.append(logListUpdate)
   390      newState.view?.uiResources?.sort(compareObjectsOrder)
   391      newState.view?.uiButtons?.sort(compareObjectsOrder)
   392      return newState
   393    }
   394  
   395    // Otherwise, merge the new state updates into the old state object.
   396    let result = { ...state }
   397  
   398    // We're going to merge in view updates manually.
   399    result.view = prevState.view
   400  
   401    if (logListUpdate) {
   402      // We can assume state always has a log store.
   403      prevState.logStore!.append(logListUpdate)
   404    }
   405  
   406    // Merge the UISession
   407    let sessionUpdate = state.view?.uiSession
   408    if (sessionUpdate) {
   409      result.view = Object.assign({}, result.view, {
   410        uiSession: sessionUpdate,
   411      })
   412    }
   413  
   414    const uiResourceUpdates = state.view?.uiResources
   415    if (uiResourceUpdates) {
   416      result.view = Object.assign({}, result.view, {
   417        uiResources: mergeObjectUpdates(
   418          uiResourceUpdates,
   419          result.view?.uiResources
   420        ),
   421      })
   422    }
   423  
   424    const uiButtonUpdates = state.view?.uiButtons
   425    if (uiButtonUpdates) {
   426      result.view = Object.assign({}, result.view, {
   427        uiButtons: mergeObjectUpdates(uiButtonUpdates, result.view?.uiButtons),
   428      })
   429    }
   430  
   431    const clusterUpdates = state.view?.clusters
   432    if (clusterUpdates) {
   433      result.view = Object.assign({}, result.view, {
   434        clusters: mergeObjectUpdates(clusterUpdates, result.view?.clusters),
   435      })
   436    }
   437  
   438    // If no references have changed, don't re-render.
   439    //
   440    // LogStore handles its own update events, so a change
   441    // to LogStore doesn't update its reference.
   442    // This makes rendering much, much faster for apps
   443    // with lots of logs.
   444    if (!hasChange(result, prevState)) {
   445      return null
   446    }
   447  
   448    return result
   449  }
   450  
   451  function hasChange(result: any, prevState: any): boolean {
   452    for (let k in result) {
   453      let resultV = result[k] as any
   454      let prevV = prevState[k] as any
   455      if (resultV !== prevV) {
   456        return true
   457      }
   458    }
   459    return false
   460  }