mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: UI standardization and fixes (#38770)
* Add expand all button for rule list group view * filter out recording rules by default * Create rule type filter * Add placeholder text for inputs * WIP move Silences to use DynamicTable * Use dynamic table for silences page * hide expand all for state list view * Add placeholders for inputs * Update selector in receivers test * Fix strict error for ruleType * remove redundant placeholder text and cleanup hooks * add fixed width for schedule * Rebase with dynamic table for silences * only show expand/collapse when filters are active
This commit is contained in:
parent
9f93a81bee
commit
427f75d9b0
@ -7,6 +7,7 @@ export const getAvailableIcons = () =>
|
|||||||
[
|
[
|
||||||
'angle-double-down',
|
'angle-double-down',
|
||||||
'angle-double-right',
|
'angle-double-right',
|
||||||
|
'angle-double-up',
|
||||||
'angle-down',
|
'angle-down',
|
||||||
'angle-left',
|
'angle-left',
|
||||||
'angle-right',
|
'angle-right',
|
||||||
|
@ -132,6 +132,5 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 0;
|
height: 0;
|
||||||
margin-bottom: ${theme.spacing(2)};
|
margin-bottom: ${theme.spacing(2)};
|
||||||
border-bottom: solid 1px ${theme.colors.border.medium};
|
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
@ -18,7 +18,7 @@ import {
|
|||||||
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
||||||
import { fetchNotifiers } from './api/grafana';
|
import { fetchNotifiers } from './api/grafana';
|
||||||
import { grafanaNotifiersMock } from './mocks/grafana-notifiers';
|
import { grafanaNotifiersMock } from './mocks/grafana-notifiers';
|
||||||
import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector';
|
import { byLabelText, byPlaceholderText, byRole, byTestId, byText } from 'testing-library-selector';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants';
|
import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants';
|
||||||
import store from 'app/core/store';
|
import store from 'app/core/store';
|
||||||
@ -87,7 +87,7 @@ const ui = {
|
|||||||
channelFormContainer: byTestId('item-container'),
|
channelFormContainer: byTestId('item-container'),
|
||||||
|
|
||||||
inputs: {
|
inputs: {
|
||||||
name: byLabelText('Name'),
|
name: byPlaceholderText('Name'),
|
||||||
email: {
|
email: {
|
||||||
addresses: byLabelText(/Addresses/),
|
addresses: byLabelText(/Addresses/),
|
||||||
toEmails: byLabelText(/To/),
|
toEmails: byLabelText(/To/),
|
||||||
@ -213,7 +213,7 @@ describe('Receivers', () => {
|
|||||||
expect(locationService.getLocation().pathname).toEqual('/alerting/notifications/receivers/new');
|
expect(locationService.getLocation().pathname).toEqual('/alerting/notifications/receivers/new');
|
||||||
|
|
||||||
// type in a name for the new receiver
|
// type in a name for the new receiver
|
||||||
await userEvent.type(byLabelText('Name').get(), 'my new receiver');
|
await userEvent.type(byPlaceholderText('Name').get(), 'my new receiver');
|
||||||
|
|
||||||
// check that default email form is rendered
|
// check that default email form is rendered
|
||||||
await ui.inputs.email.addresses.find();
|
await ui.inputs.email.addresses.find();
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { GrafanaTheme2, urlUtil } from '@grafana/data';
|
import { GrafanaTheme2, urlUtil } from '@grafana/data';
|
||||||
import { useStyles2, LinkButton, withErrorBoundary } from '@grafana/ui';
|
import { useStyles2, LinkButton, withErrorBoundary, Button } from '@grafana/ui';
|
||||||
import React, { useEffect, useMemo } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
|
||||||
import { NoRulesSplash } from './components/rules/NoRulesCTA';
|
import { NoRulesSplash } from './components/rules/NoRulesCTA';
|
||||||
@ -19,6 +19,7 @@ import { useLocation } from 'react-router-dom';
|
|||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { RuleStats } from './components/rules/RuleStats';
|
import { RuleStats } from './components/rules/RuleStats';
|
||||||
import { RuleListErrors } from './components/rules/RuleListErrors';
|
import { RuleListErrors } from './components/rules/RuleListErrors';
|
||||||
|
import { getFiltersFromUrlParams } from './utils/misc';
|
||||||
|
|
||||||
const VIEWS = {
|
const VIEWS = {
|
||||||
groups: RuleListGroupView,
|
groups: RuleListGroupView,
|
||||||
@ -31,8 +32,11 @@ export const RuleList = withErrorBoundary(
|
|||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []);
|
const rulesDataSourceNames = useMemo(getAllRulesSourceNames, []);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [expandAll, setExpandAll] = useState(false);
|
||||||
|
|
||||||
const [queryParams] = useQueryParams();
|
const [queryParams] = useQueryParams();
|
||||||
|
const filters = getFiltersFromUrlParams(queryParams);
|
||||||
|
const filtersActive = Object.values(filters).some((filter) => filter !== undefined);
|
||||||
|
|
||||||
const view = VIEWS[queryParams['view'] as keyof typeof VIEWS]
|
const view = VIEWS[queryParams['view'] as keyof typeof VIEWS]
|
||||||
? (queryParams['view'] as keyof typeof VIEWS)
|
? (queryParams['view'] as keyof typeof VIEWS)
|
||||||
@ -76,8 +80,19 @@ export const RuleList = withErrorBoundary(
|
|||||||
<RulesFilter />
|
<RulesFilter />
|
||||||
<div className={styles.break} />
|
<div className={styles.break} />
|
||||||
<div className={styles.buttonsContainer}>
|
<div className={styles.buttonsContainer}>
|
||||||
|
<div className={styles.statsContainer}>
|
||||||
|
{view === 'groups' && filtersActive && (
|
||||||
|
<Button
|
||||||
|
className={styles.expandAllButton}
|
||||||
|
icon={expandAll ? 'angle-double-up' : 'angle-double-down'}
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setExpandAll(!expandAll)}
|
||||||
|
>
|
||||||
|
{expandAll ? 'Collapse all' : 'Expand all'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<RuleStats showInactive={true} showRecording={true} namespaces={filteredNamespaces} />
|
<RuleStats showInactive={true} showRecording={true} namespaces={filteredNamespaces} />
|
||||||
<div />
|
</div>
|
||||||
{(contextSrv.hasEditPermissionInFolders || contextSrv.isEditor) && (
|
{(contextSrv.hasEditPermissionInFolders || contextSrv.isEditor) && (
|
||||||
<LinkButton
|
<LinkButton
|
||||||
href={urlUtil.renderUrl('alerting/new', { returnTo: location.pathname + location.search })}
|
href={urlUtil.renderUrl('alerting/new', { returnTo: location.pathname + location.search })}
|
||||||
@ -90,7 +105,7 @@ export const RuleList = withErrorBoundary(
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{showNewAlertSplash && <NoRulesSplash />}
|
{showNewAlertSplash && <NoRulesSplash />}
|
||||||
{haveResults && <ViewComponent namespaces={filteredNamespaces} />}
|
{haveResults && <ViewComponent expandAll={expandAll} namespaces={filteredNamespaces} />}
|
||||||
</AlertingPageWrapper>
|
</AlertingPageWrapper>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -109,4 +124,12 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
`,
|
`,
|
||||||
|
statsContainer: css`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
`,
|
||||||
|
expandAllButton: css`
|
||||||
|
margin-right: ${theme.spacing(1)};
|
||||||
|
`,
|
||||||
});
|
});
|
||||||
|
@ -46,9 +46,9 @@ const dataSources = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ui = {
|
const ui = {
|
||||||
silencesTable: byTestId('silences-table'),
|
silencesTable: byTestId('dynamic-table'),
|
||||||
silenceRow: byTestId('silence-table-row'),
|
silenceRow: byTestId('row'),
|
||||||
silencedAlertCell: byTestId('silenced-alerts'),
|
silencedAlertCell: byTestId('alerts'),
|
||||||
queryBar: byPlaceholderText('Search'),
|
queryBar: byPlaceholderText('Search'),
|
||||||
editor: {
|
editor: {
|
||||||
timeRange: byLabelText('Timepicker', { exact: false }),
|
timeRange: byLabelText('Timepicker', { exact: false }),
|
||||||
@ -58,7 +58,7 @@ const ui = {
|
|||||||
matcherName: byPlaceholderText('label'),
|
matcherName: byPlaceholderText('label'),
|
||||||
matcherValue: byPlaceholderText('value'),
|
matcherValue: byPlaceholderText('value'),
|
||||||
comment: byPlaceholderText('Details about the silence'),
|
comment: byPlaceholderText('Details about the silence'),
|
||||||
createdBy: byPlaceholderText('Username'),
|
createdBy: byPlaceholderText('User'),
|
||||||
matcherOperatorSelect: byLabelText('operator'),
|
matcherOperatorSelect: byLabelText('operator'),
|
||||||
matcherOperator: (operator: MatcherOperator) => byText(operator, { exact: true }),
|
matcherOperator: (operator: MatcherOperator) => byText(operator, { exact: true }),
|
||||||
addMatcherButton: byRole('button', { name: 'Add matcher' }),
|
addMatcherButton: byRole('button', { name: 'Add matcher' }),
|
||||||
|
@ -54,7 +54,10 @@ export const AmRootRouteForm: FC<AmRootRouteFormProps> = ({
|
|||||||
rules={{ required: { value: true, message: 'Required.' } }}
|
rules={{ required: { value: true, message: 'Required.' } }}
|
||||||
/>
|
/>
|
||||||
<span>or</span>
|
<span>or</span>
|
||||||
<Link href={makeAMLink('/alerting/notifications/receivers/new', alertManagerSourceName)}>
|
<Link
|
||||||
|
className={styles.linkText}
|
||||||
|
href={makeAMLink('/alerting/notifications/receivers/new', alertManagerSourceName)}
|
||||||
|
>
|
||||||
Create a contact point
|
Create a contact point
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -89,6 +92,7 @@ export const AmRootRouteForm: FC<AmRootRouteFormProps> = ({
|
|||||||
</Field>
|
</Field>
|
||||||
<Collapse
|
<Collapse
|
||||||
collapsible
|
collapsible
|
||||||
|
className={styles.collapse}
|
||||||
isOpen={isTimingOptionsExpanded}
|
isOpen={isTimingOptionsExpanded}
|
||||||
label="Timing options"
|
label="Timing options"
|
||||||
onToggle={setIsTimingOptionsExpanded}
|
onToggle={setIsTimingOptionsExpanded}
|
||||||
@ -104,7 +108,12 @@ export const AmRootRouteForm: FC<AmRootRouteFormProps> = ({
|
|||||||
<div className={cx(styles.container, styles.timingContainer)}>
|
<div className={cx(styles.container, styles.timingContainer)}>
|
||||||
<InputControl
|
<InputControl
|
||||||
render={({ field, fieldState: { invalid } }) => (
|
render={({ field, fieldState: { invalid } }) => (
|
||||||
<Input {...field} className={styles.smallInput} invalid={invalid} />
|
<Input
|
||||||
|
{...field}
|
||||||
|
className={styles.smallInput}
|
||||||
|
invalid={invalid}
|
||||||
|
placeholder={'Default 30 seconds'}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
control={control}
|
control={control}
|
||||||
name="groupWaitValue"
|
name="groupWaitValue"
|
||||||
@ -139,7 +148,12 @@ export const AmRootRouteForm: FC<AmRootRouteFormProps> = ({
|
|||||||
<div className={cx(styles.container, styles.timingContainer)}>
|
<div className={cx(styles.container, styles.timingContainer)}>
|
||||||
<InputControl
|
<InputControl
|
||||||
render={({ field, fieldState: { invalid } }) => (
|
render={({ field, fieldState: { invalid } }) => (
|
||||||
<Input {...field} className={styles.smallInput} invalid={invalid} />
|
<Input
|
||||||
|
{...field}
|
||||||
|
className={styles.smallInput}
|
||||||
|
invalid={invalid}
|
||||||
|
placeholder={'Default 5 minutes'}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
control={control}
|
control={control}
|
||||||
name="groupIntervalValue"
|
name="groupIntervalValue"
|
||||||
@ -174,7 +188,7 @@ export const AmRootRouteForm: FC<AmRootRouteFormProps> = ({
|
|||||||
<div className={cx(styles.container, styles.timingContainer)}>
|
<div className={cx(styles.container, styles.timingContainer)}>
|
||||||
<InputControl
|
<InputControl
|
||||||
render={({ field, fieldState: { invalid } }) => (
|
render={({ field, fieldState: { invalid } }) => (
|
||||||
<Input {...field} className={styles.smallInput} invalid={invalid} />
|
<Input {...field} className={styles.smallInput} invalid={invalid} placeholder="Default 4 hours" />
|
||||||
)}
|
)}
|
||||||
control={control}
|
control={control}
|
||||||
name="repeatIntervalValue"
|
name="repeatIntervalValue"
|
||||||
|
@ -189,7 +189,7 @@ export const AmRoutesExpandedForm: FC<AmRoutesExpandedFormProps> = ({ onCancel,
|
|||||||
<div className={cx(formStyles.container, formStyles.timingContainer)}>
|
<div className={cx(formStyles.container, formStyles.timingContainer)}>
|
||||||
<InputControl
|
<InputControl
|
||||||
render={({ field, fieldState: { invalid } }) => (
|
render={({ field, fieldState: { invalid } }) => (
|
||||||
<Input {...field} className={formStyles.smallInput} invalid={invalid} />
|
<Input {...field} className={formStyles.smallInput} invalid={invalid} placeholder="Time" />
|
||||||
)}
|
)}
|
||||||
control={control}
|
control={control}
|
||||||
name="groupWaitValue"
|
name="groupWaitValue"
|
||||||
@ -223,7 +223,7 @@ export const AmRoutesExpandedForm: FC<AmRoutesExpandedFormProps> = ({ onCancel,
|
|||||||
<div className={cx(formStyles.container, formStyles.timingContainer)}>
|
<div className={cx(formStyles.container, formStyles.timingContainer)}>
|
||||||
<InputControl
|
<InputControl
|
||||||
render={({ field, fieldState: { invalid } }) => (
|
render={({ field, fieldState: { invalid } }) => (
|
||||||
<Input {...field} className={formStyles.smallInput} invalid={invalid} />
|
<Input {...field} className={formStyles.smallInput} invalid={invalid} placeholder="Time" />
|
||||||
)}
|
)}
|
||||||
control={control}
|
control={control}
|
||||||
name="groupIntervalValue"
|
name="groupIntervalValue"
|
||||||
@ -257,7 +257,7 @@ export const AmRoutesExpandedForm: FC<AmRoutesExpandedFormProps> = ({ onCancel,
|
|||||||
<div className={cx(formStyles.container, formStyles.timingContainer)}>
|
<div className={cx(formStyles.container, formStyles.timingContainer)}>
|
||||||
<InputControl
|
<InputControl
|
||||||
render={({ field, fieldState: { invalid } }) => (
|
render={({ field, fieldState: { invalid } }) => (
|
||||||
<Input {...field} className={formStyles.smallInput} invalid={invalid} />
|
<Input {...field} className={formStyles.smallInput} invalid={invalid} placeholder="Time" />
|
||||||
)}
|
)}
|
||||||
control={control}
|
control={control}
|
||||||
name="repeatIntervalValue"
|
name="repeatIntervalValue"
|
||||||
|
@ -21,5 +21,13 @@ export const getFormStyles = (theme: GrafanaTheme2) => {
|
|||||||
smallInput: css`
|
smallInput: css`
|
||||||
width: ${theme.spacing(6.5)};
|
width: ${theme.spacing(6.5)};
|
||||||
`,
|
`,
|
||||||
|
linkText: css`
|
||||||
|
text-decoration: underline;
|
||||||
|
`,
|
||||||
|
collapse: css`
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: ${theme.colors.text.primary};
|
||||||
|
`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -98,12 +98,13 @@ export const TemplateForm: FC<Props> = ({ existing, alertManagerSourceName, conf
|
|||||||
{error.message || (error as any)?.data?.message || String(error)}
|
{error.message || (error as any)?.data?.message || String(error)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<Field label="Template name" error={errors?.name?.message} invalid={!!errors.name?.message}>
|
<Field label="Template name" error={errors?.name?.message} invalid={!!errors.name?.message} required>
|
||||||
<Input
|
<Input
|
||||||
{...register('name', {
|
{...register('name', {
|
||||||
required: { value: true, message: 'Required.' },
|
required: { value: true, message: 'Required.' },
|
||||||
validate: { nameIsUnique: validateNameIsUnique },
|
validate: { nameIsUnique: validateNameIsUnique },
|
||||||
})}
|
})}
|
||||||
|
placeholder="Give your template a name"
|
||||||
width={42}
|
width={42}
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
/>
|
/>
|
||||||
@ -134,10 +135,12 @@ export const TemplateForm: FC<Props> = ({ existing, alertManagerSourceName, conf
|
|||||||
label="Content"
|
label="Content"
|
||||||
error={errors?.content?.message}
|
error={errors?.content?.message}
|
||||||
invalid={!!errors.content?.message}
|
invalid={!!errors.content?.message}
|
||||||
|
required
|
||||||
>
|
>
|
||||||
<TextArea
|
<TextArea
|
||||||
{...register('content', { required: { value: true, message: 'Required.' } })}
|
{...register('content', { required: { value: true, message: 'Required.' } })}
|
||||||
className={styles.textarea}
|
className={styles.textarea}
|
||||||
|
placeholder="Message"
|
||||||
rows={12}
|
rows={12}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
@ -98,7 +98,7 @@ export function ReceiverForm<R extends ChannelValues>({
|
|||||||
<h4 className={styles.heading}>
|
<h4 className={styles.heading}>
|
||||||
{readOnly ? 'Contact point' : initialValues ? 'Update contact point' : 'Create contact point'}
|
{readOnly ? 'Contact point' : initialValues ? 'Update contact point' : 'Create contact point'}
|
||||||
</h4>
|
</h4>
|
||||||
<Field label="Name" invalid={!!errors.name} error={errors.name && errors.name.message}>
|
<Field label="Name" invalid={!!errors.name} error={errors.name && errors.name.message} required>
|
||||||
<Input
|
<Input
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
id="name"
|
id="name"
|
||||||
@ -107,6 +107,7 @@ export function ReceiverForm<R extends ChannelValues>({
|
|||||||
validate: { nameIsAvailable: validateNameIsAvailable },
|
validate: { nameIsAvailable: validateNameIsAvailable },
|
||||||
})}
|
})}
|
||||||
width={39}
|
width={39}
|
||||||
|
placeholder="Name"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
{fields.map((field, index) => {
|
{fields.map((field, index) => {
|
||||||
|
@ -10,9 +10,10 @@ import pluralize from 'pluralize';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
namespaces: CombinedRuleNamespace[];
|
namespaces: CombinedRuleNamespace[];
|
||||||
|
expandAll: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CloudRules: FC<Props> = ({ namespaces }) => {
|
export const CloudRules: FC<Props> = ({ namespaces, expandAll }) => {
|
||||||
const styles = useStyles(getStyles);
|
const styles = useStyles(getStyles);
|
||||||
const rules = useUnifiedAlertingSelector((state) => state.promRules);
|
const rules = useUnifiedAlertingSelector((state) => state.promRules);
|
||||||
const rulesDataSources = useMemo(getRulesDataSources, []);
|
const rulesDataSources = useMemo(getRulesDataSources, []);
|
||||||
@ -43,6 +44,7 @@ export const CloudRules: FC<Props> = ({ namespaces }) => {
|
|||||||
group={group}
|
group={group}
|
||||||
key={`${getRulesSourceName(rulesSource)}-${name}-${group.name}`}
|
key={`${getRulesSourceName(rulesSource)}-${name}-${group.name}`}
|
||||||
namespace={namespace}
|
namespace={namespace}
|
||||||
|
expandAll={expandAll}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
})}
|
})}
|
||||||
|
@ -10,9 +10,10 @@ import { initialAsyncRequestState } from '../../utils/redux';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
namespaces: CombinedRuleNamespace[];
|
namespaces: CombinedRuleNamespace[];
|
||||||
|
expandAll: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GrafanaRules: FC<Props> = ({ namespaces }) => {
|
export const GrafanaRules: FC<Props> = ({ namespaces, expandAll }) => {
|
||||||
const styles = useStyles(getStyles);
|
const styles = useStyles(getStyles);
|
||||||
const { loading } = useUnifiedAlertingSelector(
|
const { loading } = useUnifiedAlertingSelector(
|
||||||
(state) => state.promRules[GRAFANA_RULES_SOURCE_NAME] || initialAsyncRequestState
|
(state) => state.promRules[GRAFANA_RULES_SOURCE_NAME] || initialAsyncRequestState
|
||||||
@ -27,7 +28,12 @@ export const GrafanaRules: FC<Props> = ({ namespaces }) => {
|
|||||||
|
|
||||||
{namespaces?.map((namespace) =>
|
{namespaces?.map((namespace) =>
|
||||||
namespace.groups.map((group) => (
|
namespace.groups.map((group) => (
|
||||||
<RulesGroup group={group} key={`${namespace.name}-${group.name}`} namespace={namespace} />
|
<RulesGroup
|
||||||
|
group={group}
|
||||||
|
key={`${namespace.name}-${group.name}`}
|
||||||
|
namespace={namespace}
|
||||||
|
expandAll={expandAll}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
{namespaces?.length === 0 && <p>No rules found.</p>}
|
{namespaces?.length === 0 && <p>No rules found.</p>}
|
||||||
|
@ -6,9 +6,10 @@ import { GrafanaRules } from './GrafanaRules';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
namespaces: CombinedRuleNamespace[];
|
namespaces: CombinedRuleNamespace[];
|
||||||
|
expandAll: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RuleListGroupView: FC<Props> = ({ namespaces }) => {
|
export const RuleListGroupView: FC<Props> = ({ namespaces, expandAll }) => {
|
||||||
const [grafanaNamespaces, cloudNamespaces] = useMemo(() => {
|
const [grafanaNamespaces, cloudNamespaces] = useMemo(() => {
|
||||||
const sorted = namespaces
|
const sorted = namespaces
|
||||||
.map((namespace) => ({
|
.map((namespace) => ({
|
||||||
@ -24,8 +25,8 @@ export const RuleListGroupView: FC<Props> = ({ namespaces }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GrafanaRules namespaces={grafanaNamespaces} />
|
<GrafanaRules namespaces={grafanaNamespaces} expandAll={expandAll} />
|
||||||
<CloudRules namespaces={cloudNamespaces} />
|
<CloudRules namespaces={cloudNamespaces} expandAll={expandAll} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -8,6 +8,7 @@ import { RuleListStateSection } from './RuleListStateSection';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
namespaces: CombinedRuleNamespace[];
|
namespaces: CombinedRuleNamespace[];
|
||||||
|
expandAll?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type GroupedRules = Record<PromAlertingRuleState, CombinedRule[]>;
|
type GroupedRules = Record<PromAlertingRuleState, CombinedRule[]>;
|
||||||
|
@ -4,7 +4,7 @@ import { DataSourceInstanceSettings, GrafanaTheme, SelectableValue } from '@graf
|
|||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
|
|
||||||
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
|
import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto';
|
||||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||||
import { getFiltersFromUrlParams } from '../../utils/misc';
|
import { getFiltersFromUrlParams } from '../../utils/misc';
|
||||||
import { DataSourcePicker } from '@grafana/runtime';
|
import { DataSourcePicker } from '@grafana/runtime';
|
||||||
@ -23,6 +23,17 @@ const ViewOptions: SelectableValue[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const RuleTypeOptions: SelectableValue[] = [
|
||||||
|
{
|
||||||
|
label: 'Alert ',
|
||||||
|
value: PromRuleType.Alerting,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Recording ',
|
||||||
|
value: PromRuleType.Recording,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const RulesFilter = () => {
|
const RulesFilter = () => {
|
||||||
const [queryParams, setQueryParams] = useQueryParams();
|
const [queryParams, setQueryParams] = useQueryParams();
|
||||||
// This key is used to force a rerender on the inputs when the filters are cleared
|
// This key is used to force a rerender on the inputs when the filters are cleared
|
||||||
@ -30,7 +41,7 @@ const RulesFilter = () => {
|
|||||||
const dataSourceKey = `dataSource-${filterKey}`;
|
const dataSourceKey = `dataSource-${filterKey}`;
|
||||||
const queryStringKey = `queryString-${filterKey}`;
|
const queryStringKey = `queryString-${filterKey}`;
|
||||||
|
|
||||||
const { dataSource, alertState, queryString } = getFiltersFromUrlParams(queryParams);
|
const { dataSource, alertState, queryString, ruleType } = getFiltersFromUrlParams(queryParams);
|
||||||
|
|
||||||
const styles = useStyles(getStyles);
|
const styles = useStyles(getStyles);
|
||||||
const stateOptions = Object.entries(PromAlertingRuleState).map(([key, value]) => ({
|
const stateOptions = Object.entries(PromAlertingRuleState).map(([key, value]) => ({
|
||||||
@ -55,11 +66,16 @@ const RulesFilter = () => {
|
|||||||
setQueryParams({ view });
|
setQueryParams({ view });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRuleTypeChange = (ruleType: PromRuleType) => {
|
||||||
|
setQueryParams({ ruleType });
|
||||||
|
};
|
||||||
|
|
||||||
const handleClearFiltersClick = () => {
|
const handleClearFiltersClick = () => {
|
||||||
setQueryParams({
|
setQueryParams({
|
||||||
alertState: null,
|
alertState: null,
|
||||||
queryString: null,
|
queryString: null,
|
||||||
dataSource: null,
|
dataSource: null,
|
||||||
|
ruleType: null,
|
||||||
});
|
});
|
||||||
setTimeout(() => setFilterKey(filterKey + 1), 100);
|
setTimeout(() => setFilterKey(filterKey + 1), 100);
|
||||||
};
|
};
|
||||||
@ -107,6 +123,14 @@ const RulesFilter = () => {
|
|||||||
<Label>State</Label>
|
<Label>State</Label>
|
||||||
<RadioButtonGroup options={stateOptions} value={alertState} onChange={handleAlertStateChange} />
|
<RadioButtonGroup options={stateOptions} value={alertState} onChange={handleAlertStateChange} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.rowChild}>
|
||||||
|
<Label>Rule type</Label>
|
||||||
|
<RadioButtonGroup
|
||||||
|
options={RuleTypeOptions}
|
||||||
|
value={ruleType as PromRuleType}
|
||||||
|
onChange={handleRuleTypeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className={styles.rowChild}>
|
<div className={styles.rowChild}>
|
||||||
<Label>View as</Label>
|
<Label>View as</Label>
|
||||||
<RadioButtonGroup
|
<RadioButtonGroup
|
||||||
@ -116,7 +140,7 @@ const RulesFilter = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(dataSource || alertState || queryString) && (
|
{(dataSource || alertState || queryString || ruleType) && (
|
||||||
<div className={styles.flexRow}>
|
<div className={styles.flexRow}>
|
||||||
<Button
|
<Button
|
||||||
className={styles.clearButton}
|
className={styles.clearButton}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
|
import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting';
|
||||||
import React, { FC, useState } from 'react';
|
import React, { FC, useState, useEffect } from 'react';
|
||||||
import { HorizontalGroup, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui';
|
import { HorizontalGroup, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui';
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
@ -17,14 +17,19 @@ import { EditCloudGroupModal } from './EditCloudGroupModal';
|
|||||||
interface Props {
|
interface Props {
|
||||||
namespace: CombinedRuleNamespace;
|
namespace: CombinedRuleNamespace;
|
||||||
group: CombinedRuleGroup;
|
group: CombinedRuleGroup;
|
||||||
|
expandAll: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RulesGroup: FC<Props> = React.memo(({ group, namespace }) => {
|
export const RulesGroup: FC<Props> = React.memo(({ group, namespace, expandAll }) => {
|
||||||
const { rulesSource } = namespace;
|
const { rulesSource } = namespace;
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
|
||||||
const [isEditingGroup, setIsEditingGroup] = useState(false);
|
const [isEditingGroup, setIsEditingGroup] = useState(false);
|
||||||
|
const [isCollapsed, setIsCollapsed] = useState(!expandAll);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsCollapsed(!expandAll);
|
||||||
|
}, [expandAll]);
|
||||||
|
|
||||||
const hasRuler = useHasRuler();
|
const hasRuler = useHasRuler();
|
||||||
const rulerRule = group.rules[0]?.rulerRule;
|
const rulerRule = group.rules[0]?.rulerRule;
|
||||||
|
@ -85,30 +85,6 @@ export const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
background-color: ${theme.colors.background.secondary};
|
background-color: ${theme.colors.background.secondary};
|
||||||
border-radius: ${theme.shape.borderRadius()};
|
border-radius: ${theme.shape.borderRadius()};
|
||||||
`,
|
`,
|
||||||
table: css`
|
|
||||||
width: 100%;
|
|
||||||
border-radius: ${theme.shape.borderRadius()};
|
|
||||||
border: solid 1px ${theme.colors.border.weak};
|
|
||||||
background-color: ${theme.colors.background.secondary};
|
|
||||||
|
|
||||||
th {
|
|
||||||
padding: ${theme.spacing(1)};
|
|
||||||
}
|
|
||||||
|
|
||||||
td + td {
|
|
||||||
padding: ${theme.spacing(0, 1)};
|
|
||||||
}
|
|
||||||
|
|
||||||
tr {
|
|
||||||
height: 38px;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
evenRow: css`
|
|
||||||
background-color: ${theme.colors.background.primary};
|
|
||||||
`,
|
|
||||||
state: css`
|
|
||||||
width: 110px;
|
|
||||||
`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean) {
|
function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean) {
|
||||||
|
@ -0,0 +1,49 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { dateMath, GrafanaTheme2, intervalToAbbreviatedDurationString } from '@grafana/data';
|
||||||
|
import React from 'react';
|
||||||
|
import { useStyles2 } from '@grafana/ui';
|
||||||
|
import SilencedAlertsTable from './SilencedAlertsTable';
|
||||||
|
|
||||||
|
import { SilenceTableItem } from './SilencesTable';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
silence: SilenceTableItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SilenceDetails = ({ silence }: Props) => {
|
||||||
|
const { startsAt, endsAt, comment, createdBy, silencedAlerts } = silence;
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
const dateDisplayFormat = 'YYYY-MM-DD HH:mm';
|
||||||
|
const startsAtDate = dateMath.parse(startsAt);
|
||||||
|
const endsAtDate = dateMath.parse(endsAt);
|
||||||
|
const duration = intervalToAbbreviatedDurationString({ start: new Date(startsAt), end: new Date(endsAt) });
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.title}>Comment</div>
|
||||||
|
<div>{comment}</div>
|
||||||
|
<div className={styles.title}>Schedule</div>
|
||||||
|
<div>{`${startsAtDate?.format(dateDisplayFormat)} - ${endsAtDate?.format(dateDisplayFormat)}`}</div>
|
||||||
|
<div className={styles.title}>Duration</div>
|
||||||
|
<div> {duration}</div>
|
||||||
|
<div className={styles.title}>Created by</div>
|
||||||
|
<div> {createdBy}</div>
|
||||||
|
<div className={styles.title}>Affected alerts</div>
|
||||||
|
<SilencedAlertsTable silencedAlerts={silencedAlerts} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
|
container: css`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 9fr;
|
||||||
|
grid-row-gap: 1rem;
|
||||||
|
`,
|
||||||
|
title: css`
|
||||||
|
color: ${theme.colors.text.primary};
|
||||||
|
`,
|
||||||
|
row: css`
|
||||||
|
margin: ${theme.spacing(1, 0)};
|
||||||
|
`,
|
||||||
|
});
|
@ -4,7 +4,7 @@ import { CollapseToggle } from '../CollapseToggle';
|
|||||||
import { ActionIcon } from '../rules/ActionIcon';
|
import { ActionIcon } from '../rules/ActionIcon';
|
||||||
import { getAlertTableStyles } from '../../styles/table';
|
import { getAlertTableStyles } from '../../styles/table';
|
||||||
import { useStyles2 } from '@grafana/ui';
|
import { useStyles2 } from '@grafana/ui';
|
||||||
import { dateTimeAsMoment, toDuration } from '@grafana/data';
|
import { intervalToAbbreviatedDurationString } from '@grafana/data';
|
||||||
import { AlertLabels } from '../AlertLabels';
|
import { AlertLabels } from '../AlertLabels';
|
||||||
import { AmAlertStateTag } from './AmAlertStateTag';
|
import { AmAlertStateTag } from './AmAlertStateTag';
|
||||||
|
|
||||||
@ -16,7 +16,11 @@ interface Props {
|
|||||||
export const SilencedAlertsTableRow: FC<Props> = ({ alert, className }) => {
|
export const SilencedAlertsTableRow: FC<Props> = ({ alert, className }) => {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||||
const tableStyles = useStyles2(getAlertTableStyles);
|
const tableStyles = useStyles2(getAlertTableStyles);
|
||||||
const alertDuration = toDuration(dateTimeAsMoment(alert.endsAt).diff(alert.startsAt)).asSeconds();
|
|
||||||
|
const duration = intervalToAbbreviatedDurationString({
|
||||||
|
start: new Date(alert.startsAt),
|
||||||
|
end: new Date(alert.endsAt),
|
||||||
|
});
|
||||||
const alertName = Object.entries(alert.labels).reduce((name, [labelKey, labelValue]) => {
|
const alertName = Object.entries(alert.labels).reduce((name, [labelKey, labelValue]) => {
|
||||||
if (labelKey === 'alertname' || labelKey === '__alert_rule_title__') {
|
if (labelKey === 'alertname' || labelKey === '__alert_rule_title__') {
|
||||||
name = labelValue;
|
name = labelValue;
|
||||||
@ -32,7 +36,7 @@ export const SilencedAlertsTableRow: FC<Props> = ({ alert, className }) => {
|
|||||||
<td>
|
<td>
|
||||||
<AmAlertStateTag state={alert.status.state} />
|
<AmAlertStateTag state={alert.status.state} />
|
||||||
</td>
|
</td>
|
||||||
<td>for {alertDuration} seconds</td>
|
<td>for {duration} seconds</td>
|
||||||
<td>{alertName}</td>
|
<td>{alertName}</td>
|
||||||
<td className={tableStyles.actionsCell}>
|
<td className={tableStyles.actionsCell}>
|
||||||
<ActionIcon icon="chart-line" to={alert.generatorURL} tooltip="View in explorer" />
|
<ActionIcon icon="chart-line" to={alert.generatorURL} tooltip="View in explorer" />
|
||||||
|
@ -208,10 +208,7 @@ export const SilencesEditor: FC<Props> = ({ silence, alertManagerSourceName }) =
|
|||||||
error={formState.errors.createdBy?.message}
|
error={formState.errors.createdBy?.message}
|
||||||
invalid={!!formState.errors.createdBy}
|
invalid={!!formState.errors.createdBy}
|
||||||
>
|
>
|
||||||
<Input
|
<Input {...register('createdBy', { required: { value: true, message: 'Required.' } })} placeholder="User" />
|
||||||
{...register('createdBy', { required: { value: true, message: 'Required.' } })}
|
|
||||||
placeholder="Username"
|
|
||||||
/>
|
|
||||||
</Field>
|
</Field>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
<div className={styles.flexRow}>
|
<div className={styles.flexRow}>
|
||||||
|
@ -1,16 +1,29 @@
|
|||||||
import React, { FC, useMemo } from 'react';
|
import React, { FC, useMemo } from 'react';
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2, dateMath } from '@grafana/data';
|
||||||
import { Icon, useStyles2, Link, Button } from '@grafana/ui';
|
import { Icon, useStyles2, Link, Button } from '@grafana/ui';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { AlertmanagerAlert, Silence, SilenceState } from 'app/plugins/datasource/alertmanager/types';
|
import { AlertmanagerAlert, Silence, SilenceState } from 'app/plugins/datasource/alertmanager/types';
|
||||||
import SilenceTableRow from './SilenceTableRow';
|
|
||||||
import { getAlertTableStyles } from '../../styles/table';
|
|
||||||
import { NoSilencesSplash } from './NoSilencesCTA';
|
import { NoSilencesSplash } from './NoSilencesCTA';
|
||||||
import { getFiltersFromUrlParams, makeAMLink } from '../../utils/misc';
|
import { getSilenceFiltersFromUrlParams, makeAMLink } from '../../utils/misc';
|
||||||
import { contextSrv } from 'app/core/services/context_srv';
|
import { contextSrv } from 'app/core/services/context_srv';
|
||||||
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
import { useQueryParams } from 'app/core/hooks/useQueryParams';
|
||||||
import { SilencesFilter } from './SilencesFilter';
|
import { SilencesFilter } from './SilencesFilter';
|
||||||
import { parseMatchers } from '../../utils/alertmanager';
|
import { parseMatchers } from '../../utils/alertmanager';
|
||||||
|
import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable';
|
||||||
|
import { SilenceStateTag } from './SilenceStateTag';
|
||||||
|
import { Matchers } from './Matchers';
|
||||||
|
import { ActionButton } from '../rules/ActionButton';
|
||||||
|
import { ActionIcon } from '../rules/ActionIcon';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { expireSilenceAction } from '../../state/actions';
|
||||||
|
import { SilenceDetails } from './SilenceDetails';
|
||||||
|
|
||||||
|
export interface SilenceTableItem extends Silence {
|
||||||
|
silencedAlerts: AlertmanagerAlert[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type SilenceTableColumnProps = DynamicTableColumnProps<SilenceTableItem>;
|
||||||
|
type SilenceTableItemProps = DynamicTableItemProps<SilenceTableItem>;
|
||||||
interface Props {
|
interface Props {
|
||||||
silences: Silence[];
|
silences: Silence[];
|
||||||
alertManagerAlerts: AlertmanagerAlert[];
|
alertManagerAlerts: AlertmanagerAlert[];
|
||||||
@ -19,18 +32,28 @@ interface Props {
|
|||||||
|
|
||||||
const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSourceName }) => {
|
const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSourceName }) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const tableStyles = useStyles2(getAlertTableStyles);
|
|
||||||
const [queryParams] = useQueryParams();
|
const [queryParams] = useQueryParams();
|
||||||
const filteredSilences = useFilteredSilences(silences);
|
const filteredSilences = useFilteredSilences(silences);
|
||||||
|
|
||||||
const { silenceState } = getFiltersFromUrlParams(queryParams);
|
const { silenceState } = getSilenceFiltersFromUrlParams(queryParams);
|
||||||
|
|
||||||
const showExpiredSilencesBanner =
|
const showExpiredSilencesBanner =
|
||||||
!!filteredSilences.length && (silenceState === undefined || silenceState === SilenceState.Expired);
|
!!filteredSilences.length && (silenceState === undefined || silenceState === SilenceState.Expired);
|
||||||
|
|
||||||
|
const columns = useColumns(alertManagerSourceName);
|
||||||
|
|
||||||
|
const items = useMemo((): SilenceTableItemProps[] => {
|
||||||
const findSilencedAlerts = (id: string) => {
|
const findSilencedAlerts = (id: string) => {
|
||||||
return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id));
|
return alertManagerAlerts.filter((alert) => alert.status.silencedBy.includes(id));
|
||||||
};
|
};
|
||||||
|
return filteredSilences.map((silence) => {
|
||||||
|
const silencedAlerts = findSilencedAlerts(silence.id);
|
||||||
|
return {
|
||||||
|
id: silence.id,
|
||||||
|
data: { ...silence, silencedAlerts },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [filteredSilences, alertManagerAlerts]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="silences-table">
|
<div data-testid="silences-table">
|
||||||
@ -46,48 +69,14 @@ const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSo
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!!filteredSilences.length ? (
|
{!!items.length ? (
|
||||||
<table className={tableStyles.table}>
|
<>
|
||||||
<colgroup>
|
<DynamicTable
|
||||||
<col className={tableStyles.colExpand} />
|
items={items}
|
||||||
<col className={styles.colState} />
|
cols={columns}
|
||||||
<col className={styles.colMatchers} />
|
isExpandable
|
||||||
<col />
|
renderExpandedContent={({ data }) => <SilenceDetails silence={data} />}
|
||||||
<col />
|
|
||||||
{contextSrv.isEditor && <col />}
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th />
|
|
||||||
<th>State</th>
|
|
||||||
<th>Matching labels</th>
|
|
||||||
<th>Alerts</th>
|
|
||||||
<th>Schedule</th>
|
|
||||||
{contextSrv.isEditor && <th>Action</th>}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filteredSilences.map((silence, index) => {
|
|
||||||
const silencedAlerts = findSilencedAlerts(silence.id);
|
|
||||||
return (
|
|
||||||
<SilenceTableRow
|
|
||||||
key={silence.id}
|
|
||||||
silence={silence}
|
|
||||||
className={index % 2 === 0 ? tableStyles.evenRow : undefined}
|
|
||||||
silencedAlerts={silencedAlerts}
|
|
||||||
alertManagerSourceName={alertManagerSourceName}
|
|
||||||
/>
|
/>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
) : (
|
|
||||||
<div className={styles.callout}>
|
|
||||||
<Icon className={styles.calloutIcon} name="info-circle" />
|
|
||||||
<span>No silences match your filters</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showExpiredSilencesBanner && (
|
{showExpiredSilencesBanner && (
|
||||||
<div className={styles.callout}>
|
<div className={styles.callout}>
|
||||||
<Icon className={styles.calloutIcon} name="info-circle" />
|
<Icon className={styles.calloutIcon} name="info-circle" />
|
||||||
@ -95,6 +84,10 @@ const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSo
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
'No matching silences found'
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{!silences.length && <NoSilencesSplash alertManagerSourceName={alertManagerSourceName} />}
|
{!silences.length && <NoSilencesSplash alertManagerSourceName={alertManagerSourceName} />}
|
||||||
</div>
|
</div>
|
||||||
@ -104,7 +97,7 @@ const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSo
|
|||||||
const useFilteredSilences = (silences: Silence[]) => {
|
const useFilteredSilences = (silences: Silence[]) => {
|
||||||
const [queryParams] = useQueryParams();
|
const [queryParams] = useQueryParams();
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const { queryString, silenceState } = getFiltersFromUrlParams(queryParams);
|
const { queryString, silenceState } = getSilenceFiltersFromUrlParams(queryParams);
|
||||||
const silenceIdsString = queryParams?.silenceIds;
|
const silenceIdsString = queryParams?.silenceIds;
|
||||||
return silences.filter((silence) => {
|
return silences.filter((silence) => {
|
||||||
if (typeof silenceIdsString === 'string') {
|
if (typeof silenceIdsString === 'string') {
|
||||||
@ -148,12 +141,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
addNewSilence: css`
|
addNewSilence: css`
|
||||||
margin: ${theme.spacing(2, 0)};
|
margin: ${theme.spacing(2, 0)};
|
||||||
`,
|
`,
|
||||||
colState: css`
|
|
||||||
width: 110px;
|
|
||||||
`,
|
|
||||||
colMatchers: css`
|
|
||||||
width: 50%;
|
|
||||||
`,
|
|
||||||
callout: css`
|
callout: css`
|
||||||
background-color: ${theme.colors.background.secondary};
|
background-color: ${theme.colors.background.secondary};
|
||||||
border-top: 3px solid ${theme.colors.info.border};
|
border-top: 3px solid ${theme.colors.info.border};
|
||||||
@ -171,6 +158,95 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
calloutIcon: css`
|
calloutIcon: css`
|
||||||
color: ${theme.colors.info.text};
|
color: ${theme.colors.info.text};
|
||||||
`,
|
`,
|
||||||
|
editButton: css`
|
||||||
|
margin-left: ${theme.spacing(0.5)};
|
||||||
|
`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function useColumns(alertManagerSourceName: string) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
return useMemo((): SilenceTableColumnProps[] => {
|
||||||
|
const handleExpireSilenceClick = (id: string) => {
|
||||||
|
dispatch(expireSilenceAction(alertManagerSourceName, id));
|
||||||
|
};
|
||||||
|
const showActions = contextSrv.isEditor;
|
||||||
|
const columns: SilenceTableColumnProps[] = [
|
||||||
|
{
|
||||||
|
id: 'state',
|
||||||
|
label: 'State',
|
||||||
|
renderCell: function renderStateTag({ data: { status } }) {
|
||||||
|
return <SilenceStateTag state={status.state} />;
|
||||||
|
},
|
||||||
|
size: '88px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'matchers',
|
||||||
|
label: 'Matching labels',
|
||||||
|
renderCell: function renderMatchers({ data: { matchers } }) {
|
||||||
|
return <Matchers matchers={matchers || []} />;
|
||||||
|
},
|
||||||
|
size: 9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'alerts',
|
||||||
|
label: 'Alerts',
|
||||||
|
renderCell: function renderSilencedAlerts({ data: { silencedAlerts } }) {
|
||||||
|
return <span data-testid="alerts">{silencedAlerts.length}</span>;
|
||||||
|
},
|
||||||
|
size: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'schedule',
|
||||||
|
label: 'Schedule',
|
||||||
|
renderCell: function renderSchedule({ data: { startsAt, endsAt } }) {
|
||||||
|
const startsAtDate = dateMath.parse(startsAt);
|
||||||
|
const endsAtDate = dateMath.parse(endsAt);
|
||||||
|
const dateDisplayFormat = 'YYYY-MM-DD HH:mm';
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
{startsAtDate?.format(dateDisplayFormat)} {'-'}
|
||||||
|
<br />
|
||||||
|
{endsAtDate?.format(dateDisplayFormat)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
size: '150px',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (showActions) {
|
||||||
|
columns.push({
|
||||||
|
id: 'actions',
|
||||||
|
label: 'Actions',
|
||||||
|
renderCell: function renderActions({ data: silence }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{silence.status.state === 'expired' ? (
|
||||||
|
<Link href={makeAMLink(`/alerting/silence/${silence.id}/edit`, alertManagerSourceName)}>
|
||||||
|
<ActionButton icon="sync">Recreate</ActionButton>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<ActionButton icon="bell" onClick={() => handleExpireSilenceClick(silence.id)}>
|
||||||
|
Unsilence
|
||||||
|
</ActionButton>
|
||||||
|
)}
|
||||||
|
{silence.status.state !== 'expired' && (
|
||||||
|
<ActionIcon
|
||||||
|
className={styles.editButton}
|
||||||
|
to={makeAMLink(`/alerting/silence/${silence.id}/edit`, alertManagerSourceName)}
|
||||||
|
icon="pen"
|
||||||
|
tooltip="edit"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
size: '140px',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return columns;
|
||||||
|
}, [alertManagerSourceName, dispatch, styles]);
|
||||||
|
}
|
||||||
|
|
||||||
export default SilencesTable;
|
export default SilencesTable;
|
||||||
|
@ -14,9 +14,6 @@ export const useFilteredRules = (namespaces: CombinedRuleNamespace[]) => {
|
|||||||
const filters = getFiltersFromUrlParams(queryParams);
|
const filters = getFiltersFromUrlParams(queryParams);
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (!filters.queryString && !filters.dataSource && !filters.alertState) {
|
|
||||||
return namespaces;
|
|
||||||
}
|
|
||||||
const filteredNamespaces = namespaces
|
const filteredNamespaces = namespaces
|
||||||
// Filter by data source
|
// Filter by data source
|
||||||
// TODO: filter by multiple data sources for grafana-managed alerts
|
// TODO: filter by multiple data sources for grafana-managed alerts
|
||||||
@ -48,6 +45,9 @@ const reduceNamespaces = (filters: FilterState) => {
|
|||||||
const reduceGroups = (filters: FilterState) => {
|
const reduceGroups = (filters: FilterState) => {
|
||||||
return (groupAcc: CombinedRuleGroup[], group: CombinedRuleGroup) => {
|
return (groupAcc: CombinedRuleGroup[], group: CombinedRuleGroup) => {
|
||||||
const rules = group.rules.filter((rule) => {
|
const rules = group.rules.filter((rule) => {
|
||||||
|
if (filters.ruleType && filters.ruleType !== rule.promRule?.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (filters.dataSource && isGrafanaRulerRule(rule.rulerRule) && !isQueryingDataSource(rule.rulerRule, filters)) {
|
if (filters.dataSource && isGrafanaRulerRule(rule.rulerRule) && !isQueryingDataSource(rule.rulerRule, filters)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -37,9 +37,9 @@ export const getFiltersFromUrlParams = (queryParams: UrlQueryMap): FilterState =
|
|||||||
const queryString = queryParams['queryString'] === undefined ? undefined : String(queryParams['queryString']);
|
const queryString = queryParams['queryString'] === undefined ? undefined : String(queryParams['queryString']);
|
||||||
const alertState = queryParams['alertState'] === undefined ? undefined : String(queryParams['alertState']);
|
const alertState = queryParams['alertState'] === undefined ? undefined : String(queryParams['alertState']);
|
||||||
const dataSource = queryParams['dataSource'] === undefined ? undefined : String(queryParams['dataSource']);
|
const dataSource = queryParams['dataSource'] === undefined ? undefined : String(queryParams['dataSource']);
|
||||||
|
const ruleType = queryParams['ruleType'] === undefined ? undefined : String(queryParams['ruleType']);
|
||||||
const groupBy = queryParams['groupBy'] === undefined ? undefined : String(queryParams['groupBy']).split(',');
|
const groupBy = queryParams['groupBy'] === undefined ? undefined : String(queryParams['groupBy']).split(',');
|
||||||
const silenceState = queryParams['silenceState'] === undefined ? undefined : String(queryParams['silenceState']);
|
return { queryString, alertState, dataSource, groupBy, ruleType };
|
||||||
return { queryString, alertState, dataSource, groupBy, silenceState };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSilenceFiltersFromUrlParams = (queryParams: UrlQueryMap): SilenceFilterState => {
|
export const getSilenceFiltersFromUrlParams = (queryParams: UrlQueryMap): SilenceFilterState => {
|
||||||
|
@ -133,7 +133,7 @@ export interface FilterState {
|
|||||||
dataSource?: string;
|
dataSource?: string;
|
||||||
alertState?: string;
|
alertState?: string;
|
||||||
groupBy?: string[];
|
groupBy?: string[];
|
||||||
silenceState?: string;
|
ruleType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SilenceFilterState {
|
export interface SilenceFilterState {
|
||||||
|
Loading…
Reference in New Issue
Block a user