import { css, cx } from '@emotion/css'; import uFuzzy from '@leeoniya/ufuzzy'; import { createSelector } from '@reduxjs/toolkit'; import { debounce } from 'lodash'; import pluralize from 'pluralize'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useLocation } from 'react-router-dom'; import { useLocalStorage } from 'react-use'; import { GrafanaTheme2, UrlQueryMap } from '@grafana/data'; import { Stack } from '@grafana/experimental'; import { locationService } from '@grafana/runtime'; import { Alert, Badge, Button, ConfirmModal, FilterInput, HorizontalGroup, Icon, Link, LoadingPlaceholder, Spinner, Tab, TabContent, TabsBar, TagList, Text, TextLink, Tooltip, useStyles2, } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { getSearchPlaceholder } from '../search/tempI18nPhrases'; import { AlertPair, ContactPair, DashboardUpgrade, OrgMigrationState, upgradeApi } from './unified/api/upgradeApi'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from './unified/components/DynamicTable'; import { DynamicTableWithGuidelines } from './unified/components/DynamicTableWithGuidelines'; import { Matchers } from './unified/components/notification-policies/Matchers'; import { ActionIcon } from './unified/components/rules/ActionIcon'; import { createContactPointLink, makeDashboardLink, makeFolderAlertsLink } from './unified/utils/misc'; import { createUrl } from './unified/utils/url'; export const UpgradePage = () => { const [, { isLoading: isUpgradeLoading }] = upgradeApi.useUpgradeOrgMutation({ fixedCacheKey: 'upgrade-org-loading', }); const [, { isLoading: isCancelLoading }] = upgradeApi.useCancelOrgUpgradeMutation({ fixedCacheKey: 'cancel-org-upgrade-loading', }); const { currentData: summary, isError: isFetchError, error: fetchError, isLoading: isLoading, } = upgradeApi.useGetOrgUpgradeSummaryQuery(undefined, { pollingInterval: 10000, skip: isCancelLoading || isUpgradeLoading, // Stop polling when upgrade or cancel is in progress. }); const alertCount = (summary?.migratedDashboards ?? []).reduce( (acc, cur) => acc + (cur?.migratedAlerts?.length ?? 0), 0 ); const contactCount = summary?.migratedChannels?.length ?? 0; const errors = summary?.errors ?? []; const hasData = alertCount > 0 || contactCount > 0 || errors.length > 0; const cancelUpgrade = useMemo(() => { if (!isFetchError && hasData) { return ; } return null; }, [isFetchError, hasData]); const showError = isFetchError; const showLoading = isLoading; const showData = !isLoading && !isFetchError && hasData; return ( {showError && ( {fetchError instanceof Error ? fetchError.message : 'Unknown error.'} )} {showLoading && } {showData && ( <> )} ); }; interface UpgradeTabsProps { alertCount: number; contactCount: number; } export const UpgradeTabs = ({ alertCount, contactCount }: UpgradeTabsProps) => { const styles = useStyles2(getStyles); const [queryParams, setQueryParams] = useQueryParams(); const { tab } = getActiveTabFromUrl(queryParams); const [activeTab, setActiveTab] = useState(tab); useEffect(() => { setActiveTab(tab); }, [tab]); return ( <>

Preview of how your existing alert rules and notification channels wll be upgraded to the new Grafana Alerting.
Once you are happy with the results, you can permanently upgrade by modifying the Grafana configuration.

{'For more information, please refer to the '} Grafana Alerting Migration Guide

{ setActiveTab(ActiveTab.Alerts); setQueryParams({ tab: ActiveTab.Alerts }); }} /> { setActiveTab(ActiveTab.Contacts); setQueryParams({ tab: ActiveTab.Contacts }); }} /> <> {activeTab === ActiveTab.Alerts && } {activeTab === ActiveTab.Contacts && } ); }; const CancelUpgradeButton = () => { const styles = useStyles2(getStyles); const [startOver] = upgradeApi.useCancelOrgUpgradeMutation({ fixedCacheKey: 'cancel-org-upgrade-loading' }); const [showConfirmStartOver, setShowConfirmStartOver] = useState(false); const cancelUpgrade = async () => { startOver(); setShowConfirmStartOver(false); }; return ( <> {showConfirmStartOver && ( All new Grafana Alerting resources will be deleted. This includes: alert rules, contact points, notification policies, silences, mute timings, and any manual changes you have made. No legacy alerts or notification channels will be affected. } confirmText="Reset upgrade" onConfirm={cancelUpgrade} dismissText={'Keep reviewing'} onDismiss={() => setShowConfirmStartOver(false)} /> )} ); }; enum ActiveTab { Alerts = 'alerts', Contacts = 'contacts', } interface QueryParamValues { tab: ActiveTab; } function getActiveTabFromUrl(queryParams: UrlQueryMap): QueryParamValues { let tab = ActiveTab.Alerts; // default tab if (queryParams['tab'] === ActiveTab.Alerts) { tab = ActiveTab.Alerts; } if (queryParams['tab'] === ActiveTab.Contacts) { tab = ActiveTab.Contacts; } return { tab, }; } const AlertTabContentWrapper = () => { const columns = useAlertColumns(); const filterParam = 'alertFilter'; const [queryParam, updateQueryParam] = useSingleQueryParam(filterParam); const [startAlertUpgrade, { isLoading: isAlertLoading }] = upgradeApi.useUpgradeAllDashboardsMutation({ fixedCacheKey: 'upgrade-alerts-loading', }); const [_, { isLoading: isChannelLoading }] = upgradeApi.useUpgradeAllChannelsMutation({ fixedCacheKey: 'upgrade-channels-loading', }); const isUpgrading = isChannelLoading || isAlertLoading; const selectRows = useMemo(() => { const emptyArray: Array> = []; return createSelector( (res: OrgMigrationState | undefined) => res?.migratedDashboards ?? [], (rows) => rows ?? emptyArray ); }, []); const { items } = upgradeApi.useGetOrgUpgradeSummaryQuery(undefined, { selectFromResult: ({ data }) => ({ items: selectRows(data), }), }); const searchSpaceMap = useCallback( (dashUpgrade: DashboardUpgrade) => `${dashUpgrade.folderName} ${dashUpgrade.dashboardName} ${dashUpgrade.newFolderName} ${dashUpgrade.migratedAlerts .map((a) => a.legacyAlert?.name ?? '') .join(' ')}`, [] ); const renderExpandedContent = useCallback( ({ data: dashUpgrade }: { data: DashboardUpgrade }) => ( ), [] ); const syncNewButton = useMemo(() => { const syncAlerting = async () => { await startAlertUpgrade({ skipExisting: true }); }; return ( ); }, [startAlertUpgrade, isUpgrading]); const syncAllButton = useMemo(() => { const syncAlerting = async () => { await startAlertUpgrade({ skipExisting: false }); }; return ( ); }, [startAlertUpgrade, isUpgrading]); return ( ); }; AlertTabContentWrapper.displayName = 'AlertTabContentWrapper'; const ChannelTabContentWrapper = () => { const columns = useChannelColumns(); const filterParam = 'contactFilter'; const [queryParam, updateQueryParam] = useSingleQueryParam(filterParam); const [startChannelUpgrade, { isLoading: isChannelLoading }] = upgradeApi.useUpgradeAllChannelsMutation({ fixedCacheKey: 'upgrade-channels-loading', }); const [, { isLoading: isAlertLoading }] = upgradeApi.useUpgradeAllDashboardsMutation({ fixedCacheKey: 'upgrade-alerts-loading', }); const isUpgrading = isChannelLoading || isAlertLoading; const selectRows = useMemo(() => { const emptyArray: Array> = []; return createSelector( (res: OrgMigrationState | undefined) => res?.migratedChannels ?? [], (rows) => rows ?? emptyArray ); }, []); const { items } = upgradeApi.useGetOrgUpgradeSummaryQuery(undefined, { selectFromResult: ({ data }) => ({ items: selectRows(data), }), }); const searchSpaceMap = useCallback( (pair: ContactPair) => `${pair.legacyChannel?.name} ${pair.contactPoint?.name} ${pair.legacyChannel?.type}`, [] ); const syncNewButton = useMemo(() => { const syncAlerting = async () => { await startChannelUpgrade({ skipExisting: true }); }; return ( ); }, [startChannelUpgrade, isUpgrading]); const syncAllButton = useMemo(() => { const syncAlerting = async () => { await startChannelUpgrade({ skipExisting: false }); }; return ( ); }, [startChannelUpgrade, isUpgrading]); return ( ); }; ChannelTabContentWrapper.displayName = 'ChannelTabContentWrapper'; function useSingleQueryParam(name: string): [string | undefined, (values: string) => void] { const { search } = useLocation(); const param = useMemo(() => { return new URLSearchParams(search).get(name) || ''; }, [name, search]); const update = useCallback( (value: string) => { return locationService.partial({ [name]: value || null }); }, [name] ); return [param, update]; } interface UpgradeTabContentProps { rows?: T[]; updateQueryParam?: (values: string) => void; queryParam?: string; searchSpaceMap: (row: T) => string; searchPlaceholder: string; syncNewButton: JSX.Element; syncAllButton: JSX.Element; isUpgrading: boolean; columns: Array>; isExpandable?: boolean; renderExpandedContent?: (item: DynamicTableItemProps) => React.ReactNode; emptyMessage: string; } const UpgradeTabContent = ({ rows = [], queryParam, updateQueryParam, searchSpaceMap, columns, isExpandable = false, renderExpandedContent, emptyMessage, searchPlaceholder, syncNewButton, syncAllButton, isUpgrading, }: UpgradeTabContentProps) => { const styles = useStyles2(getStyles); const isLoading = isUpgrading || isUpgrading; const filterFn = useMemo(() => { return createfilterByMapping(searchSpaceMap, rows); }, [searchSpaceMap, rows]); const items = useMemo((): Array> => { return filterFn(queryParam).map((row, Idx) => { return { id: `${searchSpaceMap(row)} - ${Idx}`, data: row, }; }); }, [searchSpaceMap, filterFn, queryParam]); const showGuidelines = false; const wrapperClass = cx(styles.wrapper, { [styles.wrapperMargin]: showGuidelines }); const TableComponent = showGuidelines ? DynamicTableWithGuidelines : DynamicTable; const pagination = useMemo(() => ({ itemsPerPage: 50 }), []); return ( <>
{ updateQueryParam?.(phrase || ''); }} searchPhrase={queryParam || ''} /> {syncNewButton} {syncAllButton}
{isLoading && } {!isLoading && !!items.length && (
)} {!isLoading && !items.length &&
{emptyMessage}
} ); }; const ChannelTabContent = React.memo(UpgradeTabContent); const AlertTabContent = React.memo(UpgradeTabContent); const useChannelColumns = (): Array> => { const styles = useStyles2(getStyles); const { useUpgradeChannelMutation } = upgradeApi; const [migrateChannel] = useUpgradeChannelMutation(); return useMemo( () => [ { id: 'contact-level-error', label: '', renderCell: ({ data: contactPair }) => { if (!contactPair.error) { return null; } const warning = contactPair?.error === 'channel not upgraded' || contactPair?.error === 'channel no longer exists'; return ( ); }, size: '45px', }, { id: 'legacyChannel', label: 'Legacy Channel', // eslint-disable-next-line react/display-name renderCell: ({ data: contactPair }) => { if (!contactPair?.legacyChannel) { return null; } if (!contactPair.legacyChannel.name && contactPair.contactPoint?.name) { return ; } if (!contactPair.legacyChannel.name) { return ; } return ( {contactPair.legacyChannel.name} {contactPair.legacyChannel?.type && } ); }, size: 5, }, { id: 'arrow', label: '', renderCell: ({ data: contactPair }) => { if (!contactPair?.contactPoint) { return null; } return ; }, size: '45px', }, { id: 'route', label: 'Notification Policy', renderCell: ({ data: contactPair }) => { return ; }, size: 5, }, { id: 'arrow2', label: '', renderCell: ({ data: contactPair }) => { if (!contactPair?.contactPoint) { return null; } return ; }, size: '45px', }, { id: 'contactPoint', label: 'Contact Point', // eslint-disable-next-line react/display-name renderCell: ({ data: contactPair }) => { return ( {contactPair?.contactPoint && ( <> {contactPair.contactPoint.name} )} ); }, size: 5, }, { id: 'provisioned', label: '', renderCell: ({ data: contactPair }) => { return contactPair.provisioned ? ( ) : null; }, size: '100px', }, { id: 'actions', label: 'Actions', renderCell: ({ data: pair }) => { if (!pair?.legacyChannel) { return null; } if (pair.legacyChannel.id <= 0) { return null; } if (pair.isUpgrading) { return ( ); } if (pair?.error === 'channel not upgraded') { return ( migrateChannel({ channelId: pair.legacyChannel.id, skipExisting: false })} /> ); } if (pair?.error === 'channel no longer exists') { return ( migrateChannel({ channelId: pair.legacyChannel.id, skipExisting: false })} /> ); } return ( migrateChannel({ channelId: pair.legacyChannel.id, skipExisting: false })} /> ); }, size: '70px', }, ], [styles.textLink, styles.errorIcon, styles.warningIcon, styles.badge, styles.spinner, migrateChannel] ); }; const useAlertColumns = (): Array> => { const styles = useStyles2(getStyles); const { useUpgradeDashboardMutation } = upgradeApi; const [migrateDashboard] = useUpgradeDashboardMutation(); return useMemo( () => [ { id: 'dashboard-level-error', label: '', renderCell: ({ data: dashUpgrade }) => { if (!dashUpgrade.error) { return null; } const warning = dashUpgrade?.error === 'dashboard not upgraded' || dashUpgrade?.error === 'dashboard no longer exists'; return ( ); }, size: '45px', }, { id: 'folder', label: 'Folder', renderCell: ({ data: dashUpgrade }) => { if (!dashUpgrade.folderName) { return ( ); } return ( {dashUpgrade.folderName} ); }, size: 2, }, { id: 'dashboard', label: 'Dashboard', renderCell: ({ data: dashUpgrade }) => { if (!dashUpgrade.dashboardName) { return ( ); } return ( {dashUpgrade.dashboardName} ); }, size: 2, }, { id: 'new-folder-arrow', label: '', renderCell: ({ data: dashUpgrade }) => { const migratedFolderUid = dashUpgrade?.newFolderUid; const folderChanged = migratedFolderUid!! && migratedFolderUid !== dashUpgrade.folderUid; if (folderChanged && dashUpgrade?.newFolderName) { return ; } return null; }, size: '45px', }, { id: 'new-folder', label: 'New folder', renderCell: ({ data: dashUpgrade }) => { const migratedFolderUid = dashUpgrade?.newFolderUid; if (migratedFolderUid && migratedFolderUid !== dashUpgrade.folderUid && dashUpgrade?.newFolderName) { const newFolderWarning = dashUpgrade.warning.includes('dashboard alerts moved'); return ( {dashUpgrade.newFolderName} {newFolderWarning && ( )} ); } return null; }, size: 3, }, { id: 'provisioned', label: '', className: styles.tableBadges, renderCell: ({ data: dashUpgrade }) => { const provisionedWarning = dashUpgrade.warning.includes('provisioned status:'); return ( <> {dashUpgrade.provisioned && ( )} ); }, size: '100px', }, { id: 'error-badge', label: '', className: styles.tableBadges, renderCell: ({ data: dashUpgrade }) => { const migratedAlerts = dashUpgrade?.migratedAlerts ?? []; const nestedErrors = migratedAlerts.map((alertPair) => alertPair.error ?? '').filter((error) => !!error); if (nestedErrors.length === 0) { return null; } return ; }, size: '90px', }, { id: 'alert-count-badge', label: '', className: styles.tableBadges, renderCell: ({ data: dashUpgrade }) => { const migratedAlerts = dashUpgrade?.migratedAlerts ?? []; return ( ); }, size: '115px', }, { id: 'actions', label: 'Actions', renderCell: ({ data: dashUpgrade }) => { if (dashUpgrade.isUpgrading) { return ( ); } if (dashUpgrade?.error === 'dashboard not upgraded') { return ( migrateDashboard({ dashboardId: dashUpgrade.dashboardId, skipExisting: false })} /> ); } if (dashUpgrade?.error === 'dashboard no longer exists') { return ( migrateDashboard({ dashboardId: dashUpgrade.dashboardId, skipExisting: false })} /> ); } return ( {dashUpgrade.dashboardId && ( migrateDashboard({ dashboardId: dashUpgrade.dashboardId, skipExisting: false })} /> )} ); }, size: '70px', }, ], [ styles.tableBadges, styles.errorIcon, styles.warningIcon, styles.textLink, styles.badge, styles.spinner, migrateDashboard, ] ); }; const ufuzzy = new uFuzzy({ intraMode: 1, intraIns: 1, intraSub: 1, intraTrn: 1, intraDel: 1, }); const createfilterByMapping = (searchSpaceMap: (row: T) => string, filterables: T[]) => { const haystack = filterables.map(searchSpaceMap); return (filter: string | undefined) => { if (!filter) { return filterables; } const [idxs, info, order] = ufuzzy.search(haystack, filter, 5); if (info && order) { return order.map((idx) => filterables[info.idx[idx]]); } else if (idxs) { return idxs.map((idx) => filterables[idx]); } return filterables; }; }; interface SearchProps { searchFn: (searchPhrase: string) => void; searchPhrase: string | undefined; placeholder?: string; } const Search = ({ searchFn, searchPhrase, placeholder }: SearchProps) => { const [searchFilter, setSearchFilter] = useState(searchPhrase); const debouncedSearch = useMemo(() => debounce(searchFn, 600), [searchFn]); useEffect(() => { setSearchFilter(searchPhrase); return () => { // Stop the invocation of the debounced function after unmounting debouncedSearch?.cancel(); }; }, [debouncedSearch, searchPhrase]); return ( { setSearchFilter(value || ''); if (value === '') { // This is so clicking clear is instant. Otherwise, clearing and switching tabs before debounce is ready will lose filter state. debouncedSearch?.cancel(); searchFn(''); } else { debouncedSearch(value || ''); } }} /> ); }; interface AlertTableProps { dashboardId: number; dashboardUid: string; showGuidelines?: boolean; emptyMessage?: string; } const AlertTable = ({ dashboardId, dashboardUid, showGuidelines = false, emptyMessage = 'No alert upgrades found.', }: AlertTableProps) => { const styles = useStyles2(getStyles); const selectRowsForDashUpgrade = useMemo(() => { const emptyArray: Array> = []; return createSelector( (res: OrgMigrationState | undefined) => res?.migratedDashboards ?? [], (res: OrgMigrationState | undefined, dashboardId: number) => dashboardId, (migratedDashboards, dashboardId) => migratedDashboards ?.find((du) => du.dashboardId === dashboardId) ?.migratedAlerts.map((alertPair, Idx) => { return { id: `${alertPair?.legacyAlert?.id}-${Idx}`, data: alertPair, }; }) ?? emptyArray ); }, []); const { items } = upgradeApi.useGetOrgUpgradeSummaryQuery(undefined, { selectFromResult: ({ data }) => ({ items: selectRowsForDashUpgrade(data, dashboardId), }), }); const { useUpgradeAlertMutation } = upgradeApi; const [migrateAlert] = useUpgradeAlertMutation(); const wrapperClass = cx(styles.wrapper, styles.rulesTable, { [styles.wrapperMargin]: showGuidelines }); const columns: Array> = [ { id: 'alert-level-error', label: '', renderCell: ({ data: alertPair }) => { if (!alertPair.error) { return null; } const warning = alertPair?.error === 'alert not upgraded' || alertPair?.error.endsWith('no longer exists'); return ( ); }, size: '45px', }, { id: 'legacyAlert', label: 'Legacy alert rule', renderCell: ({ data: alertPair }) => { if (!alertPair?.legacyAlert) { return null; } const deleted = (alertPair.error ?? '').endsWith('no longer exists'); if (deleted) { return ; } return ( <> {dashboardUid ? ( {alertPair.legacyAlert.name || 'Missing Title'} ) : ( )} ); }, size: 5, }, { id: 'arrow', label: '', renderCell: ({ data: alertPair }) => { if (!alertPair?.legacyAlert) { return null; } return ; }, size: '45px', }, { id: 'alertRule', label: 'New alert rule', renderCell: ({ data: alertPair }) => { return ( {alertPair?.alertRule && ( {alertPair.alertRule?.title ?? ''} )} ); }, size: 5, }, { id: 'contacts', label: 'Sends To', renderCell: ({ data: alertPair }) => { return ( <> {alertPair?.alertRule && ( )} ); }, size: 3, }, { id: 'actions', label: 'Actions', renderCell: ({ data: alertPair }) => { if (!alertPair?.legacyAlert) { return null; } if (alertPair.legacyAlert.dashboardId <= 0 || alertPair.legacyAlert.panelId <= 0) { return null; } if (alertPair.isUpgrading) { return ( ); } if (alertPair?.error === 'alert not upgraded') { return ( migrateAlert({ dashboardId: alertPair.legacyAlert.dashboardId, panelId: alertPair.legacyAlert.panelId, skipExisting: false, }) } /> ); } if (alertPair?.error?.endsWith('no longer exists')) { return ( migrateAlert({ dashboardId: alertPair.legacyAlert.dashboardId, panelId: alertPair.legacyAlert.panelId, skipExisting: false, }) } /> ); } return ( migrateAlert({ dashboardId: alertPair.legacyAlert.dashboardId, panelId: alertPair.legacyAlert.panelId, skipExisting: false, }) } /> ); }, size: '70px', }, ]; if (!items.length) { return
{emptyMessage}
; } const TableComponent = showGuidelines ? DynamicTableWithGuidelines : DynamicTable; return (
); }; interface ErrorSummaryButtonProps { count: number; onClick: () => void; } const ErrorSummaryButton = ({ count, onClick }: ErrorSummaryButtonProps) => { return ( ); }; interface ErrorSummaryProps { errors: string[]; } const ErrorSummary = ({ errors }: ErrorSummaryProps) => { const [expanded, setExpanded] = useState(false); const [closed, setClosed] = useLocalStorage('grafana.unifiedalerting.upgrade.hideErrors', true); const styles = useStyles2(getStyles); return ( <> {!!errors.length && closed && setClosed(false)} />} {!!errors.length && !closed && ( setClosed(true)} > {expanded && errors.map((item, idx) =>
{item}
)} {!expanded && ( <>
{errors[0]}
{errors.length >= 2 && ( )} )}
)} ); }; interface LoadingProps { text?: string; } const Loading = ({ text = 'Loading...' }: LoadingProps) => { return (
); }; export const getStyles = (theme: GrafanaTheme2) => ({ wrapperMargin: css({ [theme.breakpoints.up('md')]: { marginLeft: '36px', }, }), emptyMessage: css({ padding: theme.spacing(1), }), wrapper: css({ width: 'auto', borderRadius: theme.shape.radius.default, }), pagination: css({ display: 'flex', margin: '0', paddingTop: theme.spacing(1), paddingBottom: theme.spacing(0.25), justifyContent: 'center', borderLeft: `1px solid ${theme.colors.border.medium}`, borderRight: `1px solid ${theme.colors.border.medium}`, borderBottom: `1px solid ${theme.colors.border.medium}`, }), rulesTable: css({ marginTop: theme.spacing(3), }), errorIcon: css({ fill: theme.colors.error.text, }), warningIcon: css({ fill: theme.colors.warning.text, }), searchWrapper: css({ marginBottom: theme.spacing(2), }), textLink: css({ color: theme.colors.text.link, cursor: 'pointer', '&:hover': { textDecoration: 'underline', }, }), errorLink: css({ color: theme.colors.error.text, cursor: 'pointer', '&:hover': { textDecoration: 'underline', }, }), tabContent: css({ marginTop: theme.spacing(2), }), moreButton: css({ padding: '0', }), tableBadges: css({ justifyContent: 'flex-end', }), badge: css({ width: '100%', justifyContent: 'center', }), separator: css({ borderBottom: `1px solid ${theme.colors.border.weak}`, marginTop: theme.spacing(2), }), spinner: css({ display: 'flex', alignItems: 'center', justifyContent: 'center', width: theme.spacing(3), height: theme.spacing(3), }), }); export default UpgradePage;