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

     1  import { StylesProvider } from "@material-ui/core/styles"
     2  import { History, UnregisterCallback } from "history"
     3  import React, { Component } from "react"
     4  import ReactOutlineManager from "react-outline-manager"
     5  import { useHistory } from "react-router"
     6  import { Route, RouteComponentProps, Switch } from "react-router-dom"
     7  import { incr, navigationToTags } from "./analytics"
     8  import AnalyticsNudge from "./AnalyticsNudge"
     9  import AppController from "./AppController"
    10  import { tiltfileKeyContext } from "./BrowserStorage"
    11  import ErrorModal from "./ErrorModal"
    12  import FatalErrorModal from "./FatalErrorModal"
    13  import { FeaturesProvider } from "./feature"
    14  import HeroScreen from "./HeroScreen"
    15  import "./HUD.scss"
    16  import { HudErrorContextProvider } from "./HudErrorContext"
    17  import HudState from "./HudState"
    18  import { InterfaceVersion, useInterfaceVersion } from "./InterfaceVersion"
    19  import LogStore, { LogStoreProvider } from "./LogStore"
    20  import OverviewResourcePane from "./OverviewResourcePane"
    21  import OverviewTablePane from "./OverviewTablePane"
    22  import PathBuilder, { PathBuilderProvider } from "./PathBuilder"
    23  import { ResourceGroupsContextProvider } from "./ResourceGroupsContext"
    24  import { ResourceListOptionsProvider } from "./ResourceListOptionsContext"
    25  import { ResourceNavProvider } from "./ResourceNav"
    26  import { ResourceSelectionProvider } from "./ResourceSelectionContext"
    27  import ShareSnapshotModal from "./ShareSnapshotModal"
    28  import { SidebarContextProvider } from "./SidebarContext"
    29  import { TiltSnackbarProvider } from "./Snackbar"
    30  import { SnapshotActionProvider, SnapshotProviderProps } from "./snapshot"
    31  import SocketBar, { isTiltSocketConnected } from "./SocketBar"
    32  import { StarredResourcesContextProvider } from "./StarredResourcesContext"
    33  import {
    34    ShowErrorModal,
    35    ShowFatalErrorModal,
    36    SocketState,
    37    UIResourceStatus,
    38  } from "./types"
    39  
    40  export type HudProps = {
    41    history: History
    42    interfaceVersion: InterfaceVersion
    43  }
    44  
    45  // Snapshot logs are capped to 1MB (max upload size is 4MB; this ensures between the rest of state and JSON overhead
    46  // that the snapshot should still fit)
    47  const MAX_SNAPSHOT_LOG_SIZE = 1000 * 1000
    48  
    49  // The Main HUD view, as specified in
    50  // https://docs.google.com/document/d/1VNIGfpC4fMfkscboW0bjYYFJl07um_1tsFrbN-Fu3FI/edit#heading=h.l8mmnclsuxl1
    51  export default class HUD extends Component<HudProps, HudState> {
    52    // The root of the HUD view, without the slash.
    53    private pathBuilder: PathBuilder
    54    private controller: AppController
    55    private history: History
    56    private unlisten: UnregisterCallback
    57  
    58    constructor(props: HudProps) {
    59      super(props)
    60  
    61      incr("ui.web.init", { ua: window.navigator.userAgent })
    62  
    63      this.pathBuilder = new PathBuilder(window.location)
    64      this.controller = new AppController(this.pathBuilder, this)
    65      this.history = props.history
    66      this.unlisten = this.history.listen((location, action) => {
    67        let tags = navigationToTags(location, action)
    68        incr("ui.web.navigation", tags)
    69      })
    70  
    71      this.state = {
    72        view: {},
    73        snapshotHighlight: undefined,
    74        snapshotDialogAnchor: null,
    75        snapshotStartTime: undefined,
    76        showSnapshotModal: false,
    77        showFatalErrorModal: ShowFatalErrorModal.Default,
    78        showCopySuccess: false,
    79        socketState: SocketState.Closed,
    80        showErrorModal: ShowErrorModal.Default,
    81        error: undefined,
    82        logStore: new LogStore(),
    83      }
    84  
    85      this.handleOpenModal = this.handleOpenModal.bind(this)
    86      this.handleShowCopySuccess = this.handleShowCopySuccess.bind(this)
    87      this.setError = this.setError.bind(this)
    88      this.snapshotFromState = this.snapshotFromState.bind(this)
    89      this.getSnapshotProviderProps = this.getSnapshotProviderProps.bind(this)
    90    }
    91  
    92    componentDidMount() {
    93      if (process.env.NODE_ENV === "test") {
    94        // we don't want to run any bootstrapping code in the test environment
    95        return
    96      }
    97      if (this.pathBuilder.isSnapshot()) {
    98        this.controller.setStateFromSnapshot()
    99      } else {
   100        this.controller.createNewSocket()
   101      }
   102    }
   103  
   104    componentWillUnmount() {
   105      this.controller.dispose()
   106      this.unlisten()
   107    }
   108  
   109    onAppChange<K extends keyof HudState>(stateUpdates: Pick<HudState, K>) {
   110      this.setState((prevState) => mergeAppUpdate(prevState, stateUpdates))
   111    }
   112  
   113    setHistoryLocation(path: string) {
   114      this.props.history.replace(path)
   115    }
   116  
   117    path(relPath: string) {
   118      return this.pathBuilder.path(relPath)
   119    }
   120  
   121    snapshotFromState(state: HudState): Proto.webviewSnapshot {
   122      let view: any = {}
   123      if (state.view) {
   124        Object.assign(view, state.view)
   125      }
   126      if (state.logStore) {
   127        view.logList = state.logStore.toLogList(MAX_SNAPSHOT_LOG_SIZE)
   128      }
   129      return {
   130        view: view,
   131        path: this.props.history.location.pathname,
   132        snapshotHighlight: state.snapshotHighlight,
   133        createdAt: new Date().toISOString(),
   134      }
   135    }
   136  
   137    handleShowCopySuccess() {
   138      this.setState(
   139        {
   140          showCopySuccess: true,
   141        },
   142        () => {
   143          setTimeout(() => {
   144            this.setState({
   145              showCopySuccess: false,
   146            })
   147          }, 1500)
   148        }
   149      )
   150    }
   151  
   152    private handleOpenModal(dialogAnchor?: HTMLElement | null) {
   153      this.setState({
   154        showSnapshotModal: true,
   155        snapshotDialogAnchor: dialogAnchor ?? null,
   156      })
   157    }
   158  
   159    private getSnapshotProviderProps(): SnapshotProviderProps {
   160      const providerProps: SnapshotProviderProps = {
   161        openModal: this.handleOpenModal,
   162      }
   163  
   164      if (this.pathBuilder.isSnapshot()) {
   165        providerProps.currentSnapshotTime = {
   166          tiltUpTime: this.state.view.tiltStartTime,
   167          createdAt: this.state.snapshotStartTime,
   168        }
   169      }
   170  
   171      return providerProps
   172    }
   173  
   174    render() {
   175      let view = this.state.view
   176      let session = this.state.view.uiSession?.status
   177  
   178      let needsNudge = session?.needsAnalyticsNudge ?? false
   179      let resources = view?.uiResources ?? []
   180      if (!resources?.length || !session?.tiltfileKey) {
   181        return (
   182          <HeroScreen>
   183            <SocketBar state={this.state.socketState} />
   184            <div>Loading…</div>
   185          </HeroScreen>
   186        )
   187      }
   188  
   189      let tiltfileKey = session?.tiltfileKey
   190      let shareSnapshotModal = this.renderShareSnapshotModal(view)
   191      let fatalErrorModal = this.renderFatalErrorModal(view)
   192      let errorModal = this.renderErrorModal()
   193  
   194      const isSnapshot = this.pathBuilder.isSnapshot()
   195      const hudClasses = ["HUD"]
   196      if (isSnapshot) {
   197        hudClasses.push("is-snapshot")
   198      }
   199  
   200      let validateResource = (name: string) =>
   201        resources.some((res) => res.metadata?.name === name)
   202      return (
   203        <tiltfileKeyContext.Provider value={tiltfileKey}>
   204          <StarredResourcesContextProvider>
   205            <ReactOutlineManager>
   206              <HudErrorContextProvider setError={this.setError}>
   207                <TiltSnackbarProvider>
   208                  <ResourceNavProvider validateResource={validateResource}>
   209                    <div className={hudClasses.join(" ")}>
   210                      <AnalyticsNudge needsNudge={needsNudge} />
   211                      <SocketBar state={this.state.socketState} />
   212                      {fatalErrorModal}
   213                      {errorModal}
   214                      {shareSnapshotModal}
   215                      {this.renderOverviewSwitch()}
   216                    </div>
   217                  </ResourceNavProvider>
   218                </TiltSnackbarProvider>
   219              </HudErrorContextProvider>
   220            </ReactOutlineManager>
   221          </StarredResourcesContextProvider>
   222        </tiltfileKeyContext.Provider>
   223      )
   224    }
   225  
   226    renderOverviewSwitch() {
   227      const isSocketConnected = isTiltSocketConnected(this.state.socketState)
   228      return (
   229        <FeaturesProvider
   230          featureFlags={this.state.view.uiSession?.status?.featureFlags || null}
   231        >
   232          <PathBuilderProvider value={this.pathBuilder}>
   233            <SnapshotActionProvider {...this.getSnapshotProviderProps()}>
   234              <LogStoreProvider value={this.state.logStore || new LogStore()}>
   235                <ResourceGroupsContextProvider>
   236                  <ResourceListOptionsProvider>
   237                    <ResourceSelectionProvider>
   238                      <Switch>
   239                        <Route
   240                          path={this.path("/r/:name/overview")}
   241                          render={(_props: RouteComponentProps<any>) => (
   242                            <SidebarContextProvider>
   243                              <OverviewResourcePane
   244                                view={this.state.view}
   245                                isSocketConnected={isSocketConnected}
   246                              />
   247                            </SidebarContextProvider>
   248                          )}
   249                        />
   250                        <Route
   251                          render={() => (
   252                            <OverviewTablePane
   253                              view={this.state.view}
   254                              isSocketConnected={isSocketConnected}
   255                            />
   256                          )}
   257                        />
   258                      </Switch>
   259                    </ResourceSelectionProvider>
   260                  </ResourceListOptionsProvider>
   261                </ResourceGroupsContextProvider>
   262              </LogStoreProvider>
   263            </SnapshotActionProvider>
   264          </PathBuilderProvider>
   265        </FeaturesProvider>
   266      )
   267    }
   268  
   269    renderShareSnapshotModal(view: Proto.webviewView | null) {
   270      let handleClose = () => this.setState({ showSnapshotModal: false })
   271      return (
   272        <ShareSnapshotModal
   273          getSnapshot={() => this.snapshotFromState(this.state)}
   274          handleClose={handleClose}
   275          isOpen={this.state.showSnapshotModal}
   276          dialogAnchor={this.state.snapshotDialogAnchor}
   277        />
   278      )
   279    }
   280  
   281    renderFatalErrorModal(view: Proto.webviewView | null) {
   282      let session = view?.uiSession?.status
   283      let error = session?.fatalError
   284      let handleClose = () =>
   285        this.setState({ showFatalErrorModal: ShowFatalErrorModal.Hide })
   286      return (
   287        <FatalErrorModal
   288          error={error}
   289          showFatalErrorModal={this.state.showFatalErrorModal}
   290          handleClose={handleClose}
   291        />
   292      )
   293    }
   294  
   295    renderErrorModal() {
   296      return (
   297        <ErrorModal
   298          error={this.state.error}
   299          showErrorModal={this.state.showErrorModal}
   300          handleClose={() =>
   301            this.setState({
   302              showErrorModal: ShowErrorModal.Default,
   303              error: undefined,
   304            })
   305          }
   306        />
   307      )
   308    }
   309  
   310    setError(error: string) {
   311      this.setState({ error: error })
   312    }
   313  }
   314  
   315  export function HUDFromContext(props: React.PropsWithChildren<{}>) {
   316    let history = useHistory()
   317    let interfaceVersion = useInterfaceVersion()
   318    return (
   319      /* allow Styled Components to override MUI - https://material-ui.com/guides/interoperability/#controlling-priority-3*/
   320      <StylesProvider injectFirst>
   321        <HUD history={history} interfaceVersion={interfaceVersion} />
   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  }