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 }