Alerting: make rule list page work on mobile (#35991)

pull/36074/head
Domas 4 years ago committed by GitHub
parent 8660d0692e
commit e5f6ab7fd2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 29
      public/app/features/alerting/unified/RuleList.test.tsx
  2. 19
      public/app/features/alerting/unified/components/DetailsField.tsx
  3. 135
      public/app/features/alerting/unified/components/DynamicTable.tsx
  4. 149
      public/app/features/alerting/unified/components/amroutes/AmRoutesTable.tsx
  5. 120
      public/app/features/alerting/unified/components/rules/AlertInstancesTable.tsx
  6. 31
      public/app/features/alerting/unified/components/rules/RuleDetails.tsx
  7. 2
      public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx
  8. 6
      public/app/features/alerting/unified/components/rules/RuleState.tsx
  9. 7
      public/app/features/alerting/unified/components/rules/RulesFilter.tsx
  10. 7
      public/app/features/alerting/unified/components/rules/RulesGroup.tsx
  11. 240
      public/app/features/alerting/unified/components/rules/RulesTable.tsx
  12. 61
      public/app/features/alerting/unified/utils/dynamicTable.ts

@ -65,9 +65,10 @@ const ui = {
ruleGroup: byTestId('rule-group'),
cloudRulesSourceErrors: byTestId('cloud-rulessource-errors'),
groupCollapseToggle: byTestId('group-collapse-toggle'),
ruleCollapseToggle: byTestId('rule-collapse-toggle'),
alertCollapseToggle: byTestId('alert-collapse-toggle'),
ruleCollapseToggle: byTestId('collapse-toggle'),
rulesTable: byTestId('rules-table'),
ruleRow: byTestId('row'),
expandedContent: byTestId('expanded-content'),
};
describe('RuleList', () => {
@ -241,7 +242,7 @@ describe('RuleList', () => {
const table = await ui.rulesTable.find(groups[1]);
// check that rule rows are rendered properly
let ruleRows = table.querySelectorAll<HTMLTableRowElement>(':scope > tbody > tr');
let ruleRows = ui.ruleRow.getAll(table);
expect(ruleRows).toHaveLength(4);
expect(ruleRows[0]).toHaveTextContent('Recording rule');
@ -261,10 +262,7 @@ describe('RuleList', () => {
// expand alert details
userEvent.click(ui.ruleCollapseToggle.get(ruleRows[1]));
ruleRows = table.querySelectorAll<HTMLTableRowElement>(':scope > tbody > tr');
expect(ruleRows).toHaveLength(5);
const ruleDetails = ruleRows[2];
const ruleDetails = ui.expandedContent.get(ruleRows[1]);
expect(ruleDetails).toHaveTextContent('Labelsseverity=warningfoo=bar');
expect(ruleDetails).toHaveTextContent('Expressiontopk ( 5 , foo ) [ 5m ]');
@ -272,28 +270,25 @@ describe('RuleList', () => {
expect(ruleDetails).toHaveTextContent('Matching instances');
// finally, check instances table
const instancesTable = ruleDetails.querySelector('table');
const instancesTable = byTestId('dynamic-table').get(ruleDetails);
expect(instancesTable).toBeInTheDocument();
let instanceRows = instancesTable?.querySelectorAll<HTMLTableRowElement>(':scope > tbody > tr');
const instanceRows = byTestId('row').getAll(instancesTable);
expect(instanceRows).toHaveLength(2);
expect(instanceRows![0]).toHaveTextContent('Firingfoo=barseverity=warning2021-03-18 13:47:05');
expect(instanceRows![1]).toHaveTextContent('Firingfoo=bazseverity=error2021-03-18 13:47:05');
// expand details of an instance
userEvent.click(ui.alertCollapseToggle.get(instanceRows![0]));
instanceRows = instancesTable?.querySelectorAll<HTMLTableRowElement>(':scope > tbody > tr')!;
expect(instanceRows).toHaveLength(3);
userEvent.click(ui.ruleCollapseToggle.get(instanceRows![0]));
const alertDetails = instanceRows[1];
const alertDetails = byTestId('expanded-content').get(instanceRows[0]);
expect(alertDetails).toHaveTextContent('Value2e+10');
expect(alertDetails).toHaveTextContent('messagefirst alert message');
// collapse everything again
userEvent.click(ui.alertCollapseToggle.get(instanceRows![0]));
expect(instancesTable?.querySelectorAll<HTMLTableRowElement>(':scope > tbody > tr')).toHaveLength(2);
userEvent.click(ui.ruleCollapseToggle.get(ruleRows[1]));
expect(table.querySelectorAll<HTMLTableRowElement>(':scope > tbody > tr')).toHaveLength(4);
userEvent.click(ui.ruleCollapseToggle.get(instanceRows![0]));
expect(byTestId('expanded-content').query(instanceRows[0])).not.toBeInTheDocument();
userEvent.click(ui.ruleCollapseToggle.getAll(ruleRows[1])[0]);
userEvent.click(ui.groupCollapseToggle.get(groups[1]));
expect(ui.rulesTable.query()).not.toBeInTheDocument();
});

@ -1,7 +1,7 @@
import React, { FC } from 'react';
import { css, cx } from '@emotion/css';
import { GrafanaTheme } from '@grafana/data';
import { useStyles } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
interface Props {
label: React.ReactNode;
@ -10,7 +10,7 @@ interface Props {
}
export const DetailsField: FC<Props> = ({ className, label, horizontal, children }) => {
const styles = useStyles(getStyles);
const styles = useStyles2(getStyles);
return (
<div className={cx(className, styles.field, horizontal ? styles.fieldHorizontal : styles.fieldVertical)}>
@ -20,27 +20,30 @@ export const DetailsField: FC<Props> = ({ className, label, horizontal, children
);
};
const getStyles = (theme: GrafanaTheme) => ({
const getStyles = (theme: GrafanaTheme2) => ({
fieldHorizontal: css`
flex-direction: row;
${theme.breakpoints.down('md')} {
flex-direction: column;
}
`,
fieldVertical: css`
flex-direction: column;
`,
field: css`
display: flex;
margin: ${theme.spacing.md} 0;
margin: ${theme.spacing(2)} 0;
& > div:first-child {
width: 110px;
padding-right: ${theme.spacing.sm};
padding-right: ${theme.spacing(1)};
font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.weight.semibold};
font-weight: ${theme.typography.fontWeightBold};
line-height: 1.8;
}
& > div:nth-child(2) {
flex: 1;
color: ${theme.colors.textSemiWeak};
color: ${theme.colors.text.secondary};
}
`,
});

@ -1,23 +1,20 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, useState } from 'react';
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, useStyles2, useTheme2 } from '@grafana/ui';
import { useMedia } from 'react-use';
import { IconButton, useStyles2 } from '@grafana/ui';
export interface DynamicTableColumnProps<T = unknown> {
id: string | number;
label: string;
renderCell?: (item: DynamicTableItemProps<T>, index: number) => ReactNode;
renderCell: (item: DynamicTableItemProps<T>, index: number) => ReactNode;
size?: number | string;
}
export interface DynamicTableItemProps<T = unknown> {
id: string | number;
data: T;
renderExpandedContent?: () => ReactNode;
isExpanded?: boolean;
}
export interface DynamicTableProps<T = unknown> {
@ -25,10 +22,16 @@ export interface DynamicTableProps<T = unknown> {
items: Array<DynamicTableItemProps<T>>;
isExpandable?: boolean;
onCollapse?: (id: DynamicTableItemProps<T>) => void;
onExpand?: (id: DynamicTableItemProps<T>) => void;
// provide these to manually control expanded status
onCollapse?: (item: DynamicTableItemProps<T>) => void;
onExpand?: (item: DynamicTableItemProps<T>) => void;
isExpanded?: (item: DynamicTableItemProps<T>) => boolean;
renderExpandedContent?: (item: DynamicTableItemProps<T>, index: number) => ReactNode;
testIdGenerator?: (item: DynamicTableItemProps<T>) => string;
testIdGenerator?: (item: DynamicTableItemProps<T>, index: number) => string;
renderPrefixHeader?: () => ReactNode;
renderPrefixCell?: (item: DynamicTableItemProps<T>, index: number) => ReactNode;
}
export const DynamicTable = <T extends object>({
@ -37,16 +40,38 @@ export const DynamicTable = <T extends object>({
isExpandable = false,
onCollapse,
onExpand,
isExpanded,
renderExpandedContent,
testIdGenerator,
// render a cell BEFORE expand icon for header/ each row.
// currently use by RuleList to render guidelines
renderPrefixCell,
renderPrefixHeader,
}: DynamicTableProps<T>) => {
const styles = useStyles2(getStyles(cols, isExpandable));
const theme = useTheme2();
const isMobile = useMedia(`(${theme.breakpoints.down('sm')})`);
if ((onCollapse || onExpand || isExpanded) && !(onCollapse && onExpand && isExpanded)) {
throw new Error('either all of onCollapse, onExpand, isExpanded must be provided, or none');
}
if ((isExpandable || renderExpandedContent) && !(isExpandable && renderExpandedContent)) {
throw new Error('either both isExpanded and renderExpandedContent must be provided, or neither');
}
const styles = useStyles2(getStyles(cols, isExpandable, !!renderPrefixHeader));
const [expandedIds, setExpandedIds] = useState<Array<DynamicTableItemProps['id']>>([]);
const toggleExpanded = (item: DynamicTableItemProps<T>) => {
if (isExpanded && onCollapse && onExpand) {
isExpanded(item) ? onCollapse(item) : onExpand(item);
} else {
setExpandedIds(
expandedIds.includes(item.id) ? expandedIds.filter((itemId) => itemId !== item.id) : [...expandedIds, item.id]
);
}
};
return (
<div className={styles.container}>
<div className={styles.row}>
<div className={styles.container} data-testid="dynamic-table">
<div className={styles.row} data-testid="header">
{renderPrefixHeader && renderPrefixHeader()}
{isExpandable && <div className={styles.cell} />}
{cols.map((col) => (
<div className={styles.cell} key={col.id}>
@ -55,36 +80,45 @@ export const DynamicTable = <T extends object>({
))}
</div>
{items.map((item, index) => (
<div className={styles.row} key={item.id} data-testid={testIdGenerator?.(item)}>
{isExpandable && (
<div className={cx(styles.cell, styles.expandCell)}>
<IconButton
size={isMobile ? 'xl' : 'md'}
className={styles.expandButton}
name={item.isExpanded ? 'angle-down' : 'angle-right'}
onClick={() => (item.isExpanded ? onCollapse?.(item) : onExpand?.(item))}
type="button"
/>
</div>
)}
{cols.map((col) => (
<div className={cx(styles.cell, styles.bodyCell)} data-column={col.label} key={`${item.id}-${col.id}`}>
{col.renderCell?.(item, index)}
</div>
))}
{item.isExpanded && (
<div className={styles.expandedContentRow}>
{item.renderExpandedContent ? item.renderExpandedContent() : renderExpandedContent?.(item, index)}
</div>
)}
</div>
))}
{items.map((item, index) => {
const isItemExpanded = isExpanded ? isExpanded(item) : expandedIds.includes(item.id);
return (
<div className={styles.row} key={item.id} data-testid={testIdGenerator?.(item, index) ?? 'row'}>
{renderPrefixCell && renderPrefixCell(item, index)}
{isExpandable && (
<div className={cx(styles.cell, styles.expandCell)}>
<IconButton
size="xl"
data-testid="collapse-toggle"
className={styles.expandButton}
name={isItemExpanded ? 'angle-down' : 'angle-right'}
onClick={() => toggleExpanded(item)}
type="button"
/>
</div>
)}
{cols.map((col) => (
<div className={cx(styles.cell, styles.bodyCell)} data-column={col.label} key={`${item.id}-${col.id}`}>
{col.renderCell(item, index)}
</div>
))}
{isItemExpanded && renderExpandedContent && (
<div className={styles.expandedContentRow} data-testid="expanded-content">
{renderExpandedContent(item, index)}
</div>
)}
</div>
);
})}
</div>
);
};
const getStyles = <T extends unknown>(cols: Array<DynamicTableColumnProps<T>>, isExpandable: boolean) => {
const getStyles = <T extends unknown>(
cols: Array<DynamicTableColumnProps<T>>,
isExpandable: boolean,
hasPrefixCell: boolean
) => {
const sizes = cols.map((col) => {
if (!col.size) {
return 'auto';
@ -101,6 +135,10 @@ const getStyles = <T extends unknown>(cols: Array<DynamicTableColumnProps<T>>, i
sizes.unshift('calc(1em + 16px)');
}
if (hasPrefixCell) {
sizes.unshift('0');
}
return (theme: GrafanaTheme2) => ({
container: css`
border: 1px solid ${theme.colors.border.strong};
@ -128,11 +166,18 @@ const getStyles = <T extends unknown>(cols: Array<DynamicTableColumnProps<T>>, i
&:first-child {
display: none;
}
${hasPrefixCell
? `
& > *:first-child {
display: none;
}
`
: ''}
}
`,
cell: css`
align-items: center;
display: grid;
padding: ${theme.spacing(1)};
${theme.breakpoints.down('sm')} {
@ -141,12 +186,16 @@ const getStyles = <T extends unknown>(cols: Array<DynamicTableColumnProps<T>>, i
}
`,
bodyCell: css`
overflow: hidden;
word-break: break-all;
${theme.breakpoints.down('sm')} {
grid-column-end: right;
grid-column-start: right;
&::before {
content: attr(data-column);
display: block;
color: ${theme.colors.text.primary};
}
}
`,
@ -160,11 +209,13 @@ const getStyles = <T extends unknown>(cols: Array<DynamicTableColumnProps<T>>, i
`,
expandedContentRow: css`
grid-column-end: ${sizes.length + 1};
grid-column-start: 2;
grid-column-start: ${hasPrefixCell ? 3 : 2};
grid-row: 2;
padding: 0 ${theme.spacing(3)} 0 ${theme.spacing(1)};
position: relative;
${theme.breakpoints.down('sm')} {
grid-column-start: 2;
border-top: 1px solid ${theme.colors.border.strong};
grid-row: auto;
padding: ${theme.spacing(1)} 0 0 0;

@ -1,13 +1,7 @@
import React, { FC, useCallback, useEffect, useState } from 'react';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { Button, HorizontalGroup, IconButton } from '@grafana/ui';
import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes';
import {
addCustomExpandedContent,
collapseItem,
expandItem,
prepareItems,
removeCustomExpandedContent,
} from '../../utils/dynamicTable';
import { prepareItems } from '../../utils/dynamicTable';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
import { AmRoutesExpandedForm } from './AmRoutesExpandedForm';
import { AmRoutesExpandedRead } from './AmRoutesExpandedRead';
@ -25,42 +19,13 @@ type RouteTableColumnProps = DynamicTableColumnProps<FormAmRoute>;
type RouteTableItemProps = DynamicTableItemProps<FormAmRoute>;
export const AmRoutesTable: FC<AmRoutesTableProps> = ({ isAddMode, onCancelAdd, onChange, receivers, routes }) => {
const [items, setItems] = useState<RouteTableItemProps[]>([]);
const getRenderEditExpandedContent = useCallback(
// eslint-disable-next-line react/display-name
(item: RouteTableItemProps, index: number) => () => (
<AmRoutesExpandedForm
onCancel={() => {
setItems((items) => {
let newItems = collapseItem(items, item.id);
newItems = removeCustomExpandedContent(newItems, item.id);
return newItems;
});
if (isAddMode) {
onCancelAdd();
}
}}
onSave={(data) => {
const newRoutes = [...routes];
newRoutes[index] = {
...newRoutes[index],
...data,
};
setItems((items) => collapseItem(items, item.id));
onChange(newRoutes);
}}
receivers={receivers}
routes={item.data}
/>
),
[isAddMode, onCancelAdd, onChange, receivers, routes]
);
const [editMode, setEditMode] = useState(false);
const [expandedId, setExpandedId] = useState<string | number>();
const expandItem = useCallback((item: RouteTableItemProps) => setExpandedId(item.id), []);
const collapseItem = useCallback(() => setExpandedId(undefined), []);
const cols: RouteTableColumnProps[] = [
{
@ -91,13 +56,10 @@ export const AmRoutesTable: FC<AmRoutesTableProps> = ({ isAddMode, onCancelAdd,
return null;
}
const expandWithCustomContent = () =>
setItems((items) => {
let newItems = expandItem(items, item.id);
newItems = addCustomExpandedContent(newItems, item.id, getRenderEditExpandedContent(item, index));
return newItems;
});
const expandWithCustomContent = () => {
expandItem(item);
setEditMode(true);
};
return (
<HorizontalGroup>
@ -122,50 +84,63 @@ export const AmRoutesTable: FC<AmRoutesTableProps> = ({ isAddMode, onCancelAdd,
},
];
useEffect(() => {
const items = prepareItems(routes).map((item, index, arr) => {
if (isAddMode && index === arr.length - 1) {
return {
...item,
isExpanded: true,
renderExpandedContent: getRenderEditExpandedContent(item, index),
};
}
const items = useMemo(() => prepareItems(routes), [routes]);
return {
...item,
isExpanded: false,
renderExpandedContent: undefined,
};
});
setItems(items);
}, [routes, getRenderEditExpandedContent, isAddMode]);
// expand the last item when adding
useEffect(() => {
if (isAddMode && items.length) {
setExpandedId(items[items.length - 1].id);
}
}, [isAddMode, items]);
return (
<DynamicTable
cols={cols}
isExpandable={true}
items={items}
onCollapse={(item: RouteTableItemProps) => setItems((items) => collapseItem(items, item.id))}
onExpand={(item: RouteTableItemProps) => setItems((items) => expandItem(items, item.id))}
testIdGenerator={() => 'am-routes-row'}
renderExpandedContent={(item: RouteTableItemProps, index) => (
<AmRoutesExpandedRead
onChange={(data) => {
const newRoutes = [...routes];
newRoutes[index] = {
...item.data,
...data,
};
onChange(newRoutes);
}}
receivers={receivers}
routes={item.data}
/>
)}
onCollapse={collapseItem}
onExpand={expandItem}
isExpanded={(item) => expandedId === item.id}
renderExpandedContent={(item: RouteTableItemProps, index) =>
isAddMode || editMode ? (
<AmRoutesExpandedForm
onCancel={() => {
if (isAddMode) {
onCancelAdd();
}
setEditMode(false);
}}
onSave={(data) => {
const newRoutes = [...routes];
newRoutes[index] = {
...newRoutes[index],
...data,
};
setEditMode(false);
onChange(newRoutes);
}}
receivers={receivers}
routes={item.data}
/>
) : (
<AmRoutesExpandedRead
onChange={(data) => {
const newRoutes = [...routes];
newRoutes[index] = {
...item.data,
...data,
};
onChange(newRoutes);
}}
receivers={receivers}
routes={item.data}
/>
)
}
/>
);
};

@ -1,101 +1,40 @@
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { Alert } from 'app/types/unified-alerting';
import { css, cx } from '@emotion/css';
import React, { FC, Fragment, useMemo, useState } from 'react';
import { getAlertTableStyles } from '../../styles/table';
import { css } from '@emotion/css';
import React, { FC, useMemo } from 'react';
import { alertInstanceKey } from '../../utils/rules';
import { AlertLabels } from '../AlertLabels';
import { CollapseToggle } from '../CollapseToggle';
import { AlertInstanceDetails } from './AlertInstanceDetails';
import { AlertStateTag } from './AlertStateTag';
type AlertWithKey = Alert & { key: string };
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
interface Props {
instances: Alert[];
}
export const AlertInstancesTable: FC<Props> = ({ instances }) => {
const styles = useStyles2(getStyles);
const tableStyles = useStyles2(getAlertTableStyles);
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
type AlertTableColumnProps = DynamicTableColumnProps<Alert>;
type AlertTableItemProps = DynamicTableItemProps<Alert>;
export const AlertInstancesTable: FC<Props> = ({ instances }) => {
// add key & sort instance. API returns instances in random order, different every time.
const sortedInstances = useMemo(
(): AlertWithKey[] =>
const items = useMemo(
(): AlertTableItemProps[] =>
instances
.map((instance) => ({
...instance,
key: alertInstanceKey(instance),
data: instance,
id: alertInstanceKey(instance),
}))
.sort((a, b) => a.key.localeCompare(b.key)),
.sort((a, b) => a.id.localeCompare(b.id)),
[instances]
);
const toggleExpandedState = (ruleKey: string) =>
setExpandedKeys(
expandedKeys.includes(ruleKey) ? expandedKeys.filter((key) => key !== ruleKey) : [...expandedKeys, ruleKey]
);
return (
<table className={cx(tableStyles.table, styles.table)}>
<colgroup>
<col className={styles.colExpand} />
<col className={styles.colState} />
<col />
<col />
</colgroup>
<thead>
<tr>
<th></th>
<th>State</th>
<th>Labels</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{sortedInstances.map(({ key, ...instance }, idx) => {
const isExpanded = expandedKeys.includes(key);
// don't allow expanding if there's nothing to show
const isExpandable = instance.value || !!Object.keys(instance.annotations ?? {}).length;
return (
<Fragment key={key}>
<tr className={idx % 2 === 0 ? tableStyles.evenRow : undefined}>
<td>
{isExpandable && (
<CollapseToggle
isCollapsed={!isExpanded}
onToggle={() => toggleExpandedState(key)}
data-testid="alert-collapse-toggle"
/>
)}
</td>
<td>
<AlertStateTag state={instance.state} />
</td>
<td className={styles.labelsCell}>
<AlertLabels labels={instance.labels} />
</td>
<td className={styles.createdCell}>
{instance.activeAt.startsWith('0001') ? '-' : instance.activeAt.substr(0, 19).replace('T', ' ')}
</td>
</tr>
{isExpanded && isExpandable && (
<tr className={idx % 2 === 0 ? tableStyles.evenRow : undefined}>
<td></td>
<td colSpan={3}>
<AlertInstanceDetails instance={instance} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
<DynamicTable
cols={columns}
isExpandable={true}
items={items}
renderExpandedContent={({ data }) => <AlertInstanceDetails instance={data} />}
/>
);
};
@ -121,3 +60,28 @@ export const getStyles = (theme: GrafanaTheme2) => ({
}
`,
});
const columns: AlertTableColumnProps[] = [
{
id: 'state',
label: 'State',
// eslint-disable-next-line react/display-name
renderCell: ({ data: { state } }) => <AlertStateTag state={state} />,
size: '80px',
},
{
id: 'labels',
label: 'Labels',
// eslint-disable-next-line react/display-name
renderCell: ({ data: { labels } }) => <AlertLabels labels={labels} />,
},
{
id: 'created',
label: 'Created',
// eslint-disable-next-line react/display-name
renderCell: ({ data: { activeAt } }) => (
<>{activeAt.startsWith('0001') ? '-' : activeAt.substr(0, 19).replace('T', ' ')}</>
),
size: '150px',
},
];

@ -1,8 +1,8 @@
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
import { CombinedRule } from 'app/types/unified-alerting';
import React, { FC, useMemo } from 'react';
import { useStyles } from '@grafana/ui';
import { useStyles2 } from '@grafana/ui';
import { css, cx } from '@emotion/css';
import { GrafanaTheme } from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data';
import { isAlertingRule, isGrafanaRulerRule } from '../../utils/rules';
import { isCloudRulesSource } from '../../utils/datasource';
import { AnnotationDetailsField } from '../AnnotationDetailsField';
@ -16,13 +16,15 @@ import { RuleDetailsActionButtons } from './RuleDetailsActionButtons';
interface Props {
rule: CombinedRule;
rulesSource: RulesSource;
}
export const RuleDetails: FC<Props> = ({ rule, rulesSource }) => {
const styles = useStyles(getStyles);
export const RuleDetails: FC<Props> = ({ rule }) => {
const styles = useStyles2(getStyles);
const { promRule } = rule;
const {
promRule,
namespace: { rulesSource },
} = rule;
const annotations = Object.entries(rule.annotations).filter(([_, value]) => !!value.trim());
@ -98,23 +100,28 @@ export const RuleDetails: FC<Props> = ({ rule, rulesSource }) => {
);
};
export const getStyles = (theme: GrafanaTheme) => ({
export const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css`
display: flex;
flex-direction: row;
${theme.breakpoints.down('md')} {
flex-direction: column;
}
`,
leftSide: css`
flex: 1;
`,
rightSide: css`
padding-left: 90px;
width: 300px;
${theme.breakpoints.up('md')} {
padding-left: 90px;
width: 300px;
}
`,
exprRow: css`
margin-bottom: 46px;
`,
dataSourceIcon: css`
width: ${theme.spacing.md};
height: ${theme.spacing.md};
width: ${theme.spacing(2)};
height: ${theme.spacing(2)};
`,
});

@ -172,10 +172,12 @@ export const getStyles = (theme: GrafanaTheme2) => ({
display: flex;
flex-direction: row;
justify-content: space-between;
flex-wrap: wrap;
border-bottom: solid 1px ${theme.colors.border.medium};
`,
button: css`
height: 24px;
margin-top: ${theme.spacing(1)};
font-size: ${theme.typography.size.sm};
`,
});

@ -49,14 +49,14 @@ export const RuleState: FC<Props> = ({ rule, isDeleting, isCreating }) => {
if (isDeleting) {
return (
<HorizontalGroup>
<HorizontalGroup align="flex-start">
<Spinner />
deleting
</HorizontalGroup>
);
} else if (isCreating) {
return (
<HorizontalGroup>
<HorizontalGroup align="flex-start">
{' '}
<Spinner />
creating
@ -64,7 +64,7 @@ export const RuleState: FC<Props> = ({ rule, isDeleting, isCreating }) => {
);
} else if (promRule && isAlertingRule(promRule)) {
return (
<HorizontalGroup>
<HorizontalGroup align="flex-start">
<AlertStateTag state={promRule.state} />
{forTime}
</HorizontalGroup>

@ -135,14 +135,15 @@ const getStyles = (theme: GrafanaTheme) => {
display: flex;
flex-direction: row;
align-items: flex-end;
width: 100%;
flex-wrap: wrap;
`,
spaceBetween: css`
justify-content: space-between;
`,
rowChild: css`
& + & {
margin-left: ${theme.spacing.sm};
}
margin-right: ${theme.spacing.sm};
margin-top: ${theme.spacing.sm};
`,
clearButton: css`
align-self: flex-end;

@ -106,11 +106,18 @@ export const getStyles = (theme: GrafanaTheme2) => ({
align-items: center;
padding: ${theme.spacing(1)} ${theme.spacing(1)} ${theme.spacing(1)} 0;
background-color: ${theme.colors.background.secondary};
flex-wrap: wrap;
`,
headerStats: css`
span {
vertical-align: middle;
}
${theme.breakpoints.down('sm')} {
order: 2;
width: 100%;
padding-left: ${theme.spacing(1)};
}
`,
heading: css`
margin-left: ${theme.spacing(1)};

@ -1,16 +1,18 @@
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import React, { FC, Fragment, useState } from 'react';
import { CollapseToggle } from '../CollapseToggle';
import React, { FC, useMemo } from 'react';
import { css, cx } from '@emotion/css';
import { RuleDetails } from './RuleDetails';
import { getAlertTableStyles } from '../../styles/table';
import { isCloudRulesSource } from '../../utils/datasource';
import { useHasRuler } from '../../hooks/useHasRuler';
import { CombinedRule } from 'app/types/unified-alerting';
import { Annotation } from '../../utils/constants';
import { RuleState } from './RuleState';
import { RuleHealth } from './RuleHealth';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
type RuleTableColumnProps = DynamicTableColumnProps<CombinedRule>;
type RuleTableItemProps = DynamicTableItemProps<CombinedRule>;
interface Props {
rules: CombinedRule[];
@ -29,123 +31,76 @@ export const RulesTable: FC<Props> = ({
showGroupColumn = false,
showSummaryColumn = false,
}) => {
const hasRuler = useHasRuler();
const styles = useStyles2(getStyles);
const tableStyles = useStyles2(getAlertTableStyles);
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
const wrapperClass = cx(styles.wrapper, className, { [styles.wrapperMargin]: showGuidelines });
const toggleExpandedState = (ruleKey: string) =>
setExpandedKeys(
expandedKeys.includes(ruleKey) ? expandedKeys.filter((key) => key !== ruleKey) : [...expandedKeys, ruleKey]
);
const items = useMemo((): RuleTableItemProps[] => {
const seenKeys: string[] = [];
return rules.map((rule, ruleIdx) => {
let key = JSON.stringify([rule.promRule?.type, rule.labels, rule.query, rule.name, rule.annotations]);
if (seenKeys.includes(key)) {
key += `-${ruleIdx}`;
}
seenKeys.push(key);
return {
id: key,
data: rule,
};
});
}, [rules]);
const wrapperClass = cx(styles.wrapper, className, { [styles.wrapperMargin]: showGuidelines });
const columns = useColumns(showSummaryColumn, showGroupColumn, showGuidelines, items.length);
if (!rules.length) {
return <div className={cx(wrapperClass, styles.emptyMessage)}>{emptyMessage}</div>;
}
return (
<div className={wrapperClass}>
<table className={tableStyles.table} data-testid="rules-table">
<colgroup>
<col className={tableStyles.colExpand} />
<col className={styles.state} />
<col />
<col />
{showSummaryColumn && <col />}
{showGroupColumn && <col />}
</colgroup>
<thead>
<tr>
<th className={styles.relative}>
{showGuidelines && <div className={cx(styles.headerGuideline, styles.guideline)} />}
</th>
<th>State</th>
<th>Name</th>
<th>Health</th>
{showSummaryColumn && <th>Summary</th>}
{showGroupColumn && <th>Group</th>}
</tr>
</thead>
<tbody>
{(() => {
const seenKeys: string[] = [];
return rules.map((rule, ruleIdx) => {
const { namespace, group } = rule;
const { rulesSource } = namespace;
let key = JSON.stringify([rule.promRule?.type, rule.labels, rule.query, rule.name, rule.annotations]);
if (seenKeys.includes(key)) {
key += `-${ruleIdx}`;
}
seenKeys.push(key);
const isExpanded = expandedKeys.includes(key);
const { promRule, rulerRule } = rule;
const isDeleting = !!(hasRuler(rulesSource) && promRule && !rulerRule);
const isCreating = !!(hasRuler(rulesSource) && rulerRule && !promRule);
let detailsColspan = 3;
if (showGroupColumn) {
detailsColspan += 1;
}
if (showSummaryColumn) {
detailsColspan += 1;
}
return (
<Fragment key={key}>
<tr className={ruleIdx % 2 === 0 ? tableStyles.evenRow : undefined}>
<td className={styles.relative}>
{showGuidelines && (
<>
<div className={cx(styles.ruleTopGuideline, styles.guideline)} />
{!(ruleIdx === rules.length - 1) && (
<div className={cx(styles.ruleBottomGuideline, styles.guideline)} />
)}
</>
)}
<CollapseToggle
isCollapsed={!isExpanded}
onToggle={() => toggleExpandedState(key)}
data-testid="rule-collapse-toggle"
/>
</td>
<td>
<RuleState rule={rule} isDeleting={isDeleting} isCreating={isCreating} />
</td>
<td>{rule.name}</td>
<td>{promRule && <RuleHealth rule={promRule} />}</td>
{showSummaryColumn && <td>{rule.annotations[Annotation.summary] ?? ''}</td>}
{showGroupColumn && (
<td>{isCloudRulesSource(rulesSource) ? `${namespace.name} > ${group.name}` : namespace.name}</td>
)}
</tr>
{isExpanded && (
<tr className={ruleIdx % 2 === 0 ? tableStyles.evenRow : undefined}>
<td className={styles.relative}>
{!(ruleIdx === rules.length - 1) && showGuidelines && (
<div className={cx(styles.ruleContentGuideline, styles.guideline)} />
)}
</td>
<td colSpan={detailsColspan}>
<RuleDetails rulesSource={rulesSource} rule={rule} />
</td>
</tr>
<div className={wrapperClass} data-testid="rules-table">
<DynamicTable
cols={columns}
isExpandable={true}
items={items}
renderExpandedContent={({ data: rule }, index) => (
<>
{!(index === rules.length - 1) && showGuidelines ? (
<div className={cx(styles.ruleContentGuideline, styles.guideline)} />
) : null}
<RuleDetails rule={rule} />
</>
)}
renderPrefixHeader={
showGuidelines
? () => (
<div className={styles.relative}>
<div className={cx(styles.headerGuideline, styles.guideline)} />
</div>
)
: undefined
}
renderPrefixCell={
showGuidelines
? (_, index) => (
<div className={styles.relative}>
<div className={cx(styles.ruleTopGuideline, styles.guideline)} />
{!(index === rules.length - 1) && (
<div className={cx(styles.ruleBottomGuideline, styles.guideline)} />
)}
</Fragment>
);
});
})()}
</tbody>
</table>
</div>
)
: undefined
}
/>
</div>
);
};
export const getStyles = (theme: GrafanaTheme2) => ({
wrapperMargin: css`
margin-left: 36px;
${theme.breakpoints.up('md')} {
margin-left: 36px;
}
`,
emptyMessage: css`
padding: ${theme.spacing(1)};
@ -178,11 +133,16 @@ export const getStyles = (theme: GrafanaTheme2) => ({
`,
relative: css`
position: relative;
height: 100%;
`,
guideline: css`
left: -19px;
border-left: 1px solid ${theme.colors.border.medium};
position: absolute;
${theme.breakpoints.down('md')} {
display: none;
}
`,
ruleTopGuideline: css`
width: 18px;
@ -197,6 +157,7 @@ export const getStyles = (theme: GrafanaTheme2) => ({
ruleContentGuideline: css`
top: 0;
bottom: 0;
left: -49px !important;
`,
headerGuideline: css`
top: -24px;
@ -206,3 +167,76 @@ export const getStyles = (theme: GrafanaTheme2) => ({
width: 110px;
`,
});
function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean, showGuidelines: boolean, totalRules: number) {
const hasRuler = useHasRuler();
const styles = useStyles2(getStyles);
return useMemo((): RuleTableColumnProps[] => {
const columns: RuleTableColumnProps[] = [
{
id: 'state',
label: 'State',
// eslint-disable-next-line react/display-name
renderCell: ({ data: rule }, ruleIdx) => {
const { namespace } = rule;
const { rulesSource } = namespace;
const { promRule, rulerRule } = rule;
const isDeleting = !!(hasRuler(rulesSource) && promRule && !rulerRule);
const isCreating = !!(hasRuler(rulesSource) && rulerRule && !promRule);
return (
<>
{showGuidelines && (
<>
<div className={cx(styles.ruleTopGuideline, styles.guideline)} />
{!(ruleIdx === totalRules - 1) && (
<div className={cx(styles.ruleBottomGuideline, styles.guideline)} />
)}
</>
)}
<RuleState rule={rule} isDeleting={isDeleting} isCreating={isCreating} />
</>
);
},
size: '165px',
},
{
id: 'name',
label: 'Name',
// eslint-disable-next-line react/display-name
renderCell: ({ data: rule }) => rule.name,
size: 5,
},
{
id: 'health',
label: 'Health',
// eslint-disable-next-line react/display-name
renderCell: ({ data: { promRule } }) => (promRule ? <RuleHealth rule={promRule} /> : null),
size: '75px',
},
];
if (showSummaryColumn) {
columns.push({
id: 'summary',
label: 'Summary',
// eslint-disable-next-line react/display-name
renderCell: ({ data: rule }) => rule.annotations[Annotation.summary] ?? '',
size: 5,
});
}
if (showGroupColumn) {
columns.push({
id: 'group',
label: 'Group',
// eslint-disable-next-line react/display-name
renderCell: ({ data: rule }) => {
const { namespace, group } = rule;
const { rulesSource } = namespace;
return isCloudRulesSource(rulesSource) ? `${namespace.name} > ${group.name}` : namespace.name;
},
size: 5,
});
}
return columns;
}, [hasRuler, showSummaryColumn, showGroupColumn, showGuidelines, totalRules, styles]);
}

@ -8,64 +8,3 @@ export const prepareItems = <T = unknown>(
id: idCreator?.(item) ?? index,
data: item,
}));
export const collapseItem = <T = unknown>(
items: Array<DynamicTableItemProps<T>>,
itemId: DynamicTableItemProps<T>['id']
): Array<DynamicTableItemProps<T>> =>
items.map((currentItem) => {
if (currentItem.id !== itemId) {
return currentItem;
}
return {
...currentItem,
isExpanded: false,
};
});
export const expandItem = <T = unknown>(
items: Array<DynamicTableItemProps<T>>,
itemId: DynamicTableItemProps<T>['id']
): Array<DynamicTableItemProps<T>> =>
items.map((currentItem) => {
if (currentItem.id !== itemId) {
return currentItem;
}
return {
...currentItem,
isExpanded: true,
};
});
export const addCustomExpandedContent = <T = unknown>(
items: Array<DynamicTableItemProps<T>>,
itemId: DynamicTableItemProps<T>['id'],
renderExpandedContent: DynamicTableItemProps['renderExpandedContent']
): Array<DynamicTableItemProps<T>> =>
items.map((currentItem) => {
if (currentItem.id !== itemId) {
return currentItem;
}
return {
...currentItem,
renderExpandedContent,
};
});
export const removeCustomExpandedContent = <T = unknown>(
items: Array<DynamicTableItemProps<T>>,
itemId: DynamicTableItemProps<T>['id']
): Array<DynamicTableItemProps<T>> =>
items.map((currentItem) => {
if (currentItem.id !== itemId) {
return currentItem;
}
return {
...currentItem,
renderExpandedContent: undefined,
};
});

Loading…
Cancel
Save