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 };