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 }