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

This commit is contained in:
Domas 2021-06-23 09:27:47 +03:00 committed by GitHub
parent 8660d0692e
commit e5f6ab7fd2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 389 additions and 411 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -1,101 +1,40 @@
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { Alert } from 'app/types/unified-alerting'; import { Alert } from 'app/types/unified-alerting';
import { css, cx } from '@emotion/css'; import { css } from '@emotion/css';
import React, { FC, Fragment, useMemo, useState } from 'react'; import React, { FC, useMemo } from 'react';
import { getAlertTableStyles } from '../../styles/table';
import { alertInstanceKey } from '../../utils/rules'; import { alertInstanceKey } from '../../utils/rules';
import { AlertLabels } from '../AlertLabels'; import { AlertLabels } from '../AlertLabels';
import { CollapseToggle } from '../CollapseToggle';
import { AlertInstanceDetails } from './AlertInstanceDetails'; import { AlertInstanceDetails } from './AlertInstanceDetails';
import { AlertStateTag } from './AlertStateTag'; import { AlertStateTag } from './AlertStateTag';
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
type AlertWithKey = Alert & { key: string };
interface Props { interface Props {
instances: Alert[]; instances: Alert[];
} }
type AlertTableColumnProps = DynamicTableColumnProps<Alert>;
type AlertTableItemProps = DynamicTableItemProps<Alert>;
export const AlertInstancesTable: FC<Props> = ({ instances }) => { export const AlertInstancesTable: FC<Props> = ({ instances }) => {
const styles = useStyles2(getStyles);
const tableStyles = useStyles2(getAlertTableStyles);
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
// add key & sort instance. API returns instances in random order, different every time. // add key & sort instance. API returns instances in random order, different every time.
const sortedInstances = useMemo( const items = useMemo(
(): AlertWithKey[] => (): AlertTableItemProps[] =>
instances instances
.map((instance) => ({ .map((instance) => ({
...instance, data: instance,
key: alertInstanceKey(instance), id: alertInstanceKey(instance),
})) }))
.sort((a, b) => a.key.localeCompare(b.key)), .sort((a, b) => a.id.localeCompare(b.id)),
[instances] [instances]
); );
const toggleExpandedState = (ruleKey: string) =>
setExpandedKeys(
expandedKeys.includes(ruleKey) ? expandedKeys.filter((key) => key !== ruleKey) : [...expandedKeys, ruleKey]
);
return ( return (
<table className={cx(tableStyles.table, styles.table)}> <DynamicTable
<colgroup> cols={columns}
<col className={styles.colExpand} /> isExpandable={true}
<col className={styles.colState} /> items={items}
<col /> renderExpandedContent={({ data }) => <AlertInstanceDetails instance={data} />}
<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>
); );
}; };
@ -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',
},
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,64 +8,3 @@ export const prepareItems = <T = unknown>(
id: idCreator?.(item) ?? index, id: idCreator?.(item) ?? index,
data: item, 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,
};
});