github.com/cockroachdb/cockroach@v20.2.0-alpha.1+incompatible/pkg/ui/src/redux/alerts.ts (about)

     1  // Copyright 2018 The Cockroach Authors.
     2  //
     3  // Use of this software is governed by the Business Source License
     4  // included in the file licenses/BSL.txt.
     5  //
     6  // As of the Change Date specified in that file, in accordance with
     7  // the Business Source License, use of this software will be governed
     8  // by the Apache License, Version 2.0, included in the file
     9  // licenses/APL.txt.
    10  
    11  /**
    12   * Alerts is a collection of selectors which determine if there are any Alerts
    13   * to display based on the current redux state.
    14   */
    15  
    16  import _ from "lodash";
    17  import moment from "moment";
    18  import { createSelector } from "reselect";
    19  import { Store, Dispatch, Action } from "redux";
    20  import { ThunkAction } from "redux-thunk";
    21  
    22  import { LocalSetting } from "./localsettings";
    23  import {
    24    VERSION_DISMISSED_KEY, INSTRUCTIONS_BOX_COLLAPSED_KEY,
    25    saveUIData, loadUIData, isInFlight, UIDataState, UIDataStatus,
    26  } from "./uiData";
    27  import { refreshCluster, refreshNodes, refreshVersion, refreshHealth } from "./apiReducers";
    28  import { singleVersionSelector, versionsSelector } from "src/redux/nodes";
    29  import { AdminUIState } from "./state";
    30  import * as docsURL from "src/util/docs";
    31  
    32  export enum AlertLevel {
    33    NOTIFICATION,
    34    WARNING,
    35    CRITICAL,
    36    SUCCESS,
    37  }
    38  
    39  export interface AlertInfo {
    40    // Alert Level, which determines visual qualities such as icon and coloring.
    41    level: AlertLevel;
    42    // Title to display with the alert.
    43    title: string;
    44    // The text of this alert.
    45    text?: string;
    46    // Optional hypertext link to be followed when clicking alert.
    47    link?: string;
    48  }
    49  
    50  export interface Alert extends AlertInfo {
    51    // ThunkAction which will result in this alert being dismissed. This
    52    // function will be dispatched to the redux store when the alert is dismissed.
    53    dismiss: ThunkAction<Promise<void>, AdminUIState, void>;
    54    // Makes alert to be positioned in the top right corner of the screen instead of
    55    // stretching to full width.
    56    showAsAlert?: boolean;
    57    autoClose?: boolean;
    58    closable?: boolean;
    59    autoCloseTimeout?: number;
    60  }
    61  
    62  const localSettingsSelector = (state: AdminUIState) => state.localSettings;
    63  
    64  // Clusterviz Instruction Box collapsed
    65  
    66  export const instructionsBoxCollapsedSetting = new LocalSetting(
    67    INSTRUCTIONS_BOX_COLLAPSED_KEY, localSettingsSelector, false,
    68  );
    69  
    70  const instructionsBoxCollapsedPersistentLoadedSelector = createSelector(
    71    (state: AdminUIState) => state.uiData,
    72    (uiData): boolean => (
    73      uiData
    74        && _.has(uiData, INSTRUCTIONS_BOX_COLLAPSED_KEY)
    75        && uiData[INSTRUCTIONS_BOX_COLLAPSED_KEY].status === UIDataStatus.VALID
    76    ),
    77  );
    78  
    79  const instructionsBoxCollapsedPersistentSelector = createSelector(
    80    (state: AdminUIState) => state.uiData,
    81    (uiData): boolean => (
    82      uiData
    83        && _.has(uiData, INSTRUCTIONS_BOX_COLLAPSED_KEY)
    84        && uiData[INSTRUCTIONS_BOX_COLLAPSED_KEY].status === UIDataStatus.VALID
    85        && uiData[INSTRUCTIONS_BOX_COLLAPSED_KEY].data
    86    ),
    87  );
    88  
    89  export const instructionsBoxCollapsedSelector = createSelector(
    90    instructionsBoxCollapsedPersistentLoadedSelector,
    91    instructionsBoxCollapsedPersistentSelector,
    92    instructionsBoxCollapsedSetting.selector,
    93    (persistentLoaded, persistentCollapsed, localSettingCollapsed): boolean => {
    94      if (persistentLoaded) {
    95        return persistentCollapsed;
    96      }
    97      return localSettingCollapsed;
    98    },
    99  );
   100  
   101  export function setInstructionsBoxCollapsed(collapsed: boolean) {
   102    return (dispatch: Dispatch<Action, AdminUIState>) => {
   103      dispatch(instructionsBoxCollapsedSetting.set(collapsed));
   104      dispatch(saveUIData({
   105        key: INSTRUCTIONS_BOX_COLLAPSED_KEY,
   106        value: collapsed,
   107      }));
   108    };
   109  }
   110  
   111  ////////////////////////////////////////
   112  // Version mismatch.
   113  ////////////////////////////////////////
   114  export const staggeredVersionDismissedSetting = new LocalSetting(
   115    "staggered_version_dismissed", localSettingsSelector, false,
   116  );
   117  
   118  /**
   119   * Warning when multiple versions of CockroachDB are detected on the cluster.
   120   * This excludes decommissioned nodes.
   121   */
   122  export const staggeredVersionWarningSelector = createSelector(
   123    versionsSelector,
   124    staggeredVersionDismissedSetting.selector,
   125    (versions, versionMismatchDismissed): Alert => {
   126      if (versionMismatchDismissed) {
   127        return undefined;
   128      }
   129  
   130      if (!versions || versions.length <= 1) {
   131        return undefined;
   132      }
   133  
   134      return {
   135        level: AlertLevel.WARNING,
   136        title: "Staggered Version",
   137        text: `We have detected that multiple versions of CockroachDB are running
   138        in this cluster. This may be part of a normal rolling upgrade process, but
   139        should be investigated if this is unexpected.`,
   140        dismiss: (dispatch: Dispatch<Action, AdminUIState>) => {
   141          dispatch(staggeredVersionDismissedSetting.set(true));
   142          return Promise.resolve();
   143        },
   144      };
   145    });
   146  
   147  // A boolean that indicates whether the server has yet been checked for a
   148  // persistent dismissal of this notification.
   149  // TODO(mrtracy): Refactor so that we can distinguish "never loaded" from
   150  // "loaded, doesn't exist on server" without a separate selector.
   151  const newVersionDismissedPersistentLoadedSelector = createSelector(
   152    (state: AdminUIState) => state.uiData,
   153    (uiData) => uiData && _.has(uiData, VERSION_DISMISSED_KEY),
   154  );
   155  
   156  const newVersionDismissedPersistentSelector = createSelector(
   157    (state: AdminUIState) => state.uiData,
   158    (uiData) => {
   159      return (uiData
   160              && uiData[VERSION_DISMISSED_KEY]
   161              && uiData[VERSION_DISMISSED_KEY].data
   162              && moment(uiData[VERSION_DISMISSED_KEY].data)
   163              ) || moment(0);
   164    },
   165  );
   166  
   167  export const newVersionDismissedLocalSetting = new LocalSetting(
   168    "new_version_dismissed", localSettingsSelector, moment(0),
   169  );
   170  
   171  export const newerVersionsSelector = (state: AdminUIState) => state.cachedData.version.valid ? state.cachedData.version.data : null;
   172  
   173  /**
   174   * Notification when a new version of CockroachDB is available.
   175   */
   176  export const newVersionNotificationSelector = createSelector(
   177    newerVersionsSelector,
   178    newVersionDismissedPersistentLoadedSelector,
   179    newVersionDismissedPersistentSelector,
   180    newVersionDismissedLocalSetting.selector,
   181    (newerVersions, newVersionDismissedPersistentLoaded, newVersionDismissedPersistent, newVersionDismissedLocal): Alert => {
   182      // Check if there are new versions available.
   183      if (!newerVersions || !newerVersions.details || newerVersions.details.length === 0) {
   184        return undefined;
   185      }
   186  
   187      // Check local dismissal. Local dismissal is valid for one day.
   188      const yesterday = moment().subtract(1, "day");
   189      if (newVersionDismissedLocal.isAfter && newVersionDismissedLocal.isAfter(yesterday)) {
   190        return undefined;
   191      }
   192  
   193      // Check persistent dismissal, also valid for one day.
   194      if (!newVersionDismissedPersistentLoaded
   195          || !newVersionDismissedPersistent
   196          || newVersionDismissedPersistent.isAfter(yesterday)) {
   197        return undefined;
   198      }
   199  
   200      return {
   201        level: AlertLevel.NOTIFICATION,
   202        title: "New Version Available",
   203        text: "A new version of CockroachDB is available.",
   204        link: docsURL.upgradeCockroachVersion,
   205        dismiss: (dispatch: any) => {
   206          const dismissedAt = moment();
   207          // Dismiss locally.
   208          dispatch(newVersionDismissedLocalSetting.set(dismissedAt));
   209          // Dismiss persistently.
   210          return dispatch(saveUIData({
   211            key: VERSION_DISMISSED_KEY,
   212            value: dismissedAt.valueOf(),
   213          }));
   214        },
   215      };
   216    });
   217  
   218  export const disconnectedDismissedLocalSetting = new LocalSetting(
   219    "disconnected_dismissed", localSettingsSelector, moment(0),
   220  );
   221  
   222  /**
   223   * Notification when the Admin UI is disconnected from the cluster.
   224   */
   225  export const disconnectedAlertSelector = createSelector(
   226    (state: AdminUIState) => state.cachedData.health,
   227    disconnectedDismissedLocalSetting.selector,
   228    (health, disconnectedDismissed): Alert => {
   229      if (!health || !health.lastError) {
   230        return undefined;
   231      }
   232  
   233      // Allow local dismissal for one minute.
   234      const dismissedMaxTime = moment().subtract(1, "m");
   235      if (disconnectedDismissed.isAfter(dismissedMaxTime)) {
   236        return undefined;
   237      }
   238  
   239      return {
   240        level: AlertLevel.CRITICAL,
   241        title: "We're currently having some trouble fetching updated data. If this persists, it might be a good idea to check your network connection to the CockroachDB cluster.",
   242        dismiss: (dispatch: Dispatch<Action, AdminUIState>) => {
   243          dispatch(disconnectedDismissedLocalSetting.set(moment()));
   244          return Promise.resolve();
   245        },
   246      };
   247    },
   248  );
   249  
   250  export const emailSubscriptionAlertLocalSetting = new LocalSetting(
   251    "email_subscription_alert", localSettingsSelector, false,
   252  );
   253  
   254  export const emailSubscriptionAlertSelector = createSelector(
   255    emailSubscriptionAlertLocalSetting.selector,
   256    ( emailSubscriptionAlert): Alert => {
   257      if (!emailSubscriptionAlert) {
   258        return undefined;
   259      }
   260      return {
   261        level: AlertLevel.SUCCESS,
   262        title: "You successfully signed up for CockroachDB release notes",
   263        showAsAlert: true,
   264        autoClose: true,
   265        closable: false,
   266        dismiss: (dispatch: Dispatch<Action, AdminUIState>) => {
   267          dispatch(emailSubscriptionAlertLocalSetting.set(false));
   268          return Promise.resolve();
   269        },
   270      };
   271    },
   272  );
   273  
   274  type CreateStatementDiagnosticsAlertPayload = {
   275    show: boolean;
   276    status?: "SUCCESS" | "FAILED";
   277  };
   278  
   279  export const createStatementDiagnosticsAlertLocalSetting = new LocalSetting<AdminUIState, CreateStatementDiagnosticsAlertPayload>(
   280    "create_stmnt_diagnostics_alert", localSettingsSelector, { show: false },
   281  );
   282  
   283  export const createStatementDiagnosticsAlertSelector = createSelector(
   284    createStatementDiagnosticsAlertLocalSetting.selector,
   285    ( createStatementDiagnosticsAlert): Alert => {
   286      if (!createStatementDiagnosticsAlert || !createStatementDiagnosticsAlert.show) {
   287        return undefined;
   288      }
   289      const { status } = createStatementDiagnosticsAlert;
   290  
   291      if (status === "FAILED") {
   292        return {
   293          level: AlertLevel.CRITICAL,
   294          title: "There was an error activating statement diagnostics",
   295          text: "Please try activating again. If the problem continues please reach out to customer support.",
   296          showAsAlert: true,
   297          dismiss: (dispatch: Dispatch<Action, AdminUIState>) => {
   298            dispatch(createStatementDiagnosticsAlertLocalSetting.set({ show: false }));
   299            return Promise.resolve();
   300          },
   301        };
   302      }
   303      return {
   304        level: AlertLevel.SUCCESS,
   305        title: "Statement diagnostics were successfully activated",
   306        showAsAlert: true,
   307        autoClose: true,
   308        closable: false,
   309        dismiss: (dispatch: Dispatch<Action, AdminUIState>) => {
   310          dispatch(createStatementDiagnosticsAlertLocalSetting.set({ show: false }));
   311          return Promise.resolve();
   312        },
   313      };
   314    },
   315  );
   316  
   317  /**
   318   * Selector which returns an array of all active alerts which should be
   319   * displayed in the alerts panel, which is embedded within the cluster overview
   320   * page; currently, this includes all non-critical alerts.
   321   */
   322  export const panelAlertsSelector = createSelector(
   323    newVersionNotificationSelector,
   324    staggeredVersionWarningSelector,
   325    (...alerts: Alert[]): Alert[] => {
   326      return _.without(alerts, null, undefined);
   327    },
   328  );
   329  
   330  /**
   331   * Selector which returns an array of all active alerts which should be
   332   * displayed as a banner, which appears at the top of the page and overlaps
   333   * content in recognition of the severity of the alert; currently, this includes
   334   * all critical-level alerts.
   335   */
   336  export const bannerAlertsSelector = createSelector(
   337    disconnectedAlertSelector,
   338    emailSubscriptionAlertSelector,
   339    createStatementDiagnosticsAlertSelector,
   340    (...alerts: Alert[]): Alert[] => {
   341      return _.without(alerts, null, undefined);
   342    },
   343  );
   344  
   345  /**
   346   * This function, when supplied with a redux store, generates a callback that
   347   * attempts to populate missing information that has not yet been loaded from
   348   * the cluster that is needed to show certain alerts. This returned function is
   349   * intended to be attached to the store as a subscriber.
   350   */
   351  export function alertDataSync(store: Store<AdminUIState>) {
   352    const dispatch = store.dispatch;
   353  
   354    // Memoizers to prevent unnecessary dispatches of alertDataSync if store
   355    // hasn't changed in an interesting way.
   356    let lastUIData: UIDataState;
   357  
   358    return () => {
   359      const state: AdminUIState = store.getState();
   360  
   361      // Always refresh health.
   362      dispatch(refreshHealth());
   363  
   364      // Load persistent settings which have not yet been loaded.
   365      const uiData = state.uiData;
   366      if (uiData !== lastUIData) {
   367        lastUIData = uiData;
   368        const keysToMaybeLoad = [VERSION_DISMISSED_KEY, INSTRUCTIONS_BOX_COLLAPSED_KEY];
   369        const keysToLoad = _.filter(keysToMaybeLoad, (key) => {
   370          return !(_.has(uiData, key) || isInFlight(state, key));
   371        });
   372        if (keysToLoad) {
   373          dispatch(loadUIData(...keysToLoad));
   374        }
   375      }
   376  
   377      // Load Cluster ID once at startup.
   378      const cluster = state.cachedData.cluster;
   379      if (cluster && !cluster.data && !cluster.inFlight) {
   380        dispatch(refreshCluster());
   381      }
   382  
   383      // Load Nodes initially if it has not yet been loaded.
   384      const nodes = state.cachedData.nodes;
   385      if (nodes && !nodes.data && !nodes.inFlight) {
   386        dispatch(refreshNodes());
   387      }
   388  
   389      // Load potential new versions from CockroachDB cluster. This is the
   390      // complicating factor of this function, since the call requires the cluster
   391      // ID and node statuses being loaded first and thus cannot simply run at
   392      // startup.
   393      const currentVersion = singleVersionSelector(state);
   394      if (_.isNil(newerVersionsSelector(state))) {
   395        if (cluster.data && cluster.data.cluster_id && currentVersion) {
   396          dispatch(refreshVersion({
   397            clusterID: cluster.data.cluster_id,
   398            buildtag: currentVersion,
   399          }));
   400        }
   401      }
   402    };
   403  }