mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
When using the legacy migration dry-run, if a cancel takes a long time (long enough for the page to poll) the page will incorrectly render the previous data. This change stops the polling while the upgrading or cancelling.
1675 lines
52 KiB
TypeScript
1675 lines
52 KiB
TypeScript
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 { selectors } from '@grafana/e2e-selectors';
|
|
import { Stack } from '@grafana/experimental';
|
|
import { locationService } from '@grafana/runtime';
|
|
import {
|
|
Alert,
|
|
Badge,
|
|
Button,
|
|
CallToActionCard,
|
|
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, makeFolderLink } 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 <CancelUpgradeButton />;
|
|
}
|
|
return null;
|
|
}, [isFetchError, hasData]);
|
|
|
|
const showError = isFetchError;
|
|
const showLoading = isLoading;
|
|
const showStart = !isLoading && !isFetchError && !hasData;
|
|
const showData = !isLoading && !isFetchError && hasData;
|
|
|
|
return (
|
|
<Page navId="alerting-upgrade" actions={cancelUpgrade}>
|
|
<Page.Contents>
|
|
{showError && (
|
|
<Alert severity="error" title="Error loading Grafana Alerting upgrade information">
|
|
{fetchError instanceof Error ? fetchError.message : 'Unknown error.'}
|
|
</Alert>
|
|
)}
|
|
{showLoading && <Loading text={'Loading...'} />}
|
|
{showStart && <CTAElement />}
|
|
{showData && (
|
|
<>
|
|
<ErrorSummary errors={errors} />
|
|
<UpgradeTabs alertCount={alertCount} contactCount={contactCount} />
|
|
</>
|
|
)}
|
|
</Page.Contents>
|
|
</Page>
|
|
);
|
|
};
|
|
|
|
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<ActiveTab>(tab);
|
|
|
|
useEffect(() => {
|
|
setActiveTab(tab);
|
|
}, [tab]);
|
|
|
|
return (
|
|
<>
|
|
<Alert severity={'info'} title={'Grafana Alerting upgrade guide'}>
|
|
<p>
|
|
Preview of how your existing alert rules and notification channels wll be upgraded to the new Grafana
|
|
Alerting.
|
|
<br />
|
|
Once you are happy with the results, you can permanently upgrade by modifying the Grafana configuration.
|
|
</p>
|
|
<p>
|
|
{'For more information, please refer to the '}
|
|
<TextLink external href={'https://grafana.com/docs/grafana/latest/alerting/set-up/migrating-alerts/'}>
|
|
Grafana Alerting Migration Guide
|
|
</TextLink>
|
|
</p>
|
|
</Alert>
|
|
<TabsBar>
|
|
<Tab
|
|
label={'Upgraded alert rules'}
|
|
active={activeTab === ActiveTab.Alerts}
|
|
counter={alertCount}
|
|
icon={'bell'}
|
|
onChangeTab={() => {
|
|
setActiveTab(ActiveTab.Alerts);
|
|
setQueryParams({ tab: ActiveTab.Alerts });
|
|
}}
|
|
/>
|
|
<Tab
|
|
label={'Upgraded notification channels'}
|
|
active={activeTab === ActiveTab.Contacts}
|
|
counter={contactCount}
|
|
icon={'at'}
|
|
onChangeTab={() => {
|
|
setActiveTab(ActiveTab.Contacts);
|
|
setQueryParams({ tab: ActiveTab.Contacts });
|
|
}}
|
|
/>
|
|
</TabsBar>
|
|
<TabContent className={styles.tabContent}>
|
|
<>
|
|
{activeTab === ActiveTab.Alerts && <AlertTabContentWrapper />}
|
|
{activeTab === ActiveTab.Contacts && <ChannelTabContentWrapper />}
|
|
</>
|
|
</TabContent>
|
|
</>
|
|
);
|
|
};
|
|
|
|
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 (
|
|
<>
|
|
<Button
|
|
size="md"
|
|
variant="destructive"
|
|
onClick={() => setShowConfirmStartOver(true)}
|
|
icon={'trash-alt'}
|
|
className={''}
|
|
>
|
|
{'Cancel upgrade'}
|
|
</Button>
|
|
{showConfirmStartOver && (
|
|
<ConfirmModal
|
|
isOpen={true}
|
|
title="Cancel upgrade"
|
|
body={
|
|
<Stack direction="column" gap={0.5}>
|
|
<Text color="primary">All new Grafana Alerting resources will be deleted.</Text>
|
|
<Text color="secondary" variant="bodySmall">
|
|
This includes: alert rules, contact points, notification policies, silences, mute timings, and any
|
|
manual changes you have made.
|
|
</Text>
|
|
<span className={styles.separator} />
|
|
<Text color="primary">No legacy alerts or notification channels will be affected.</Text>
|
|
</Stack>
|
|
}
|
|
confirmText="Cancel 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 CTAElement = () => {
|
|
const styles = useStyles2(getContentBoxStyles);
|
|
const { useUpgradeOrgMutation } = upgradeApi;
|
|
const [startUpgrade, { isLoading: isUpgradeLoading }] = useUpgradeOrgMutation({
|
|
fixedCacheKey: 'upgrade-org-loading',
|
|
});
|
|
const [, { isLoading: isCancelLoading }] = upgradeApi.useCancelOrgUpgradeMutation({
|
|
fixedCacheKey: 'cancel-org-upgrade-loading',
|
|
});
|
|
const isLoading = isUpgradeLoading || isCancelLoading;
|
|
|
|
const upgradeAlerting = async () => {
|
|
await startUpgrade({ skipExisting: false });
|
|
};
|
|
|
|
if (isLoading) {
|
|
return <Loading text={isCancelLoading ? 'Cancelling upgrade...' : 'Upgrade in progress...'} />;
|
|
}
|
|
|
|
const footer = (
|
|
<>
|
|
<span key="proTipFooter">
|
|
<p>
|
|
Note:{' '}
|
|
{'Previewing the upgrade process will not affect your existing legacy alerts and can be stopped at any time.'}
|
|
</p>
|
|
</span>
|
|
</>
|
|
);
|
|
|
|
const cta = (
|
|
<div>
|
|
<Stack direction="column" gap={1}>
|
|
<Stack direction="row" gap={1}>
|
|
<Button
|
|
size="lg"
|
|
variant="primary"
|
|
onClick={upgradeAlerting}
|
|
icon={'bell'}
|
|
className={''}
|
|
data-testid={selectors.components.CallToActionCard.buttonV2('Preview upgrade')}
|
|
>
|
|
{'Preview upgrade'}
|
|
</Button>
|
|
</Stack>
|
|
</Stack>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className={styles.grid}>
|
|
<ContentBox className={styles.processBlock}>
|
|
<h3 className={styles.header}>How it works</h3>
|
|
<Stack direction="column" alignItems="space-between">
|
|
<div className={styles.list}>
|
|
<h4>Automatic Upgrade</h4>
|
|
<div className={styles.step}>
|
|
<p>
|
|
The upgrade process seamlessly transfers your existing legacy alert rules and notification channels to
|
|
the new Grafana Alerting system. This means your alerting configurations are preserved during the
|
|
transition.
|
|
</p>
|
|
</div>
|
|
<h4>Preview and Modification</h4>
|
|
<div className={styles.step}>
|
|
<p>
|
|
Alert Rules, Contact Points, and Notification Policies generated during the upgrade are available for
|
|
your review and potential adjustments. However, please note that they won't actively trigger alerts
|
|
or send notifications at this stage.
|
|
</p>
|
|
</div>
|
|
<h4>Limitations on Real-Time Updates</h4>
|
|
<div className={styles.step}>
|
|
<p>
|
|
Any changes made to your configurations after initiating the upgrade won't be immediately reflected
|
|
in the summary table. You have the flexibility to re-upgrade specific resources like dashboards, alert
|
|
rules, and notification channels at any time.
|
|
</p>
|
|
</div>
|
|
<h4>Cancellation and Restart</h4>
|
|
<div className={styles.step}>
|
|
<p>
|
|
If necessary, you can cancel and restart the upgrade process. However, it's important to be aware
|
|
that canceling the upgrade will result in the removal of all Grafana Alerting resources created during
|
|
the process, including any manual modifications.
|
|
</p>
|
|
</div>
|
|
<h4>Completing the Upgrade</h4>
|
|
<div className={styles.step}>
|
|
<p>
|
|
To enable Grafana Alerting, you'll need to modify the Grafana configuration and restart. Until this
|
|
step is completed, Grafana Alerting will remain inactive.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</Stack>
|
|
</ContentBox>
|
|
<ContentBox className={styles.getStartedBlock}>
|
|
<h3 className={styles.header}>Get started</h3>
|
|
<Stack direction="column" alignItems="space-between">
|
|
<div className={styles.list}>
|
|
<h4>Step 1: Preview the Upgrade</h4>
|
|
<div className={styles.step}>
|
|
<p>
|
|
Start the upgrade process by clicking on "Preview upgrade." This action will display a summary
|
|
table showing how your existing alert rules and notification channels will be mapped to resources in the
|
|
new Grafana Alerting system.
|
|
</p>
|
|
</div>
|
|
<h4>Step 2: Investigate and Resolve Errors</h4>
|
|
<div className={styles.step}>
|
|
<p>
|
|
Review the previewed upgrade carefully. Alert rules or notification channels that couldn't be
|
|
automatically upgraded will be marked as errors. You have two options to address these errors:
|
|
</p>
|
|
<ul className={styles.list}>
|
|
<li>
|
|
Fix the issues on the legacy side: If possible, resolve the problems within your legacy alerting
|
|
setup, and then attempt the upgrade again.
|
|
</li>
|
|
<li>
|
|
Manually create new resources: If fixing legacy issues isn't feasible, manually create new alert
|
|
rules, notification policies, or contact points in the new Grafana Alerting system to replace the
|
|
problematic ones.
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<h4>Step 3: Update Your As-Code Setup (Optional)</h4>
|
|
<div className={styles.step}>
|
|
<p>
|
|
In the new Grafana Alerting, Legacy Alerting methods of provisioning will no longer work. If you use
|
|
provisioning to manage alert rules and notification channels, you can export the upgraded versions to
|
|
generate Grafana Alerting-compatible provisioning files. This can all be done before completeing the
|
|
upgrade process.
|
|
</p>
|
|
</div>
|
|
<h4>Step 4: Perform the Upgrade to Grafana Alerting</h4>
|
|
<div className={styles.step}>
|
|
<p>
|
|
Once you are satisfied with the state of your Grafana Alerting setup, it's time to proceed with the
|
|
upgrade:
|
|
</p>
|
|
<ul className={styles.list}>
|
|
<li>
|
|
Contact your Grafana server administrator to restart Grafana with the [unified_alerting] section
|
|
enabled in your configuration.
|
|
</li>
|
|
<li>
|
|
During this process, all organizations that have undergone the above upgrade process will continue to
|
|
use their configured setup.
|
|
</li>
|
|
<li>
|
|
Any organization that has not yet started the upgrade process will be automatically upgraded as part
|
|
of this restart.
|
|
</li>
|
|
<li>
|
|
Note: If the automatic upgrade fails for any reason, Grafana will not start, so it's safer to
|
|
address any issues before initiating this step.
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
<Stack direction={'row'} alignItems={'center'} gap={0.5}>
|
|
<Text color={'secondary'}>For more information, please refer to the</Text>
|
|
<TextLink external href={'https://grafana.com/docs/grafana/latest/alerting/set-up/migrating-alerts/'}>
|
|
Grafana Alerting Migration Guide
|
|
</TextLink>
|
|
</Stack>
|
|
</Stack>
|
|
</ContentBox>
|
|
<ContentBox className={styles.ctaBlock}>
|
|
<CallToActionCard
|
|
className={styles.ctaStyle}
|
|
message={'Start the upgrade to the new Grafana Alerting.'}
|
|
footer={footer}
|
|
callToActionElement={cta}
|
|
/>
|
|
</ContentBox>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
function ContentBox({ children, className }: React.PropsWithChildren<{ className?: string }>) {
|
|
const styles = useStyles2(getContentBoxStyles);
|
|
|
|
return <div className={cx(styles.box, className)}>{children}</div>;
|
|
}
|
|
|
|
const getContentBoxStyles = (theme: GrafanaTheme2) => {
|
|
const color = theme.colors['warning'];
|
|
return {
|
|
box: css({
|
|
padding: theme.spacing(2),
|
|
backgroundColor: theme.colors.background.secondary,
|
|
borderRadius: theme.shape.radius.default,
|
|
}),
|
|
|
|
warningIcon: css({
|
|
color: color.text,
|
|
}),
|
|
|
|
grid: css({
|
|
display: 'grid',
|
|
gridTemplateRows: 'min-content auto auto',
|
|
gridTemplateColumns: '1fr 1fr 1fr 1fr 1fr',
|
|
gap: theme.spacing(2),
|
|
}),
|
|
|
|
list: css({
|
|
margin: `${theme.spacing(0)} ${theme.spacing(2)}`,
|
|
'& > li': {
|
|
marginBottom: theme.spacing(1),
|
|
},
|
|
}),
|
|
|
|
ctaStyle: css({
|
|
textAlign: 'center',
|
|
}),
|
|
|
|
processBlock: css({
|
|
gridColumn: '1 / span 2',
|
|
justifyContent: 'space-between',
|
|
}),
|
|
|
|
getStartedBlock: css({
|
|
gridColumn: '3 / span 3',
|
|
justifyContent: 'space-between',
|
|
}),
|
|
|
|
ctaBlock: css({
|
|
gridColumn: '1 / span 5',
|
|
}),
|
|
|
|
header: css({
|
|
marginBottom: theme.spacing(2),
|
|
}),
|
|
|
|
step: css({
|
|
paddingLeft: theme.spacing(2),
|
|
}),
|
|
};
|
|
};
|
|
|
|
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<DynamicTableItemProps<DashboardUpgrade>> = [];
|
|
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 }) => (
|
|
<AlertTable
|
|
dashboardUid={dashUpgrade.dashboardUid ?? ''}
|
|
dashboardId={dashUpgrade.dashboardId}
|
|
showGuidelines={true}
|
|
/>
|
|
),
|
|
[]
|
|
);
|
|
|
|
const syncNewButton = useMemo(() => {
|
|
const syncAlerting = async () => {
|
|
await startAlertUpgrade({ skipExisting: true });
|
|
};
|
|
|
|
return (
|
|
<Tooltip
|
|
theme="info-alt"
|
|
content={
|
|
isUpgrading ? 'Upgrade in progress...' : 'Upgrade all newly created legacy alerts since the previous run.'
|
|
}
|
|
placement="top"
|
|
>
|
|
<Button size="md" variant="secondary" onClick={syncAlerting} icon={'plus-circle'} disabled={isUpgrading}>
|
|
Upgrade New Alerts
|
|
</Button>
|
|
</Tooltip>
|
|
);
|
|
}, [startAlertUpgrade, isUpgrading]);
|
|
|
|
const syncAllButton = useMemo(() => {
|
|
const syncAlerting = async () => {
|
|
await startAlertUpgrade({ skipExisting: false });
|
|
};
|
|
|
|
return (
|
|
<Tooltip
|
|
theme="info-alt"
|
|
content={isUpgrading ? 'Upgrade in progress...' : 'Upgrade all legacy alerts from scratch.'}
|
|
placement="top"
|
|
>
|
|
<Button size="md" variant="secondary" onClick={syncAlerting} icon={'sync'} disabled={isUpgrading}>
|
|
Upgrade All Alerts
|
|
</Button>
|
|
</Tooltip>
|
|
);
|
|
}, [startAlertUpgrade, isUpgrading]);
|
|
|
|
return (
|
|
<AlertTabContent
|
|
rows={items}
|
|
queryParam={queryParam}
|
|
updateQueryParam={updateQueryParam}
|
|
searchSpaceMap={searchSpaceMap}
|
|
searchPlaceholder={getSearchPlaceholder(false)}
|
|
syncNewButton={syncNewButton}
|
|
syncAllButton={syncAllButton}
|
|
isUpgrading={isUpgrading}
|
|
emptyMessage={'No alert upgrades found.'}
|
|
columns={columns}
|
|
isExpandable={true}
|
|
renderExpandedContent={renderExpandedContent}
|
|
/>
|
|
);
|
|
};
|
|
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<DynamicTableItemProps<ContactPair>> = [];
|
|
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 (
|
|
<Tooltip
|
|
theme="info-alt"
|
|
content={
|
|
isUpgrading
|
|
? 'Upgrade in progress...'
|
|
: 'Upgrade all newly created legacy notification channels since the previous run.'
|
|
}
|
|
placement="top"
|
|
>
|
|
<Button size="md" variant="secondary" onClick={syncAlerting} icon={'plus-circle'} disabled={isUpgrading}>
|
|
Upgrade New Channels
|
|
</Button>
|
|
</Tooltip>
|
|
);
|
|
}, [startChannelUpgrade, isUpgrading]);
|
|
|
|
const syncAllButton = useMemo(() => {
|
|
const syncAlerting = async () => {
|
|
await startChannelUpgrade({ skipExisting: false });
|
|
};
|
|
|
|
return (
|
|
<Tooltip
|
|
theme="info-alt"
|
|
content={isUpgrading ? 'Upgrade in progress...' : 'Upgrade all legacy notification channels from scratch.'}
|
|
placement="top"
|
|
>
|
|
<Button size="md" variant="secondary" onClick={syncAlerting} icon={'sync'} disabled={isUpgrading}>
|
|
Upgrade All Channels
|
|
</Button>
|
|
</Tooltip>
|
|
);
|
|
}, [startChannelUpgrade, isUpgrading]);
|
|
|
|
return (
|
|
<ChannelTabContent
|
|
rows={items}
|
|
queryParam={queryParam}
|
|
updateQueryParam={updateQueryParam}
|
|
searchSpaceMap={searchSpaceMap}
|
|
searchPlaceholder={'Search for channel and contact point names'}
|
|
syncNewButton={syncNewButton}
|
|
syncAllButton={syncAllButton}
|
|
isUpgrading={isUpgrading}
|
|
emptyMessage={'No channel upgrades found.'}
|
|
columns={columns}
|
|
/>
|
|
);
|
|
};
|
|
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<T extends object> {
|
|
rows?: T[];
|
|
updateQueryParam?: (values: string) => void;
|
|
queryParam?: string;
|
|
searchSpaceMap: (row: T) => string;
|
|
searchPlaceholder: string;
|
|
syncNewButton: JSX.Element;
|
|
syncAllButton: JSX.Element;
|
|
isUpgrading: boolean;
|
|
columns: Array<DynamicTableColumnProps<T>>;
|
|
isExpandable?: boolean;
|
|
renderExpandedContent?: (item: DynamicTableItemProps<T>) => React.ReactNode;
|
|
emptyMessage: string;
|
|
}
|
|
|
|
const UpgradeTabContent = <T extends object>({
|
|
rows = [],
|
|
queryParam,
|
|
updateQueryParam,
|
|
searchSpaceMap,
|
|
columns,
|
|
isExpandable = false,
|
|
renderExpandedContent,
|
|
emptyMessage,
|
|
searchPlaceholder,
|
|
syncNewButton,
|
|
syncAllButton,
|
|
isUpgrading,
|
|
}: UpgradeTabContentProps<T>) => {
|
|
const styles = useStyles2(getStyles);
|
|
|
|
const isLoading = isUpgrading || isUpgrading;
|
|
|
|
const filterFn = useMemo(() => {
|
|
return createfilterByMapping<T>(searchSpaceMap, rows);
|
|
}, [searchSpaceMap, rows]);
|
|
|
|
const items = useMemo((): Array<DynamicTableItemProps<T>> => {
|
|
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 (
|
|
<>
|
|
<div className={styles.searchWrapper}>
|
|
<Stack direction="column" gap={1}>
|
|
<Stack direction="row" gap={1}>
|
|
<Search
|
|
placeholder={searchPlaceholder}
|
|
searchFn={(phrase) => {
|
|
updateQueryParam?.(phrase || '');
|
|
}}
|
|
searchPhrase={queryParam || ''}
|
|
/>
|
|
{syncNewButton}
|
|
{syncAllButton}
|
|
</Stack>
|
|
</Stack>
|
|
</div>
|
|
{isLoading && <Loading text={isUpgrading ? 'Upgrade in progress...' : 'Loading...'} />}
|
|
{!isLoading && !!items.length && (
|
|
<div className={wrapperClass}>
|
|
<TableComponent
|
|
cols={columns}
|
|
isExpandable={isExpandable}
|
|
items={items}
|
|
renderExpandedContent={renderExpandedContent}
|
|
pagination={pagination}
|
|
paginationStyles={styles.pagination}
|
|
/>
|
|
</div>
|
|
)}
|
|
{!isLoading && !items.length && <div className={cx(wrapperClass, styles.emptyMessage)}>{emptyMessage}</div>}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const ChannelTabContent = React.memo(UpgradeTabContent<ContactPair>);
|
|
const AlertTabContent = React.memo(UpgradeTabContent<DashboardUpgrade>);
|
|
|
|
const useChannelColumns = (): Array<DynamicTableColumnProps<ContactPair>> => {
|
|
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 (
|
|
<Tooltip theme="error" content={contactPair.error}>
|
|
<Icon name="exclamation-circle" className={warning ? styles.warningIcon : styles.errorIcon} size={'lg'} />
|
|
</Tooltip>
|
|
);
|
|
},
|
|
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 <Badge color="red" text={`Deleted Channel (ID: ${contactPair.legacyChannel?.id})`} />;
|
|
}
|
|
|
|
if (!contactPair.legacyChannel.name) {
|
|
return <Badge color="red" text={`Unknown Channel (ID: ${contactPair.legacyChannel?.id})`} />;
|
|
}
|
|
return (
|
|
<Stack direction={'row'} gap={1}>
|
|
<Link
|
|
rel="noreferrer"
|
|
target="_blank"
|
|
className={styles.textLink}
|
|
href={createUrl(
|
|
`/alerting-legacy/notifications/receivers/${encodeURIComponent(contactPair.legacyChannel.id)}/edit`,
|
|
{}
|
|
)}
|
|
>
|
|
{contactPair.legacyChannel.name}
|
|
</Link>
|
|
{contactPair.legacyChannel?.type && <Badge color="blue" text={contactPair.legacyChannel.type} />}
|
|
</Stack>
|
|
);
|
|
},
|
|
size: 5,
|
|
},
|
|
{
|
|
id: 'arrow',
|
|
label: '',
|
|
renderCell: ({ data: contactPair }) => {
|
|
if (!contactPair?.contactPoint) {
|
|
return null;
|
|
}
|
|
return <Icon name="arrow-right" />;
|
|
},
|
|
size: '45px',
|
|
},
|
|
{
|
|
id: 'route',
|
|
label: 'Notification Policy',
|
|
renderCell: ({ data: contactPair }) => {
|
|
return <Matchers matchers={contactPair?.contactPoint?.routeMatchers ?? []} />;
|
|
},
|
|
size: 5,
|
|
},
|
|
{
|
|
id: 'arrow2',
|
|
label: '',
|
|
renderCell: ({ data: contactPair }) => {
|
|
if (!contactPair?.contactPoint) {
|
|
return null;
|
|
}
|
|
return <Icon name="arrow-right" />;
|
|
},
|
|
size: '45px',
|
|
},
|
|
{
|
|
id: 'contactPoint',
|
|
label: 'Contact Point',
|
|
// eslint-disable-next-line react/display-name
|
|
renderCell: ({ data: contactPair }) => {
|
|
return (
|
|
<Stack direction={'row'} gap={1}>
|
|
{contactPair?.contactPoint && (
|
|
<>
|
|
<Link
|
|
rel="noreferrer"
|
|
target="_blank"
|
|
className={styles.textLink}
|
|
href={createContactPointLink(contactPair.contactPoint.name, 'grafana')}
|
|
>
|
|
{contactPair.contactPoint.name}
|
|
</Link>
|
|
<Badge color="blue" text={contactPair.contactPoint.type} />
|
|
</>
|
|
)}
|
|
</Stack>
|
|
);
|
|
},
|
|
size: 5,
|
|
},
|
|
{
|
|
id: 'provisioned',
|
|
label: '',
|
|
renderCell: ({ data: contactPair }) => {
|
|
return contactPair.provisioned ? (
|
|
<Badge color="purple" text={'Provisioned'} className={styles.badge} />
|
|
) : 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 (
|
|
<Stack gap={0.5} alignItems="center">
|
|
<Spinner size="sm" inline={true} className={styles.spinner} />
|
|
</Stack>
|
|
);
|
|
}
|
|
if (pair?.error === 'channel not upgraded') {
|
|
return (
|
|
<Stack gap={0.5} alignItems="center">
|
|
<ActionIcon
|
|
aria-label="upgrade legacy notification channel"
|
|
key="upgrade-channel"
|
|
icon="plus"
|
|
tooltip="upgrade legacy notification channel"
|
|
onClick={() => migrateChannel({ channelId: pair.legacyChannel.id, skipExisting: false })}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|
|
if (pair?.error === 'channel no longer exists') {
|
|
return (
|
|
<Stack gap={0.5} alignItems="center">
|
|
<ActionIcon
|
|
aria-label="remove upgraded notification channel"
|
|
key="upgrade-channel"
|
|
icon="minus"
|
|
tooltip="remove upgraded notification channel"
|
|
onClick={() => migrateChannel({ channelId: pair.legacyChannel.id, skipExisting: false })}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|
|
return (
|
|
<Stack gap={0.5} alignItems="center">
|
|
<ActionIcon
|
|
aria-label="re-upgrade legacy notification channel"
|
|
key="upgrade-channel"
|
|
icon="sync"
|
|
tooltip="re-upgrade legacy notification channel"
|
|
onClick={() => migrateChannel({ channelId: pair.legacyChannel.id, skipExisting: false })}
|
|
/>
|
|
</Stack>
|
|
);
|
|
},
|
|
size: '70px',
|
|
},
|
|
],
|
|
[styles.textLink, styles.errorIcon, styles.warningIcon, styles.badge, styles.spinner, migrateChannel]
|
|
);
|
|
};
|
|
|
|
const useAlertColumns = (): Array<DynamicTableColumnProps<DashboardUpgrade>> => {
|
|
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 (
|
|
<Tooltip theme="error" content={dashUpgrade.error}>
|
|
<Icon name="exclamation-circle" className={warning ? styles.warningIcon : styles.errorIcon} size={'lg'} />
|
|
</Tooltip>
|
|
);
|
|
},
|
|
size: '45px',
|
|
},
|
|
{
|
|
id: 'folder',
|
|
label: 'Folder',
|
|
renderCell: ({ data: dashUpgrade }) => {
|
|
if (!dashUpgrade.folderName) {
|
|
return (
|
|
<Stack alignItems={'center'} gap={0.5}>
|
|
<Icon name="folder" />
|
|
<Badge color="red" text="Unknown Folder" />
|
|
</Stack>
|
|
);
|
|
}
|
|
return (
|
|
<Stack alignItems={'center'} gap={0.5}>
|
|
<Icon name="folder" />
|
|
<Link
|
|
rel="noreferrer"
|
|
target="_blank"
|
|
className={styles.textLink}
|
|
href={makeFolderLink(dashUpgrade.folderUid)}
|
|
>
|
|
{dashUpgrade.folderName}
|
|
</Link>
|
|
</Stack>
|
|
);
|
|
},
|
|
size: 2,
|
|
},
|
|
{
|
|
id: 'dashboard',
|
|
label: 'Dashboard',
|
|
renderCell: ({ data: dashUpgrade }) => {
|
|
if (!dashUpgrade.dashboardName) {
|
|
return (
|
|
<Stack alignItems={'center'} gap={0.5}>
|
|
<Icon name="apps" />
|
|
<Badge color="red" text={`Unknown Dashboard (ID: ${dashUpgrade.dashboardId})`} />
|
|
</Stack>
|
|
);
|
|
}
|
|
return (
|
|
<Stack alignItems={'center'} gap={0.5}>
|
|
<Icon name="apps" />
|
|
<Link
|
|
rel="noreferrer"
|
|
target="_blank"
|
|
className={styles.textLink}
|
|
href={makeDashboardLink(dashUpgrade.dashboardUid)}
|
|
>
|
|
{dashUpgrade.dashboardName}
|
|
</Link>
|
|
</Stack>
|
|
);
|
|
},
|
|
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 <Icon name="arrow-right" />;
|
|
}
|
|
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 (
|
|
<Stack alignItems={'center'} gap={0.5}>
|
|
<Icon name={'folder'} />
|
|
<Link
|
|
rel="noreferrer"
|
|
target="_blank"
|
|
className={styles.textLink}
|
|
href={makeFolderLink(migratedFolderUid)}
|
|
>
|
|
{dashUpgrade.newFolderName}
|
|
</Link>
|
|
{newFolderWarning && (
|
|
<Tooltip theme="info-alt" content={dashUpgrade.warning} placement="top">
|
|
<Icon name={'info-circle'} />
|
|
</Tooltip>
|
|
)}
|
|
</Stack>
|
|
);
|
|
}
|
|
return null;
|
|
},
|
|
size: 3,
|
|
},
|
|
{
|
|
id: 'provisioned',
|
|
label: '',
|
|
className: styles.tableBadges,
|
|
renderCell: ({ data: dashUpgrade }) => {
|
|
const provisionedWarning = dashUpgrade.warning.includes('provisioned status:');
|
|
return (
|
|
<>
|
|
{dashUpgrade.provisioned && (
|
|
<Badge
|
|
color="purple"
|
|
text={provisionedWarning ? 'Unknown' : 'Provisioned'}
|
|
tooltip={dashUpgrade.warning}
|
|
icon={provisionedWarning ? 'exclamation-triangle' : undefined}
|
|
className={styles.badge}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
},
|
|
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 <Badge color="red" key="errors" text={`${nestedErrors.length} errors`} className={styles.badge} />;
|
|
},
|
|
size: '90px',
|
|
},
|
|
{
|
|
id: 'alert-count-badge',
|
|
label: '',
|
|
className: styles.tableBadges,
|
|
renderCell: ({ data: dashUpgrade }) => {
|
|
const migratedAlerts = dashUpgrade?.migratedAlerts ?? [];
|
|
return (
|
|
<Badge color="green" key="alerts" text={`${migratedAlerts.length} alert rules`} className={styles.badge} />
|
|
);
|
|
},
|
|
size: '115px',
|
|
},
|
|
{
|
|
id: 'actions',
|
|
label: 'Actions',
|
|
renderCell: ({ data: dashUpgrade }) => {
|
|
if (dashUpgrade.isUpgrading) {
|
|
return (
|
|
<Stack gap={0.5} alignItems="center">
|
|
<Spinner size="sm" inline={true} className={styles.spinner} />
|
|
</Stack>
|
|
);
|
|
}
|
|
if (dashUpgrade?.error === 'dashboard not upgraded') {
|
|
return (
|
|
<Stack gap={0.5} alignItems="center">
|
|
<ActionIcon
|
|
aria-label="upgrade legacy alerts for this dashboard"
|
|
key="upgrade-dashboard"
|
|
icon="plus"
|
|
tooltip="upgrade legacy alerts for this dashboard"
|
|
onClick={() => migrateDashboard({ dashboardId: dashUpgrade.dashboardId, skipExisting: false })}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|
|
if (dashUpgrade?.error === 'dashboard no longer exists') {
|
|
return (
|
|
<Stack gap={0.5} alignItems="center">
|
|
<ActionIcon
|
|
aria-label="remove upgraded alerts for this dashboard"
|
|
key="upgrade-dashboard"
|
|
icon="minus"
|
|
tooltip="remove upgraded alerts for this dashboard"
|
|
onClick={() => migrateDashboard({ dashboardId: dashUpgrade.dashboardId, skipExisting: false })}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|
|
return (
|
|
<Stack gap={0.5} alignItems="center">
|
|
{dashUpgrade.dashboardId && (
|
|
<ActionIcon
|
|
aria-label="re-upgrade legacy alerts for this dashboard"
|
|
key="upgrade-dashboard"
|
|
icon="sync"
|
|
tooltip="re-upgrade legacy alerts for this dashboard"
|
|
onClick={() => migrateDashboard({ dashboardId: dashUpgrade.dashboardId, skipExisting: false })}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
);
|
|
},
|
|
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 = <T,>(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 (
|
|
<FilterInput
|
|
placeholder={placeholder}
|
|
value={searchFilter}
|
|
width={55}
|
|
escapeRegex={false}
|
|
onChange={(value) => {
|
|
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<DynamicTableItemProps<AlertPair>> = [];
|
|
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<DynamicTableColumnProps<AlertPair>> = [
|
|
{
|
|
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 (
|
|
<Tooltip theme="error" content={alertPair.error}>
|
|
<Icon name="exclamation-circle" className={warning ? styles.warningIcon : styles.errorIcon} size={'lg'} />
|
|
</Tooltip>
|
|
);
|
|
},
|
|
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 <Badge color="red" text={`Deleted Alert: (ID: ${alertPair.legacyAlert?.panelId})`} />;
|
|
}
|
|
return (
|
|
<>
|
|
{dashboardUid ? (
|
|
<Link
|
|
rel="noreferrer"
|
|
target="_blank"
|
|
className={alertPair.legacyAlert.name ? styles.textLink : styles.errorLink}
|
|
href={createUrl(`/d/${encodeURIComponent(dashboardUid)}`, {
|
|
editPanel: String(alertPair.legacyAlert.panelId),
|
|
tab: 'alert',
|
|
})}
|
|
>
|
|
{alertPair.legacyAlert.name || 'Missing Title'}
|
|
</Link>
|
|
) : (
|
|
<Badge color="red" text={alertPair.legacyAlert.name || 'Unknown Alert'} />
|
|
)}
|
|
</>
|
|
);
|
|
},
|
|
size: 5,
|
|
},
|
|
{
|
|
id: 'arrow',
|
|
label: '',
|
|
renderCell: ({ data: alertPair }) => {
|
|
if (!alertPair?.legacyAlert) {
|
|
return null;
|
|
}
|
|
return <Icon name="arrow-right" />;
|
|
},
|
|
size: '45px',
|
|
},
|
|
{
|
|
id: 'alertRule',
|
|
label: 'New alert rule',
|
|
renderCell: ({ data: alertPair }) => {
|
|
return (
|
|
<Stack direction={'row'} gap={1}>
|
|
{alertPair?.alertRule && (
|
|
<Link
|
|
rel="noreferrer"
|
|
target="_blank"
|
|
className={styles.textLink}
|
|
href={createUrl(`/alerting/grafana/${alertPair.alertRule?.uid ?? ''}/view`, {})}
|
|
>
|
|
{alertPair.alertRule?.title ?? ''}
|
|
</Link>
|
|
)}
|
|
</Stack>
|
|
);
|
|
},
|
|
size: 5,
|
|
},
|
|
{
|
|
id: 'contacts',
|
|
label: 'Sends To',
|
|
renderCell: ({ data: alertPair }) => {
|
|
return (
|
|
<>
|
|
{alertPair?.alertRule && (
|
|
<TagList
|
|
tags={alertPair?.alertRule?.sendsTo ?? []}
|
|
displayMax={3}
|
|
className={css({ justifyContent: 'flex-start', width: '100%' })}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
},
|
|
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 (
|
|
<Stack gap={0.5} alignItems="center">
|
|
<Spinner size="sm" inline={true} className={styles.spinner} />
|
|
</Stack>
|
|
);
|
|
}
|
|
if (alertPair?.error === 'alert not upgraded') {
|
|
return (
|
|
<Stack gap={0.5} alignItems="center">
|
|
<ActionIcon
|
|
aria-label="upgrade legacy alert"
|
|
key="upgrade-alert"
|
|
icon="plus"
|
|
tooltip="upgrade legacy alert"
|
|
onClick={() =>
|
|
migrateAlert({
|
|
dashboardId: alertPair.legacyAlert.dashboardId,
|
|
panelId: alertPair.legacyAlert.panelId,
|
|
skipExisting: false,
|
|
})
|
|
}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|
|
if (alertPair?.error?.endsWith('no longer exists')) {
|
|
return (
|
|
<Stack gap={0.5} alignItems="center">
|
|
<ActionIcon
|
|
aria-label="remove upgraded alert"
|
|
key="upgrade-alert"
|
|
icon="minus"
|
|
tooltip="remove upgraded alert"
|
|
onClick={() =>
|
|
migrateAlert({
|
|
dashboardId: alertPair.legacyAlert.dashboardId,
|
|
panelId: alertPair.legacyAlert.panelId,
|
|
skipExisting: false,
|
|
})
|
|
}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|
|
return (
|
|
<Stack gap={0.5} alignItems="center">
|
|
<ActionIcon
|
|
aria-label="re-upgrade legacy alert"
|
|
key="upgrade-alert"
|
|
icon="sync"
|
|
tooltip="re-upgrade legacy alert"
|
|
onClick={() =>
|
|
migrateAlert({
|
|
dashboardId: alertPair.legacyAlert.dashboardId,
|
|
panelId: alertPair.legacyAlert.panelId,
|
|
skipExisting: false,
|
|
})
|
|
}
|
|
/>
|
|
</Stack>
|
|
);
|
|
},
|
|
size: '70px',
|
|
},
|
|
];
|
|
|
|
if (!items.length) {
|
|
return <div className={cx(wrapperClass, styles.emptyMessage)}>{emptyMessage}</div>;
|
|
}
|
|
|
|
const TableComponent = showGuidelines ? DynamicTableWithGuidelines : DynamicTable;
|
|
|
|
return (
|
|
<div className={wrapperClass} data-testid="rules-table">
|
|
<TableComponent
|
|
cols={columns}
|
|
items={items}
|
|
pagination={{ itemsPerPage: 50 }}
|
|
paginationStyles={styles.pagination}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface ErrorSummaryButtonProps {
|
|
count: number;
|
|
onClick: () => void;
|
|
}
|
|
|
|
const ErrorSummaryButton = ({ count, onClick }: ErrorSummaryButtonProps) => {
|
|
return (
|
|
<HorizontalGroup height="auto" justify="flex-start">
|
|
<Tooltip content="Show all errors" placement="top">
|
|
<Button fill="text" variant="destructive" icon="exclamation-circle" onClick={onClick}>
|
|
{count > 1 ? <>{count} errors</> : <>1 error</>}
|
|
</Button>
|
|
</Tooltip>
|
|
</HorizontalGroup>
|
|
);
|
|
};
|
|
|
|
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 && <ErrorSummaryButton count={errors.length} onClick={() => setClosed(false)} />}
|
|
{!!errors.length && !closed && (
|
|
<Alert
|
|
data-testid="upgrade-errors"
|
|
title="Errors upgrading to Grafana Alerting"
|
|
severity="error"
|
|
onRemove={() => setClosed(true)}
|
|
>
|
|
{expanded && errors.map((item, idx) => <div key={idx}>{item}</div>)}
|
|
{!expanded && (
|
|
<>
|
|
<div>{errors[0]}</div>
|
|
{errors.length >= 2 && (
|
|
<Button
|
|
className={styles.moreButton}
|
|
fill="text"
|
|
icon="angle-right"
|
|
size="sm"
|
|
onClick={() => setExpanded(true)}
|
|
>
|
|
{errors.length - 1} more {pluralize('error', errors.length - 1)}
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</Alert>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
interface LoadingProps {
|
|
text?: string;
|
|
}
|
|
|
|
const Loading = ({ text = 'Loading...' }: LoadingProps) => {
|
|
return (
|
|
<div className="page-loader-wrapper">
|
|
<LoadingPlaceholder text={text} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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;
|