mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: make rule list page work on mobile (#35991)
This commit is contained in:
parent
8660d0692e
commit
e5f6ab7fd2
@ -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();
|
||||||
});
|
});
|
||||||
|
@ -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};
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
|
@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
@ -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)};
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
@ -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};
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
@ -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)};
|
||||||
|
@ -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]);
|
||||||
|
}
|
||||||
|
@ -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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
Loading…
Reference in New Issue
Block a user