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 }