Alerting: Alert rules pagination (#50612)

pull/51237/head
Konrad Lalik 3 years ago committed by GitHub
parent 1ca2e2b6c2
commit 765b995b1b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      packages/grafana-ui/src/components/Pagination/Pagination.tsx
  2. 3
      public/app/features/alerting/unified/RuleViewer.tsx
  3. 128
      public/app/features/alerting/unified/components/DynamicTable.tsx
  4. 59
      public/app/features/alerting/unified/components/rules/AlertInstanceStateFilter.tsx
  5. 33
      public/app/features/alerting/unified/components/rules/AlertInstancesTable.tsx
  6. 43
      public/app/features/alerting/unified/components/rules/CloudRules.tsx
  7. 43
      public/app/features/alerting/unified/components/rules/GrafanaRules.tsx
  8. 7
      public/app/features/alerting/unified/components/rules/RuleDetails.tsx
  9. 14
      public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.test.tsx
  10. 65
      public/app/features/alerting/unified/components/rules/RuleDetailsMatchingInstances.tsx
  11. 14
      public/app/features/alerting/unified/components/rules/RulesTable.tsx
  12. 16
      public/app/features/alerting/unified/components/rules/useCombinedGroupNamespace.tsx
  13. 20
      public/app/features/alerting/unified/hooks/usePagination.ts
  14. 12
      public/app/features/alerting/unified/styles/pagination.ts
  15. 4
      public/app/features/alerting/unified/utils/datasource.ts
  16. 9
      public/app/plugins/panel/alertlist/AlertInstances.tsx
  17. 4
      public/app/types/unified-alerting.ts

@ -1,4 +1,4 @@
import { css } from '@emotion/css'; import { css, cx } from '@emotion/css';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useStyles2 } from '../../themes'; import { useStyles2 } from '../../themes';
@ -16,6 +16,7 @@ export interface Props {
hideWhenSinglePage?: boolean; hideWhenSinglePage?: boolean;
/** Small version only shows the current page and the navigation buttons. */ /** Small version only shows the current page and the navigation buttons. */
showSmallVersion?: boolean; showSmallVersion?: boolean;
className?: string;
} }
export const Pagination: React.FC<Props> = ({ export const Pagination: React.FC<Props> = ({
@ -24,6 +25,7 @@ export const Pagination: React.FC<Props> = ({
onNavigate, onNavigate,
hideWhenSinglePage, hideWhenSinglePage,
showSmallVersion, showSmallVersion,
className,
}) => { }) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const pageLengthToCondense = showSmallVersion ? 1 : 8; const pageLengthToCondense = showSmallVersion ? 1 : 8;
@ -96,7 +98,7 @@ export const Pagination: React.FC<Props> = ({
} }
return ( return (
<div className={styles.container}> <div className={cx(styles.container, className)}>
<ol> <ol>
<li className={styles.item}> <li className={styles.item}>
<Button <Button

@ -15,6 +15,7 @@ import {
} from '@grafana/ui'; } from '@grafana/ui';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../core/constants';
import { AlertQuery } from '../../../types/unified-alerting-dto'; import { AlertQuery } from '../../../types/unified-alerting-dto';
import { AlertLabels } from './components/AlertLabels'; import { AlertLabels } from './components/AlertLabels';
@ -179,7 +180,7 @@ export function RuleViewer({ match }: RuleViewerProps) {
</div> </div>
</div> </div>
<div> <div>
<RuleDetailsMatchingInstances rule={rule} /> <RuleDetailsMatchingInstances rule={rule} pagination={{ itemsPerPage: DEFAULT_PER_PAGE_PAGINATION }} />
</div> </div>
</RuleViewerLayoutContent> </RuleViewerLayoutContent>
{!isFederatedRule && data && Object.keys(data).length > 0 && ( {!isFederatedRule && data && Object.keys(data).length > 0 && (

@ -2,7 +2,14 @@ import { css, cx } from '@emotion/css';
import React, { ReactNode, useState } from 'react'; import React, { ReactNode, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, useStyles2 } from '@grafana/ui'; import { IconButton, Pagination, useStyles2 } from '@grafana/ui';
import { usePagination } from '../hooks/usePagination';
import { getPaginationStyles } from '../styles/pagination';
interface DynamicTablePagination {
itemsPerPage: number;
}
export interface DynamicTableColumnProps<T = unknown> { export interface DynamicTableColumnProps<T = unknown> {
id: string | number; id: string | number;
@ -23,6 +30,8 @@ export interface DynamicTableProps<T = unknown> {
items: Array<DynamicTableItemProps<T>>; items: Array<DynamicTableItemProps<T>>;
isExpandable?: boolean; isExpandable?: boolean;
pagination?: DynamicTablePagination;
paginationStyles?: string;
// provide these to manually control expanded status // provide these to manually control expanded status
onCollapse?: (item: DynamicTableItemProps<T>) => void; onCollapse?: (item: DynamicTableItemProps<T>) => void;
@ -41,6 +50,8 @@ export interface DynamicTableProps<T = unknown> {
index: number, index: number,
items: Array<DynamicTableItemProps<T>> items: Array<DynamicTableItemProps<T>>
) => ReactNode; ) => ReactNode;
footerRow?: JSX.Element;
} }
export const DynamicTable = <T extends object>({ export const DynamicTable = <T extends object>({
@ -52,12 +63,16 @@ export const DynamicTable = <T extends object>({
isExpanded, isExpanded,
renderExpandedContent, renderExpandedContent,
testIdGenerator, testIdGenerator,
pagination,
paginationStyles,
// render a cell BEFORE expand icon for header/ each row. // render a cell BEFORE expand icon for header/ each row.
// currently use by RuleList to render guidelines // currently use by RuleList to render guidelines
renderPrefixCell, renderPrefixCell,
renderPrefixHeader, renderPrefixHeader,
footerRow,
}: DynamicTableProps<T>) => { }: DynamicTableProps<T>) => {
const defaultPaginationStyles = useStyles2(getPaginationStyles);
if ((onCollapse || onExpand || isExpanded) && !(onCollapse && onExpand && isExpanded)) { if ((onCollapse || onExpand || isExpanded) && !(onCollapse && onExpand && isExpanded)) {
throw new Error('either all of onCollapse, onExpand, isExpanded must be provided, or none'); throw new Error('either all of onCollapse, onExpand, isExpanded must be provided, or none');
} }
@ -77,50 +92,70 @@ export const DynamicTable = <T extends object>({
); );
} }
}; };
const itemsPerPage = pagination?.itemsPerPage ?? items.length;
const { page, numberOfPages, onPageChange, pageItems } = usePagination(items, 1, itemsPerPage);
return ( return (
<div className={styles.container} data-testid="dynamic-table"> <>
<div className={styles.row} data-testid="header"> <div className={styles.container} data-testid="dynamic-table">
{renderPrefixHeader && renderPrefixHeader()} <div className={styles.row} data-testid="header">
{isExpandable && <div className={styles.cell} />} {renderPrefixHeader && renderPrefixHeader()}
{cols.map((col) => ( {isExpandable && <div className={styles.cell} />}
<div className={styles.cell} key={col.id}> {cols.map((col) => (
{col.label} <div className={styles.cell} key={col.id}>
</div> {col.label}
))} </div>
</div> ))}
</div>
{items.map((item, index) => { {pageItems.map((item, index) => {
const isItemExpanded = isExpanded ? isExpanded(item) : expandedIds.includes(item.id); const isItemExpanded = isExpanded ? isExpanded(item) : expandedIds.includes(item.id);
return ( return (
<div className={styles.row} key={`${item.id}-${index}`} data-testid={testIdGenerator?.(item, index) ?? 'row'}> <div
{renderPrefixCell && renderPrefixCell(item, index, items)} className={styles.row}
{isExpandable && ( key={`${item.id}-${index}`}
<div className={cx(styles.cell, styles.expandCell)}> data-testid={testIdGenerator?.(item, index) ?? 'row'}
<IconButton >
aria-label={`${isItemExpanded ? 'Collapse' : 'Expand'} row`} {renderPrefixCell && renderPrefixCell(item, index, items)}
size="xl" {isExpandable && (
data-testid="collapse-toggle" <div className={cx(styles.cell, styles.expandCell)}>
className={styles.expandButton} <IconButton
name={isItemExpanded ? 'angle-down' : 'angle-right'} aria-label={`${isItemExpanded ? 'Collapse' : 'Expand'} row`}
onClick={() => toggleExpanded(item)} size="xl"
type="button" data-testid="collapse-toggle"
/> className={styles.expandButton}
</div> name={isItemExpanded ? 'angle-down' : 'angle-right'}
)} onClick={() => toggleExpanded(item)}
{cols.map((col) => ( type="button"
<div className={cx(styles.cell, styles.bodyCell)} data-column={col.label} key={`${item.id}-${col.id}`}> />
{col.renderCell(item, index)} </div>
</div> )}
))} {cols.map((col) => (
{isItemExpanded && renderExpandedContent && ( <div className={cx(styles.cell, styles.bodyCell)} data-column={col.label} key={`${item.id}-${col.id}`}>
<div className={styles.expandedContentRow} data-testid="expanded-content"> {col.renderCell(item, index)}
{renderExpandedContent(item, index, items)} </div>
</div> ))}
)} {isItemExpanded && renderExpandedContent && (
</div> <div className={styles.expandedContentRow} data-testid="expanded-content">
); {renderExpandedContent(item, index, items)}
})} </div>
</div> )}
</div>
);
})}
{footerRow && <div className={cx(styles.row, styles.footerRow)}>{footerRow}</div>}
</div>
{pagination && (
<Pagination
className={cx(defaultPaginationStyles, paginationStyles)}
currentPage={page}
numberOfPages={numberOfPages}
onNavigate={onPageChange}
hideWhenSinglePage
/>
)}
</>
); );
}; };
@ -186,6 +221,10 @@ const getStyles = <T extends unknown>(
: ''} : ''}
} }
`, `,
footerRow: css`
display: flex;
padding: ${theme.spacing(1)};
`,
cell: css` cell: css`
align-items: center; align-items: center;
padding: ${theme.spacing(1)}; padding: ${theme.spacing(1)};
@ -197,6 +236,7 @@ const getStyles = <T extends unknown>(
`, `,
bodyCell: css` bodyCell: css`
overflow: hidden; overflow: hidden;
${theme.breakpoints.down('sm')} { ${theme.breakpoints.down('sm')} {
grid-column-end: right; grid-column-end: right;
grid-column-start: right; grid-column-start: right;

@ -1,7 +1,9 @@
import { css } from '@emotion/css';
import { capitalize } from 'lodash'; import { capitalize } from 'lodash';
import React, { useMemo } from 'react'; import React from 'react';
import { Label, RadioButtonGroup } from '@grafana/ui'; import { GrafanaTheme2 } from '@grafana/data/src';
import { Label, RadioButtonGroup, Tag, useStyles2 } from '@grafana/ui';
import { GrafanaAlertState, PromAlertingRuleState } from 'app/types/unified-alerting-dto'; import { GrafanaAlertState, PromAlertingRuleState } from 'app/types/unified-alerting-dto';
export type InstanceStateFilter = GrafanaAlertState | PromAlertingRuleState.Pending | PromAlertingRuleState.Firing; export type InstanceStateFilter = GrafanaAlertState | PromAlertingRuleState.Pending | PromAlertingRuleState.Firing;
@ -11,21 +13,40 @@ interface Props {
filterType: 'grafana' | 'prometheus'; filterType: 'grafana' | 'prometheus';
stateFilter?: InstanceStateFilter; stateFilter?: InstanceStateFilter;
onStateFilterChange: (value?: InstanceStateFilter) => void; onStateFilterChange: (value?: InstanceStateFilter) => void;
itemPerStateStats?: Record<string, number>;
} }
const grafanaOptions = Object.values(GrafanaAlertState).map((value) => ({ export const AlertInstanceStateFilter = ({
label: value, className,
value, onStateFilterChange,
})); stateFilter,
filterType,
itemPerStateStats,
}: Props) => {
const styles = useStyles2(getStyles);
const promOptionValues = [PromAlertingRuleState.Firing, PromAlertingRuleState.Pending] as const; const getOptionComponent = (state: InstanceStateFilter) => {
const promOptions = promOptionValues.map((state) => ({ return function InstanceStateCounter() {
label: capitalize(state), return itemPerStateStats && itemPerStateStats[state] ? (
value: state, <Tag name={itemPerStateStats[state].toFixed(0)} colorIndex={9} className={styles.tag} />
})); ) : null;
};
};
export const AlertInstanceStateFilter = ({ className, onStateFilterChange, stateFilter, filterType }: Props) => { const grafanaOptions = Object.values(GrafanaAlertState).map((state) => ({
const stateOptions = useMemo(() => (filterType === 'grafana' ? grafanaOptions : promOptions), [filterType]); label: state,
value: state,
component: getOptionComponent(state),
}));
const promOptionValues = [PromAlertingRuleState.Firing, PromAlertingRuleState.Pending] as const;
const promOptions = promOptionValues.map((state) => ({
label: capitalize(state),
value: state,
component: getOptionComponent(state),
}));
const stateOptions = filterType === 'grafana' ? grafanaOptions : promOptions;
return ( return (
<div className={className} data-testid="alert-instance-state-filter"> <div className={className} data-testid="alert-instance-state-filter">
@ -43,3 +64,15 @@ export const AlertInstanceStateFilter = ({ className, onStateFilterChange, state
</div> </div>
); );
}; };
function getStyles(theme: GrafanaTheme2) {
return {
tag: css`
font-size: 11px;
font-weight: normal;
padding: ${theme.spacing(0.25, 0.5)};
vertical-align: middle;
margin-left: ${theme.spacing(0.5)};
`,
};
}

@ -1,8 +1,6 @@
import { css } from '@emotion/css';
import React, { FC, useMemo } from 'react'; import React, { FC, useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { Alert, PaginationProps } from 'app/types/unified-alerting';
import { Alert } from 'app/types/unified-alerting';
import { alertInstanceKey } from '../../utils/rules'; import { alertInstanceKey } from '../../utils/rules';
import { AlertLabels } from '../AlertLabels'; import { AlertLabels } from '../AlertLabels';
@ -13,12 +11,14 @@ import { AlertStateTag } from './AlertStateTag';
interface Props { interface Props {
instances: Alert[]; instances: Alert[];
pagination?: PaginationProps;
footerRow?: JSX.Element;
} }
type AlertTableColumnProps = DynamicTableColumnProps<Alert>; type AlertTableColumnProps = DynamicTableColumnProps<Alert>;
type AlertTableItemProps = DynamicTableItemProps<Alert>; type AlertTableItemProps = DynamicTableItemProps<Alert>;
export const AlertInstancesTable: FC<Props> = ({ instances }) => { export const AlertInstancesTable: FC<Props> = ({ instances, pagination, footerRow }) => {
const items = useMemo( const items = useMemo(
(): AlertTableItemProps[] => (): AlertTableItemProps[] =>
instances.map((instance) => ({ instances.map((instance) => ({
@ -34,33 +34,12 @@ export const AlertInstancesTable: FC<Props> = ({ instances }) => {
isExpandable={true} isExpandable={true}
items={items} items={items}
renderExpandedContent={({ data }) => <AlertInstanceDetails instance={data} />} renderExpandedContent={({ data }) => <AlertInstanceDetails instance={data} />}
pagination={pagination}
footerRow={footerRow}
/> />
); );
}; };
export const getStyles = (theme: GrafanaTheme2) => ({
colExpand: css`
width: 36px;
`,
colState: css`
width: 110px;
`,
labelsCell: css`
padding-top: ${theme.spacing(0.5)} !important;
padding-bottom: ${theme.spacing(0.5)} !important;
`,
createdCell: css`
white-space: nowrap;
`,
table: css`
td {
vertical-align: top;
padding-top: ${theme.spacing(1)};
padding-bottom: ${theme.spacing(1)};
}
`,
});
const columns: AlertTableColumnProps[] = [ const columns: AlertTableColumnProps[] = [
{ {
id: 'state', id: 'state',

@ -2,14 +2,18 @@ import { css } from '@emotion/css';
import pluralize from 'pluralize'; import pluralize from 'pluralize';
import React, { FC, useMemo } from 'react'; import React, { FC, useMemo } from 'react';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { LoadingPlaceholder, useStyles } from '@grafana/ui'; import { LoadingPlaceholder, Pagination, useStyles2 } from '@grafana/ui';
import { CombinedRuleNamespace } from 'app/types/unified-alerting'; import { CombinedRuleNamespace } from 'app/types/unified-alerting';
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants';
import { usePagination } from '../../hooks/usePagination';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { getRulesDataSources, getRulesSourceName } from '../../utils/datasource'; import { getPaginationStyles } from '../../styles/pagination';
import { getRulesDataSources, getRulesSourceUid } from '../../utils/datasource';
import { RulesGroup } from './RulesGroup'; import { RulesGroup } from './RulesGroup';
import { useCombinedGroupNamespace } from './useCombinedGroupNamespace';
interface Props { interface Props {
namespaces: CombinedRuleNamespace[]; namespaces: CombinedRuleNamespace[];
@ -17,15 +21,23 @@ interface Props {
} }
export const CloudRules: FC<Props> = ({ namespaces, expandAll }) => { export const CloudRules: FC<Props> = ({ namespaces, expandAll }) => {
const styles = useStyles(getStyles); const styles = useStyles2(getStyles);
const rules = useUnifiedAlertingSelector((state) => state.promRules); const rules = useUnifiedAlertingSelector((state) => state.promRules);
const rulesDataSources = useMemo(getRulesDataSources, []); const rulesDataSources = useMemo(getRulesDataSources, []);
const groupsWithNamespaces = useCombinedGroupNamespace(namespaces);
const dataSourcesLoading = useMemo( const dataSourcesLoading = useMemo(
() => rulesDataSources.filter((ds) => rules[ds.name]?.loading), () => rulesDataSources.filter((ds) => rules[ds.name]?.loading),
[rules, rulesDataSources] [rules, rulesDataSources]
); );
const { numberOfPages, onPageChange, page, pageItems } = usePagination(
groupsWithNamespaces,
1,
DEFAULT_PER_PAGE_PAGINATION
);
return ( return (
<section className={styles.wrapper}> <section className={styles.wrapper}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
@ -40,24 +52,30 @@ export const CloudRules: FC<Props> = ({ namespaces, expandAll }) => {
)} )}
</div> </div>
{namespaces.map((namespace) => { {pageItems.map(({ group, namespace }) => {
const { groups, rulesSource } = namespace; return (
return groups.map((group) => (
<RulesGroup <RulesGroup
group={group} group={group}
key={`${getRulesSourceName(rulesSource)}-${name}-${group.name}`} key={`${getRulesSourceUid(namespace.rulesSource)}-${namespace.name}-${group.name}`}
namespace={namespace} namespace={namespace}
expandAll={expandAll} expandAll={expandAll}
/> />
)); );
})} })}
{namespaces?.length === 0 && !!rulesDataSources.length && <p>No rules found.</p>} {namespaces?.length === 0 && !!rulesDataSources.length && <p>No rules found.</p>}
{!rulesDataSources.length && <p>There are no Prometheus or Loki datas sources configured.</p>} {!rulesDataSources.length && <p>There are no Prometheus or Loki data sources configured.</p>}
<Pagination
className={styles.pagination}
currentPage={page}
numberOfPages={numberOfPages}
onNavigate={onPageChange}
hideWhenSinglePage
/>
</section> </section>
); );
}; };
const getStyles = (theme: GrafanaTheme) => ({ const getStyles = (theme: GrafanaTheme2) => ({
loader: css` loader: css`
margin-bottom: 0; margin-bottom: 0;
`, `,
@ -66,6 +84,7 @@ const getStyles = (theme: GrafanaTheme) => ({
justify-content: space-between; justify-content: space-between;
`, `,
wrapper: css` wrapper: css`
margin-bottom: ${theme.spacing.xl}; margin-bottom: ${theme.spacing(4)};
`, `,
pagination: getPaginationStyles(theme),
}); });

@ -1,17 +1,21 @@
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import React, { FC } from 'react'; import React, { FC } from 'react';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { LoadingPlaceholder, useStyles } from '@grafana/ui'; import { LoadingPlaceholder, Pagination, useStyles2 } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { CombinedRuleNamespace } from 'app/types/unified-alerting'; import { CombinedRuleNamespace } from 'app/types/unified-alerting';
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants';
import { flattenGrafanaManagedRules } from '../../hooks/useCombinedRuleNamespaces'; import { flattenGrafanaManagedRules } from '../../hooks/useCombinedRuleNamespaces';
import { usePagination } from '../../hooks/usePagination';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { getPaginationStyles } from '../../styles/pagination';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { initialAsyncRequestState } from '../../utils/redux'; import { initialAsyncRequestState } from '../../utils/redux';
import { RulesGroup } from './RulesGroup'; import { RulesGroup } from './RulesGroup';
import { useCombinedGroupNamespace } from './useCombinedGroupNamespace';
interface Props { interface Props {
namespaces: CombinedRuleNamespace[]; namespaces: CombinedRuleNamespace[];
@ -19,7 +23,7 @@ interface Props {
} }
export const GrafanaRules: FC<Props> = ({ namespaces, expandAll }) => { export const GrafanaRules: FC<Props> = ({ namespaces, expandAll }) => {
const styles = useStyles(getStyles); const styles = useStyles2(getStyles);
const [queryParams] = useQueryParams(); const [queryParams] = useQueryParams();
const { loading } = useUnifiedAlertingSelector( const { loading } = useUnifiedAlertingSelector(
@ -29,6 +33,14 @@ export const GrafanaRules: FC<Props> = ({ namespaces, expandAll }) => {
const wantsGroupedView = queryParams['view'] === 'grouped'; const wantsGroupedView = queryParams['view'] === 'grouped';
const namespacesFormat = wantsGroupedView ? namespaces : flattenGrafanaManagedRules(namespaces); const namespacesFormat = wantsGroupedView ? namespaces : flattenGrafanaManagedRules(namespaces);
const groupsWithNamespaces = useCombinedGroupNamespace(namespacesFormat);
const { numberOfPages, onPageChange, page, pageItems } = usePagination(
groupsWithNamespaces,
1,
DEFAULT_PER_PAGE_PAGINATION
);
return ( return (
<section className={styles.wrapper}> <section className={styles.wrapper}>
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
@ -36,22 +48,22 @@ export const GrafanaRules: FC<Props> = ({ namespaces, expandAll }) => {
{loading ? <LoadingPlaceholder className={styles.loader} text="Loading..." /> : <div />} {loading ? <LoadingPlaceholder className={styles.loader} text="Loading..." /> : <div />}
</div> </div>
{namespacesFormat?.map((namespace) => {pageItems.map(({ group, namespace }) => (
namespace.groups.map((group) => ( <RulesGroup group={group} key={`${namespace.name}-${group.name}`} namespace={namespace} expandAll={expandAll} />
<RulesGroup ))}
group={group}
key={`${namespace.name}-${group.name}`}
namespace={namespace}
expandAll={expandAll}
/>
))
)}
{namespacesFormat?.length === 0 && <p>No rules found.</p>} {namespacesFormat?.length === 0 && <p>No rules found.</p>}
<Pagination
className={styles.pagination}
currentPage={page}
numberOfPages={numberOfPages}
onNavigate={onPageChange}
hideWhenSinglePage
/>
</section> </section>
); );
}; };
const getStyles = (theme: GrafanaTheme) => ({ const getStyles = (theme: GrafanaTheme2) => ({
loader: css` loader: css`
margin-bottom: 0; margin-bottom: 0;
`, `,
@ -60,6 +72,7 @@ const getStyles = (theme: GrafanaTheme) => ({
justify-content: space-between; justify-content: space-between;
`, `,
wrapper: css` wrapper: css`
margin-bottom: ${theme.spacing.xl}; margin-bottom: ${theme.spacing(4)};
`, `,
pagination: getPaginationStyles(theme),
}); });

@ -18,6 +18,11 @@ interface Props {
rule: CombinedRule; rule: CombinedRule;
} }
// The limit is set to 15 in order to upkeep the good performance
// and to encourage users to go to the rule details page to see the rest of the instances
// We don't want to paginate the instances list on the alert list page
const INSTANCES_DISPLAY_LIMIT = 15;
export const RuleDetails: FC<Props> = ({ rule }) => { export const RuleDetails: FC<Props> = ({ rule }) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const { const {
@ -43,7 +48,7 @@ export const RuleDetails: FC<Props> = ({ rule }) => {
<RuleDetailsDataSources rulesSource={rulesSource} rule={rule} /> <RuleDetailsDataSources rulesSource={rulesSource} rule={rule} />
</div> </div>
</div> </div>
<RuleDetailsMatchingInstances rule={rule} /> <RuleDetailsMatchingInstances rule={rule} itemsDisplayLimit={INSTANCES_DISPLAY_LIMIT} />
</div> </div>
); );
}; };

@ -14,15 +14,15 @@ const ui = {
stateFilter: byTestId('alert-instance-state-filter'), stateFilter: byTestId('alert-instance-state-filter'),
stateButton: byRole('radio'), stateButton: byRole('radio'),
grafanaStateButton: { grafanaStateButton: {
normal: byLabelText('Normal'), normal: byLabelText(/^Normal/),
alerting: byLabelText('Alerting'), alerting: byLabelText(/^Alerting/),
pending: byLabelText('Pending'), pending: byLabelText(/^Pending/),
noData: byLabelText('NoData'), noData: byLabelText(/^NoData/),
error: byLabelText('Error'), error: byLabelText(/^Error/),
}, },
cloudStateButton: { cloudStateButton: {
firing: byLabelText('Firing'), firing: byLabelText(/^Firing/),
pending: byLabelText('Pending'), pending: byLabelText(/^Pending/),
}, },
instanceRow: byTestId('row'), instanceRow: byTestId('row'),
}; };

@ -1,17 +1,18 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { countBy } from 'lodash';
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { useStyles } from '@grafana/ui'; import { LinkButton, useStyles } from '@grafana/ui';
import { MatcherFilter } from 'app/features/alerting/unified/components/alert-groups/MatcherFilter'; import { MatcherFilter } from 'app/features/alerting/unified/components/alert-groups/MatcherFilter';
import { import {
AlertInstanceStateFilter, AlertInstanceStateFilter,
InstanceStateFilter, InstanceStateFilter,
} from 'app/features/alerting/unified/components/rules/AlertInstanceStateFilter'; } from 'app/features/alerting/unified/components/rules/AlertInstanceStateFilter';
import { labelsMatchMatchers, parseMatchers } from 'app/features/alerting/unified/utils/alertmanager'; import { labelsMatchMatchers, parseMatchers } from 'app/features/alerting/unified/utils/alertmanager';
import { sortAlerts } from 'app/features/alerting/unified/utils/misc'; import { createViewLink, sortAlerts } from 'app/features/alerting/unified/utils/misc';
import { SortOrder } from 'app/plugins/panel/alertlist/types'; import { SortOrder } from 'app/plugins/panel/alertlist/types';
import { Alert, CombinedRule } from 'app/types/unified-alerting'; import { Alert, CombinedRule, PaginationProps } from 'app/types/unified-alerting';
import { mapStateWithReasonToBaseState } from 'app/types/unified-alerting-dto'; import { mapStateWithReasonToBaseState } from 'app/types/unified-alerting-dto';
import { GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../../utils/datasource'; import { GRAFANA_RULES_SOURCE_NAME, isGrafanaRulesSource } from '../../utils/datasource';
@ -20,13 +21,40 @@ import { DetailsField } from '../DetailsField';
import { AlertInstancesTable } from './AlertInstancesTable'; import { AlertInstancesTable } from './AlertInstancesTable';
type Props = { interface Props {
rule: CombinedRule; rule: CombinedRule;
}; pagination?: PaginationProps;
itemsDisplayLimit?: number;
}
interface ShowMoreStats {
totalItemsCount: number;
visibleItemsCount: number;
}
function ShowMoreInstances(props: { ruleViewPageLink: string; stats: ShowMoreStats }) {
const styles = useStyles(getStyles);
const { ruleViewPageLink, stats } = props;
return (
<div className={styles.footerRow}>
<div>
Showing {stats.visibleItemsCount} out of {stats.totalItemsCount} instances
</div>
{ruleViewPageLink && (
<LinkButton href={ruleViewPageLink} size="sm" variant="secondary">
Show all {stats.totalItemsCount} alert instances
</LinkButton>
)}
</div>
);
}
export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null { export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null {
const { const {
rule: { promRule, namespace }, rule: { promRule, namespace },
itemsDisplayLimit = Number.POSITIVE_INFINITY,
pagination,
} = props; } = props;
const [queryString, setQueryString] = useState<string>(); const [queryString, setQueryString] = useState<string>();
@ -52,6 +80,22 @@ export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null {
return null; return null;
} }
const visibleInstances = alerts.slice(0, itemsDisplayLimit);
const countAllByState = countBy(promRule.alerts, (alert) => mapStateWithReasonToBaseState(alert.state));
const hiddenItemsCount = alerts.length - visibleInstances.length;
const stats: ShowMoreStats = {
totalItemsCount: alerts.length,
visibleItemsCount: visibleInstances.length,
};
const ruleViewPageLink = createViewLink(namespace.rulesSource, props.rule, location.pathname + location.search);
const footerRow = hiddenItemsCount ? (
<ShowMoreInstances stats={stats} ruleViewPageLink={ruleViewPageLink} />
) : undefined;
return ( return (
<DetailsField label="Matching instances" horizontal={true}> <DetailsField label="Matching instances" horizontal={true}>
<div className={cx(styles.flexRow, styles.spaceBetween)}> <div className={cx(styles.flexRow, styles.spaceBetween)}>
@ -67,11 +111,12 @@ export function RuleDetailsMatchingInstances(props: Props): JSX.Element | null {
filterType={stateFilterType} filterType={stateFilterType}
stateFilter={alertState} stateFilter={alertState}
onStateFilterChange={setAlertState} onStateFilterChange={setAlertState}
itemPerStateStats={countAllByState}
/> />
</div> </div>
</div> </div>
<AlertInstancesTable instances={alerts} /> <AlertInstancesTable instances={visibleInstances} pagination={pagination} footerRow={footerRow} />
</DetailsField> </DetailsField>
); );
} }
@ -111,5 +156,13 @@ const getStyles = (theme: GrafanaTheme) => {
rowChild: css` rowChild: css`
margin-right: ${theme.spacing.sm}; margin-right: ${theme.spacing.sm};
`, `,
footerRow: css`
display: flex;
flex-direction: column;
gap: ${theme.spacing.sm};
justify-content: space-between;
align-items: center;
width: 100%;
`,
}; };
}; };

@ -5,6 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui'; import { useStyles2 } from '@grafana/ui';
import { CombinedRule } from 'app/types/unified-alerting'; import { CombinedRule } from 'app/types/unified-alerting';
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../../core/constants';
import { useHasRuler } from '../../hooks/useHasRuler'; import { useHasRuler } from '../../hooks/useHasRuler';
import { Annotation } from '../../utils/constants'; import { Annotation } from '../../utils/constants';
import { isGrafanaRulerRule } from '../../utils/rules'; import { isGrafanaRulerRule } from '../../utils/rules';
@ -71,6 +72,8 @@ export const RulesTable: FC<Props> = ({
isExpandable={true} isExpandable={true}
items={items} items={items}
renderExpandedContent={({ data: rule }) => <RuleDetails rule={rule} />} renderExpandedContent={({ data: rule }) => <RuleDetails rule={rule} />}
pagination={{ itemsPerPage: DEFAULT_PER_PAGE_PAGINATION }}
paginationStyles={styles.pagination}
/> />
</div> </div>
); );
@ -87,9 +90,18 @@ export const getStyles = (theme: GrafanaTheme2) => ({
`, `,
wrapper: css` wrapper: css`
width: auto; width: auto;
background-color: ${theme.colors.background.secondary};
border-radius: ${theme.shape.borderRadius()}; border-radius: ${theme.shape.borderRadius()};
`, `,
pagination: css`
display: flex;
margin: 0;
padding-top: ${theme.spacing(1)};
padding-bottom: ${theme.spacing(0.25)};
justify-content: center;
border-left: 1px solid ${theme.colors.border.strong};
border-right: 1px solid ${theme.colors.border.strong};
border-bottom: 1px solid ${theme.colors.border.strong};
`,
}); });
function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean) { function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean) {

@ -0,0 +1,16 @@
import { useMemo } from 'react';
import { CombinedRuleNamespace } from '../../../../../types/unified-alerting';
export function useCombinedGroupNamespace(namespaces: CombinedRuleNamespace[]) {
return useMemo(
() =>
namespaces.flatMap((ns) =>
ns.groups.map((g) => ({
namespace: ns,
group: g,
}))
),
[namespaces]
);
}

@ -1,18 +1,24 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
export function usePagination<T>(items: T[], initialPage: number, itemsPerPage: number) { export function usePagination<T>(items: T[], initialPage: number, itemsPerPage: number) {
const [page, setPage] = useState(initialPage); const [page, setPage] = useState(initialPage);
const numberOfPages = Math.ceil(items.length / itemsPerPage); const numberOfPages = Math.ceil(items.length / itemsPerPage);
const firstItemOnPageIndex = itemsPerPage * (page - 1); const firstItemOnPageIndex = itemsPerPage * (page - 1);
const pageItems = items.slice(firstItemOnPageIndex, firstItemOnPageIndex + itemsPerPage);
const onPageChange = (newPage: number) => { const pageItems = useMemo(
setPage(newPage); () => items.slice(firstItemOnPageIndex, firstItemOnPageIndex + itemsPerPage),
}; [items, firstItemOnPageIndex, itemsPerPage]
);
const onPageChange = useCallback(
(newPage: number) => {
setPage(newPage);
},
[setPage]
);
// Reset the current page when number of changes has been changed // Reset the current page when number of pages has been changed
useEffect(() => setPage(1), [numberOfPages]); useEffect(() => setPage(1), [numberOfPages]);
return { page, onPageChange, numberOfPages, pageItems }; return { page, onPageChange, numberOfPages, pageItems };

@ -0,0 +1,12 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data/src';
export const getPaginationStyles = (theme: GrafanaTheme2) => {
return css`
float: none;
display: flex;
justify-content: flex-start;
margin: ${theme.spacing(2, 0)};
`;
};

@ -123,6 +123,10 @@ export function getRulesSourceName(rulesSource: RulesSource): string {
return isCloudRulesSource(rulesSource) ? rulesSource.name : rulesSource; return isCloudRulesSource(rulesSource) ? rulesSource.name : rulesSource;
} }
export function getRulesSourceUid(rulesSource: RulesSource): string {
return isCloudRulesSource(rulesSource) ? rulesSource.uid : GRAFANA_RULES_SOURCE_NAME;
}
export function isCloudRulesSource(rulesSource: RulesSource | string): rulesSource is DataSourceInstanceSettings { export function isCloudRulesSource(rulesSource: RulesSource | string): rulesSource is DataSourceInstanceSettings {
return rulesSource !== GRAFANA_RULES_SOURCE_NAME; return rulesSource !== GRAFANA_RULES_SOURCE_NAME;
} }

@ -9,6 +9,8 @@ import { AlertInstancesTable } from 'app/features/alerting/unified/components/ru
import { sortAlerts } from 'app/features/alerting/unified/utils/misc'; import { sortAlerts } from 'app/features/alerting/unified/utils/misc';
import { Alert } from 'app/types/unified-alerting'; import { Alert } from 'app/types/unified-alerting';
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../core/constants';
import { GroupMode, UnifiedAlertListOptions } from './types'; import { GroupMode, UnifiedAlertListOptions } from './types';
import { filterAlerts } from './util'; import { filterAlerts } from './util';
@ -52,7 +54,12 @@ export const AlertInstances: FC<Props> = ({ alerts, options }) => {
{hiddenInstances > 0 && <span>, {`${hiddenInstances} hidden by filters`}</span>} {hiddenInstances > 0 && <span>, {`${hiddenInstances} hidden by filters`}</span>}
</div> </div>
)} )}
{displayInstances && <AlertInstancesTable instances={filteredAlerts} />} {displayInstances && (
<AlertInstancesTable
instances={filteredAlerts}
pagination={{ itemsPerPage: 2 * DEFAULT_PER_PAGE_PAGINATION }}
/>
)}
</div> </div>
); );
}; };

@ -193,3 +193,7 @@ export interface PromBasedDataSource {
id: string | number; id: string | number;
rulerConfig?: RulerDataSourceConfig; rulerConfig?: RulerDataSourceConfig;
} }
export interface PaginationProps {
itemsPerPage: number;
}

Loading…
Cancel
Save