github.com/turbot/steampipe@v1.7.0-rc.0.0.20240517123944-7cef272d4458/ui/dashboard/src/hooks/useDashboard.tsx (about) 1 import get from "lodash/get"; 2 import isEqual from "lodash/isEqual"; 3 import useDashboardState from "./useDashboardState"; 4 import useDashboardWebSocket, { SocketActions } from "./useDashboardWebSocket"; 5 import useDashboardWebSocketEventHandler from "./useDashboardWebSocketEventHandler"; 6 import usePrevious from "./usePrevious"; 7 import { 8 DashboardActions, 9 DashboardDataModeCLISnapshot, 10 DashboardDataModeCloudSnapshot, 11 DashboardDataModeLive, 12 DashboardDataOptions, 13 DashboardRenderOptions, 14 IDashboardContext, 15 SelectedDashboardStates, 16 SocketURLFactory, 17 } from "../types"; 18 import { buildComponentsMap } from "../components"; 19 import { buildSelectedDashboardInputsFromSearchParams } from "../utils/state"; 20 import { 21 createContext, 22 useCallback, 23 useContext, 24 useEffect, 25 useState, 26 } from "react"; 27 import { GlobalHotKeys } from "react-hotkeys"; 28 import { noop } from "../utils/func"; 29 import { 30 useLocation, 31 useNavigate, 32 useNavigationType, 33 useParams, 34 useSearchParams, 35 } from "react-router-dom"; 36 37 const DashboardContext = createContext<IDashboardContext | null>(null); 38 39 type DashboardProviderProps = { 40 analyticsContext: any; 41 breakpointContext: any; 42 children: null | JSX.Element | JSX.Element[]; 43 componentOverrides?: {}; 44 dataOptions?: DashboardDataOptions; 45 eventHooks?: {}; 46 featureFlags?: string[]; 47 renderOptions?: DashboardRenderOptions; 48 socketUrlFactory?: SocketURLFactory; 49 stateDefaults?: {}; 50 themeContext: any; 51 versionMismatchCheck?: boolean; 52 }; 53 54 const DashboardProvider = ({ 55 analyticsContext, 56 breakpointContext, 57 children, 58 componentOverrides = {}, 59 dataOptions = { 60 dataMode: DashboardDataModeLive, 61 }, 62 eventHooks, 63 featureFlags = [], 64 renderOptions = { 65 headless: false, 66 }, 67 socketUrlFactory, 68 stateDefaults = {}, 69 versionMismatchCheck = false, 70 themeContext, 71 }: DashboardProviderProps) => { 72 const components = buildComponentsMap(componentOverrides); 73 const navigate = useNavigate(); 74 const [searchParams, setSearchParams] = useSearchParams(); 75 const [state, dispatch] = useDashboardState({ 76 dataOptions, 77 renderOptions, 78 searchParams, 79 stateDefaults, 80 versionMismatchCheck, 81 }); 82 const { dashboard_name } = useParams(); 83 const { eventHandler } = useDashboardWebSocketEventHandler( 84 dispatch, 85 eventHooks 86 ); 87 const { ready: socketReady, send: sendSocketMessage } = useDashboardWebSocket( 88 state.dataMode, 89 dispatch, 90 eventHandler, 91 socketUrlFactory 92 ); 93 const { 94 setMetadata: setAnalyticsMetadata, 95 setSelectedDashboard: setAnalyticsSelectedDashboard, 96 } = analyticsContext; 97 98 const location = useLocation(); 99 const navigationType = useNavigationType(); 100 101 // Keep track of the previous selected dashboard and inputs 102 const previousSelectedDashboardStates: SelectedDashboardStates | undefined = 103 usePrevious<SelectedDashboardStates>({ 104 dashboard_name, 105 dataMode: state.dataMode, 106 refetchDashboard: state.refetchDashboard, 107 search: state.search, 108 searchParams, 109 selectedDashboard: state.selectedDashboard, 110 selectedDashboardInputs: state.selectedDashboardInputs, 111 }); 112 113 // Alert analytics 114 useEffect(() => { 115 setAnalyticsMetadata(state.metadata); 116 }, [state.metadata, setAnalyticsMetadata]); 117 118 useEffect(() => { 119 setAnalyticsSelectedDashboard(state.selectedDashboard); 120 }, [state.selectedDashboard, setAnalyticsSelectedDashboard]); 121 122 useEffect(() => { 123 if ( 124 !!dashboard_name && 125 !location.pathname.startsWith("/snapshot/") && 126 state.dataMode === DashboardDataModeCLISnapshot 127 ) { 128 dispatch({ 129 type: DashboardActions.SET_DATA_MODE, 130 dataMode: DashboardDataModeLive, 131 }); 132 } 133 }, [dashboard_name, dispatch, location, navigate, state.dataMode]); 134 135 // Ensure that on history pop / push we sync the new values into state 136 useEffect(() => { 137 if (navigationType !== "POP" && navigationType !== "PUSH") { 138 return; 139 } 140 if (location.key === "default") { 141 return; 142 } 143 if (state.dataMode !== DashboardDataModeLive) { 144 return; 145 } 146 147 // If we've just popped or pushed from one dashboard to another, then we don't want to add the search to the URL 148 // as that will show the dashboard list, but we want to see the dashboard that we came from / went to previously. 149 const goneFromDashboardToDashboard = 150 previousSelectedDashboardStates?.dashboard_name && 151 dashboard_name && 152 previousSelectedDashboardStates.dashboard_name !== dashboard_name; 153 154 const search = searchParams.get("search") || ""; 155 const groupBy = 156 searchParams.get("group_by") || 157 get(stateDefaults, "search.groupBy.value", "tag"); 158 const tag = 159 searchParams.get("tag") || 160 get(stateDefaults, "search.groupBy.tag", "service"); 161 const inputs = buildSelectedDashboardInputsFromSearchParams(searchParams); 162 dispatch({ 163 type: DashboardActions.SET_DASHBOARD_SEARCH_VALUE, 164 value: goneFromDashboardToDashboard ? "" : search, 165 }); 166 dispatch({ 167 type: DashboardActions.SET_DASHBOARD_SEARCH_GROUP_BY, 168 value: groupBy, 169 tag, 170 }); 171 if ( 172 JSON.stringify( 173 previousSelectedDashboardStates?.selectedDashboardInputs 174 ) !== JSON.stringify(inputs) 175 ) { 176 dispatch({ 177 type: DashboardActions.SET_DASHBOARD_INPUTS, 178 value: inputs, 179 recordInputsHistory: false, 180 }); 181 } 182 }, [ 183 dashboard_name, 184 dispatch, 185 featureFlags, 186 location, 187 navigationType, 188 previousSelectedDashboardStates, 189 searchParams, 190 stateDefaults, 191 state.dataMode, 192 ]); 193 194 useEffect(() => { 195 // If no search params have changed 196 if ( 197 state.dataMode === DashboardDataModeCloudSnapshot || 198 state.dataMode === DashboardDataModeCLISnapshot || 199 (previousSelectedDashboardStates && 200 previousSelectedDashboardStates?.dashboard_name === dashboard_name && 201 previousSelectedDashboardStates.dataMode === state.dataMode && 202 previousSelectedDashboardStates.search.value === state.search.value && 203 previousSelectedDashboardStates.search.groupBy.value === 204 state.search.groupBy.value && 205 previousSelectedDashboardStates.search.groupBy.tag === 206 state.search.groupBy.tag && 207 previousSelectedDashboardStates.searchParams.toString() === 208 searchParams.toString()) 209 ) { 210 return; 211 } 212 213 const { 214 value: searchValue, 215 groupBy: { value: groupByValue, tag }, 216 } = state.search; 217 218 if (dashboard_name) { 219 // Only set group_by and tag if we have a search 220 if (searchValue) { 221 searchParams.set("search", searchValue); 222 searchParams.set("group_by", groupByValue); 223 224 if (groupByValue === "mod") { 225 searchParams.delete("tag"); 226 } else if (groupByValue === "tag") { 227 searchParams.set("tag", tag); 228 } else { 229 searchParams.delete("group_by"); 230 searchParams.delete("tag"); 231 } 232 } else { 233 searchParams.delete("search"); 234 searchParams.delete("group_by"); 235 searchParams.delete("tag"); 236 } 237 } else { 238 if (searchValue) { 239 searchParams.set("search", searchValue); 240 } else { 241 searchParams.delete("search"); 242 } 243 244 searchParams.set("group_by", groupByValue); 245 246 if (groupByValue === "mod") { 247 searchParams.delete("tag"); 248 } else if (groupByValue === "tag") { 249 searchParams.set("tag", tag); 250 } else { 251 searchParams.delete("group_by"); 252 searchParams.delete("tag"); 253 } 254 } 255 256 setSearchParams(searchParams, { replace: true }); 257 }, [ 258 dashboard_name, 259 featureFlags, 260 previousSelectedDashboardStates, 261 searchParams, 262 setSearchParams, 263 state.dataMode, 264 state.search, 265 ]); 266 267 useEffect(() => { 268 // If we've got no dashboard selected in the URL, but we've got one selected in state, 269 // then clear both the inputs and the selected dashboard in state 270 if (!dashboard_name && state.selectedDashboard) { 271 dispatch({ 272 type: DashboardActions.CLEAR_DASHBOARD_INPUTS, 273 recordInputsHistory: false, 274 }); 275 dispatch({ 276 type: DashboardActions.SELECT_DASHBOARD, 277 dashboard: null, 278 recordInputsHistory: false, 279 }); 280 return; 281 } 282 // Else if we've got a dashboard selected in the URL and don't have one selected in state, 283 // select that dashboard 284 if ( 285 dashboard_name && 286 !state.selectedDashboard && 287 state.dataMode === DashboardDataModeLive 288 ) { 289 const dashboard = state.dashboards.find( 290 (dashboard) => dashboard.full_name === dashboard_name 291 ); 292 dispatch({ 293 type: DashboardActions.SELECT_DASHBOARD, 294 dashboard, 295 }); 296 return; 297 } 298 // Else if we've changed to a different report in the URL then clear the inputs and select the 299 // dashboard in state 300 if ( 301 dashboard_name && 302 state.selectedDashboard && 303 dashboard_name !== state.selectedDashboard.full_name 304 ) { 305 const dashboard = state.dashboards.find( 306 (dashboard) => dashboard.full_name === dashboard_name 307 ); 308 dispatch({ type: DashboardActions.SELECT_DASHBOARD, dashboard }); 309 const value = buildSelectedDashboardInputsFromSearchParams(searchParams); 310 dispatch({ 311 type: DashboardActions.SET_DASHBOARD_INPUTS, 312 value, 313 recordInputsHistory: false, 314 }); 315 } 316 }, [ 317 dashboard_name, 318 dispatch, 319 searchParams, 320 state.dashboards, 321 state.dataMode, 322 state.selectedDashboard, 323 ]); 324 325 useEffect(() => { 326 if ( 327 !dashboard_name && 328 state.snapshot && 329 state.dataMode === DashboardDataModeCLISnapshot 330 ) { 331 dispatch({ 332 type: DashboardActions.SELECT_DASHBOARD, 333 dashboard: null, 334 dataMode: DashboardDataModeLive, 335 }); 336 } 337 }, [dashboard_name, dispatch, state.dataMode, state.snapshot]); 338 339 useEffect(() => { 340 // This effect will send events over websockets and depends on there being a dashboard selected 341 if (!socketReady || !state.selectedDashboard) { 342 return; 343 } 344 345 // If we didn't previously have a dashboard selected in state (e.g. you've gone from home page 346 // to a report, or it's first load), or the selected dashboard has been changed, select that 347 // report over the socket 348 if ( 349 (state.dataMode === DashboardDataModeLive || 350 state.dataMode === DashboardDataModeCLISnapshot) && 351 (!previousSelectedDashboardStates || 352 !previousSelectedDashboardStates.selectedDashboard || 353 state.selectedDashboard.full_name !== 354 previousSelectedDashboardStates.selectedDashboard.full_name || 355 (!previousSelectedDashboardStates.refetchDashboard && 356 state.refetchDashboard)) 357 ) { 358 sendSocketMessage({ 359 action: SocketActions.CLEAR_DASHBOARD, 360 }); 361 sendSocketMessage({ 362 action: 363 state.selectedDashboard.type === "snapshot" 364 ? SocketActions.SELECT_SNAPSHOT 365 : SocketActions.SELECT_DASHBOARD, 366 payload: { 367 dashboard: { 368 full_name: state.selectedDashboard.full_name, 369 }, 370 input_values: state.selectedDashboardInputs, 371 }, 372 }); 373 return; 374 } 375 // Else if we did previously have a dashboard selected in state and the 376 // inputs have changed, then update the inputs over the socket 377 if ( 378 state.dataMode === DashboardDataModeLive && 379 previousSelectedDashboardStates && 380 previousSelectedDashboardStates.selectedDashboard && 381 !isEqual( 382 previousSelectedDashboardStates.selectedDashboardInputs, 383 state.selectedDashboardInputs 384 ) 385 ) { 386 sendSocketMessage({ 387 action: SocketActions.INPUT_CHANGED, 388 payload: { 389 dashboard: { 390 full_name: state.selectedDashboard.full_name, 391 }, 392 changed_input: state.lastChangedInput, 393 input_values: state.selectedDashboardInputs, 394 }, 395 }); 396 } 397 }, [ 398 previousSelectedDashboardStates, 399 sendSocketMessage, 400 socketReady, 401 state.selectedDashboard, 402 state.selectedDashboardInputs, 403 state.lastChangedInput, 404 state.dataMode, 405 state.refetchDashboard, 406 ]); 407 408 useEffect(() => { 409 // This effect will send events over websockets and depends on there being no dashboard selected 410 if (!socketReady || state.selectedDashboard) { 411 return; 412 } 413 414 // If we've gone from having a report selected, to having nothing selected, clear the dashboard state 415 if ( 416 previousSelectedDashboardStates && 417 previousSelectedDashboardStates.selectedDashboard 418 ) { 419 sendSocketMessage({ 420 action: SocketActions.CLEAR_DASHBOARD, 421 }); 422 } 423 }, [ 424 previousSelectedDashboardStates, 425 sendSocketMessage, 426 socketReady, 427 state.selectedDashboard, 428 ]); 429 430 useEffect(() => { 431 // Don't do anything as this is handled elsewhere 432 if (navigationType === "POP" || navigationType === "PUSH") { 433 return; 434 } 435 436 if (!previousSelectedDashboardStates) { 437 return; 438 } 439 440 if ( 441 isEqual( 442 state.selectedDashboardInputs, 443 previousSelectedDashboardStates.selectedDashboardInputs 444 ) 445 ) { 446 return; 447 } 448 449 // Only record history when it's the same report before and after and the inputs have changed 450 const shouldRecordHistory = 451 state.recordInputsHistory && 452 !!previousSelectedDashboardStates.selectedDashboard && 453 !!state.selectedDashboard && 454 previousSelectedDashboardStates.selectedDashboard.full_name === 455 state.selectedDashboard.full_name; 456 457 // Sync params into the URL 458 const newParams = { 459 ...state.selectedDashboardInputs, 460 }; 461 setSearchParams(newParams, { 462 replace: !shouldRecordHistory, 463 }); 464 }, [ 465 featureFlags, 466 navigationType, 467 previousSelectedDashboardStates, 468 setSearchParams, 469 state.dataMode, 470 state.recordInputsHistory, 471 state.selectedDashboard, 472 state.selectedDashboardInputs, 473 ]); 474 475 useEffect(() => { 476 if ( 477 !state.availableDashboardsLoaded || 478 !dashboard_name || 479 state.dataMode === DashboardDataModeCLISnapshot 480 ) { 481 return; 482 } 483 484 // If the dashboard we're viewing no longer exists, go back to the main page 485 if (!state.dashboards.find((r) => r.full_name === dashboard_name)) { 486 navigate("../", { replace: true }); 487 } 488 }, [ 489 navigate, 490 dashboard_name, 491 state.availableDashboardsLoaded, 492 state.dashboards, 493 state.dataMode, 494 ]); 495 496 useEffect(() => { 497 if ( 498 location.pathname.startsWith("/snapshot/") && 499 state.dataMode !== DashboardDataModeCLISnapshot 500 ) { 501 navigate("/"); 502 } 503 }, [location, navigate, state.dataMode]); 504 505 useEffect(() => { 506 if (!state.selectedDashboard) { 507 document.title = "Dashboards | Steampipe"; 508 } else { 509 document.title = `${ 510 state.selectedDashboard.title || state.selectedDashboard.full_name 511 } | Dashboards | Steampipe`; 512 } 513 }, [state.selectedDashboard]); 514 515 const [hotKeysHandlers, setHotKeysHandlers] = useState({ 516 CLOSE_PANEL_DETAIL: noop, 517 }); 518 519 const hotKeysMap = { 520 CLOSE_PANEL_DETAIL: ["esc"], 521 }; 522 523 const closePanelDetail = useCallback(() => { 524 dispatch({ 525 type: DashboardActions.SELECT_PANEL, 526 panel: null, 527 }); 528 }, [dispatch]); 529 530 useEffect(() => { 531 setHotKeysHandlers({ 532 CLOSE_PANEL_DETAIL: closePanelDetail, 533 }); 534 }, [closePanelDetail]); 535 536 const [renderSnapshotCompleteDiv, setRenderSnapshotCompleteDiv] = 537 useState(false); 538 539 useEffect(() => { 540 if ( 541 (dataOptions?.dataMode !== DashboardDataModeCLISnapshot && 542 dataOptions?.dataMode !== DashboardDataModeCloudSnapshot) || 543 state.state !== "complete" 544 ) { 545 return; 546 } 547 setRenderSnapshotCompleteDiv(true); 548 }, [dataOptions?.dataMode, state.state]); 549 550 return ( 551 <DashboardContext.Provider 552 value={{ 553 ...state, 554 analyticsContext, 555 breakpointContext, 556 components, 557 dispatch, 558 closePanelDetail, 559 themeContext, 560 render: { 561 headless: renderOptions?.headless, 562 snapshotCompleteDiv: renderSnapshotCompleteDiv, 563 }, 564 }} 565 > 566 <GlobalHotKeys 567 allowChanges 568 keyMap={hotKeysMap} 569 handlers={hotKeysHandlers} 570 /> 571 {children} 572 </DashboardContext.Provider> 573 ); 574 }; 575 576 const useDashboard = () => { 577 const context = useContext(DashboardContext); 578 if (context === undefined) { 579 throw new Error("useDashboard must be used within a DashboardContext"); 580 } 581 return context as IDashboardContext; 582 }; 583 584 export { DashboardContext, DashboardProvider, useDashboard };