mirror of
https://github.com/grafana/grafana.git
synced 2026-08-07 11:55:03 -05:00
Alerting: Alert activity UI improvements part 3 (#121790)
* rename open drawer button * cherry-pick summaryStats fixes and labels sidebar improvements * update alert instance title design and add firing / pending state to the instance(s) * remove summary stats rule counts * update translations * refactor & improve alignment --------- Co-authored-by: Gilles De Mey <gilles.de.mey@gmail.com>
This commit is contained in:
@@ -28,7 +28,6 @@ import { GroupRow } from './rows/GroupRow';
|
||||
import { generateRowKey } from './rows/utils';
|
||||
import { GenericRowSkeleton } from './scene/AlertRuleInstances';
|
||||
import { SummaryChartReact } from './scene/SummaryChart';
|
||||
import { SummaryStatsReact } from './scene/SummaryStats';
|
||||
import { LabelsColumn } from './scene/filters/LabelsColumn';
|
||||
import { type Domain, type Filter, type WorkbenchRow } from './types';
|
||||
|
||||
@@ -43,7 +42,7 @@ type WorkbenchProps = {
|
||||
hasActiveFilters?: boolean;
|
||||
};
|
||||
|
||||
const initialSize = 1 / 2;
|
||||
const initialSize = 2 / 3;
|
||||
|
||||
// Helper function to recursively render WorkbenchRow items with children pattern
|
||||
function renderWorkbenchRow(
|
||||
@@ -164,7 +163,7 @@ export function Workbench({
|
||||
// splitter for template and payload editor
|
||||
const splitter = useSplitter({
|
||||
direction: 'row',
|
||||
// if Grafana Alertmanager, split 50/50, otherwise 100/0 because there is no payload editor
|
||||
// if Grafana Alertmanager, split 2/3 : 1/3, otherwise 100/0 because there is no payload editor
|
||||
initialSize: initialSize,
|
||||
dragPosition: 'middle',
|
||||
});
|
||||
@@ -224,7 +223,7 @@ export function Workbench({
|
||||
) : (
|
||||
<>
|
||||
<div className={cx(styles.groupItemWrapper(leftColumnWidth), styles.summaryContainer)}>
|
||||
<SummaryStatsReact />
|
||||
<div />
|
||||
<SummaryChartReact />
|
||||
</div>
|
||||
{groupBy && groupBy.length > 0 && (
|
||||
@@ -314,6 +313,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
overflow: 'hidden', // Let AutoSizer handle the overflow
|
||||
}),
|
||||
summaryContainer: css({
|
||||
height: theme.spacing(20),
|
||||
marginBottom: theme.spacing(2),
|
||||
alignItems: 'stretch',
|
||||
}),
|
||||
|
||||
+28
-6
@@ -19,7 +19,7 @@ import {
|
||||
TextLink,
|
||||
useStyles2,
|
||||
} from '@grafana/ui';
|
||||
import { type AlertQuery, type GrafanaRuleDefinition } from 'app/types/unified-alerting-dto';
|
||||
import { type AlertQuery, GrafanaAlertState, type GrafanaRuleDefinition } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { alertRuleApi } from '../../api/alertRuleApi';
|
||||
import { stateHistoryApi } from '../../api/stateHistoryApi';
|
||||
@@ -54,10 +54,11 @@ function calculateDrawerWidth(rightColumnWidth: number): number {
|
||||
interface InstanceDetailsDrawerProps {
|
||||
ruleUID: string;
|
||||
instanceLabels: Labels;
|
||||
commonLabels?: Labels;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function InstanceDetailsDrawer({ ruleUID, instanceLabels, onClose }: InstanceDetailsDrawerProps) {
|
||||
export function InstanceDetailsDrawer({ ruleUID, instanceLabels, commonLabels, onClose }: InstanceDetailsDrawerProps) {
|
||||
const [ref, { width: loadingBarWidth }] = useMeasure<HTMLDivElement>();
|
||||
const [timeRange] = useTimeRange();
|
||||
const { rightColumnWidth } = useWorkbenchContext();
|
||||
@@ -108,7 +109,13 @@ export function InstanceDetailsDrawer({ ruleUID, instanceLabels, onClose }: Inst
|
||||
if (error) {
|
||||
return (
|
||||
<Drawer
|
||||
title={<InstanceDetailsDrawerTitle instanceLabels={instanceLabels} />}
|
||||
title={
|
||||
<InstanceDetailsDrawerTitle
|
||||
instanceLabels={instanceLabels}
|
||||
commonLabels={commonLabels}
|
||||
alertState={instanceState}
|
||||
/>
|
||||
}
|
||||
onClose={onClose}
|
||||
width={drawerWidth}
|
||||
>
|
||||
@@ -120,7 +127,13 @@ export function InstanceDetailsDrawer({ ruleUID, instanceLabels, onClose }: Inst
|
||||
if (loading || !rule) {
|
||||
return (
|
||||
<Drawer
|
||||
title={<InstanceDetailsDrawerTitle instanceLabels={instanceLabels} />}
|
||||
title={
|
||||
<InstanceDetailsDrawerTitle
|
||||
instanceLabels={instanceLabels}
|
||||
commonLabels={commonLabels}
|
||||
alertState={instanceState}
|
||||
/>
|
||||
}
|
||||
onClose={onClose}
|
||||
width={drawerWidth}
|
||||
>
|
||||
@@ -131,7 +144,14 @@ export function InstanceDetailsDrawer({ ruleUID, instanceLabels, onClose }: Inst
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={<InstanceDetailsDrawerTitle instanceLabels={instanceLabels} rule={rule.grafana_alert} />}
|
||||
title={
|
||||
<InstanceDetailsDrawerTitle
|
||||
instanceLabels={instanceLabels}
|
||||
commonLabels={commonLabels}
|
||||
alertState={instanceState}
|
||||
rule={rule.grafana_alert}
|
||||
/>
|
||||
}
|
||||
onClose={onClose}
|
||||
width={drawerWidth}
|
||||
>
|
||||
@@ -140,7 +160,9 @@ export function InstanceDetailsDrawer({ ruleUID, instanceLabels, onClose }: Inst
|
||||
<TimeRangePicker />
|
||||
</Stack>
|
||||
{showDrawerTimeRangeBanner && !instanceState && <DrawerTimeRangeInfoBanner />}
|
||||
{instanceState && <InstanceStateInfoBanner state={instanceState} />}
|
||||
{(instanceState === GrafanaAlertState.NoData || instanceState === GrafanaAlertState.Error) && (
|
||||
<InstanceStateInfoBanner state={instanceState === GrafanaAlertState.NoData ? 'nodata' : 'error'} />
|
||||
)}
|
||||
{dataQueries.length > 0 && (
|
||||
<Box>
|
||||
<Stack direction="column" gap={2}>
|
||||
|
||||
+118
-64
@@ -1,11 +1,11 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { AlertLabels } from '@grafana/alerting/unstable';
|
||||
import { AlertLabels, StateText } from '@grafana/alerting/unstable';
|
||||
import { type Labels } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { Box, Button, LinkButton, Stack, Text, Tooltip } from '@grafana/ui';
|
||||
import { AccessControlAction } from 'app/types/accessControl';
|
||||
import { type GrafanaRuleDefinition } from 'app/types/unified-alerting-dto';
|
||||
import { GrafanaAlertState, type GrafanaRuleDefinition } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { createBridgeURL } from '../../components/PluginBridge';
|
||||
import { useCanCreateSilences } from '../../hooks/useAbilities';
|
||||
@@ -17,12 +17,44 @@ import { isLocalDevEnv, isOpenSourceEdition, makeLabelBasedSilenceLink } from '.
|
||||
|
||||
import { InstanceLocation } from './InstanceDetailsDrawer';
|
||||
|
||||
type StateTextState = 'normal' | 'firing' | 'pending' | 'recovering' | 'unknown';
|
||||
type StateTextHealth = 'ok' | 'nodata' | 'error';
|
||||
|
||||
function grafanaAlertStateToStateTextProps(state: GrafanaAlertState): {
|
||||
state?: StateTextState;
|
||||
health?: StateTextHealth;
|
||||
} {
|
||||
switch (state) {
|
||||
case GrafanaAlertState.Alerting:
|
||||
return { state: 'firing' };
|
||||
case GrafanaAlertState.Pending:
|
||||
return { state: 'pending' };
|
||||
case GrafanaAlertState.Normal:
|
||||
return { state: 'normal' };
|
||||
case GrafanaAlertState.Recovering:
|
||||
return { state: 'recovering' };
|
||||
case GrafanaAlertState.NoData:
|
||||
return { health: 'nodata' };
|
||||
case GrafanaAlertState.Error:
|
||||
return { health: 'error' };
|
||||
default:
|
||||
return { state: 'unknown' };
|
||||
}
|
||||
}
|
||||
|
||||
interface InstanceDetailsDrawerTitleProps {
|
||||
instanceLabels: Labels;
|
||||
commonLabels?: Labels;
|
||||
alertState?: GrafanaAlertState | null;
|
||||
rule?: GrafanaRuleDefinition;
|
||||
}
|
||||
|
||||
export function InstanceDetailsDrawerTitle({ instanceLabels, rule }: InstanceDetailsDrawerTitleProps) {
|
||||
export function InstanceDetailsDrawerTitle({
|
||||
instanceLabels,
|
||||
commonLabels,
|
||||
alertState,
|
||||
rule,
|
||||
}: InstanceDetailsDrawerTitleProps) {
|
||||
const { folder } = useFolder(rule?.namespace_uid);
|
||||
const { pluginId, installed, settings } = useIrmPlugin(SupportedPlugin.Incident);
|
||||
const canCreateSilence = useCanCreateSilences();
|
||||
@@ -43,69 +75,8 @@ export function InstanceDetailsDrawerTitle({ instanceLabels, rule }: InstanceDet
|
||||
: false;
|
||||
const hasFolderSilencePermission = folder?.accessControl?.[AccessControlAction.AlertingSilenceCreate] ?? false;
|
||||
const canSilence = canCreateSilence || hasFolderSilencePermission;
|
||||
|
||||
return (
|
||||
<Stack direction="column" gap={2}>
|
||||
<Text variant="h3" element="h3" truncate>
|
||||
<Trans i18nKey="alerting.triage.instance-details-drawer.instance-details">Instance details</Trans>
|
||||
</Text>
|
||||
<Stack direction="row" gap={1}>
|
||||
{silenceLink && canSilence && (
|
||||
<LinkButton
|
||||
href={silenceLink}
|
||||
icon="bell-slash"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Trans i18nKey="alerting.triage.instance-details-drawer.silence-button">Silence</Trans>
|
||||
</LinkButton>
|
||||
)}
|
||||
{silenceLink && !canSilence && (
|
||||
<Tooltip
|
||||
content={t(
|
||||
'alerting.triage.instance-details-drawer.silence-no-permission',
|
||||
'You do not have permission to create silences'
|
||||
)}
|
||||
>
|
||||
<Button icon="bell-slash" variant="secondary" size="sm" disabled>
|
||||
<Trans i18nKey="alerting.triage.instance-details-drawer.silence-button">Silence</Trans>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{shouldShowDeclareIncident && canAccessIncident && (
|
||||
<LinkButton
|
||||
href={incidentURL}
|
||||
icon="fire"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Trans i18nKey="alerting.triage.instance-details-drawer.declare-incident">Declare incident</Trans>
|
||||
</LinkButton>
|
||||
)}
|
||||
{shouldShowDeclareIncident && !canAccessIncident && (
|
||||
<Tooltip
|
||||
content={t(
|
||||
'alerting.triage.instance-details-drawer.declare-incident-no-permission',
|
||||
'You do not have permission to access Incident'
|
||||
)}
|
||||
>
|
||||
<Button icon="fire" variant="secondary" size="sm" disabled>
|
||||
<Trans i18nKey="alerting.triage.instance-details-drawer.declare-incident">Declare incident</Trans>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
<Box>
|
||||
{Object.keys(instanceLabels).length > 0 ? (
|
||||
<AlertLabels labels={instanceLabels} />
|
||||
) : (
|
||||
<Text color="secondary">{t('alerting.triage.no-labels', 'No labels')}</Text>
|
||||
)}
|
||||
</Box>
|
||||
{folder && rule && (
|
||||
<InstanceLocation
|
||||
folderTitle={stringifyFolder(folder)}
|
||||
@@ -115,6 +86,89 @@ export function InstanceDetailsDrawerTitle({ instanceLabels, rule }: InstanceDet
|
||||
ruleUid={rule.uid}
|
||||
/>
|
||||
)}
|
||||
<Stack direction="column" gap={0.5}>
|
||||
<Text variant="bodySmall" color="secondary">
|
||||
<Trans i18nKey="alerting.triage.instance-details-drawer.alert-instance-label">Alert Instance</Trans>
|
||||
</Text>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" gap={1}>
|
||||
<Stack direction="row" alignItems="center" gap={1} minWidth={0}>
|
||||
<Text variant="h3" element="h3" truncate>
|
||||
{rule?.title ?? (
|
||||
<Trans i18nKey="alerting.triage.instance-details-drawer.instance-details">Instance details</Trans>
|
||||
)}
|
||||
</Text>
|
||||
{alertState && <StateText type="alerting" {...grafanaAlertStateToStateTextProps(alertState)} />}
|
||||
</Stack>
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
{silenceLink && (
|
||||
<>
|
||||
{canSilence ? (
|
||||
<LinkButton
|
||||
href={silenceLink}
|
||||
icon="bell-slash"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Trans i18nKey="alerting.triage.instance-details-drawer.silence-button">Silence</Trans>
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Tooltip
|
||||
content={t(
|
||||
'alerting.triage.instance-details-drawer.silence-no-permission',
|
||||
'You do not have permission to create silences'
|
||||
)}
|
||||
>
|
||||
<Button icon="bell-slash" variant="secondary" size="sm" disabled>
|
||||
<Trans i18nKey="alerting.triage.instance-details-drawer.silence-button">Silence</Trans>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{shouldShowDeclareIncident && (
|
||||
<>
|
||||
{canAccessIncident ? (
|
||||
<LinkButton
|
||||
href={incidentURL}
|
||||
icon="fire"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Trans i18nKey="alerting.triage.instance-details-drawer.declare-incident">Declare incident</Trans>
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Tooltip
|
||||
content={t(
|
||||
'alerting.triage.instance-details-drawer.declare-incident-no-permission',
|
||||
'You do not have permission to access Incident'
|
||||
)}
|
||||
>
|
||||
<Button icon="fire" variant="secondary" size="sm" disabled>
|
||||
<Trans i18nKey="alerting.triage.instance-details-drawer.declare-incident">Declare incident</Trans>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Box>
|
||||
{Object.keys(instanceLabels).length > 0 ? (
|
||||
<AlertLabels
|
||||
labels={instanceLabels}
|
||||
displayCommonLabels={commonLabels !== undefined}
|
||||
labelSets={commonLabels !== undefined ? [instanceLabels, commonLabels] : undefined}
|
||||
commonLabelsMode="tooltip"
|
||||
/>
|
||||
) : (
|
||||
<Text color="secondary">{t('alerting.triage.no-labels', 'No labels')}</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
+16
-14
@@ -1,6 +1,7 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { FieldType } from '@grafana/data';
|
||||
import { GrafanaAlertState } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import {
|
||||
buildInstanceStateQueryExpr,
|
||||
@@ -80,20 +81,21 @@ describe('instanceStateUtils', () => {
|
||||
expect(getInstanceStateFromMetricSeries([])).toBe(null);
|
||||
});
|
||||
|
||||
it('returns "nodata" when grafana_alertstate is nodata', () => {
|
||||
expect(getInstanceStateFromMetricSeries(makeSeries('nodata'))).toBe('nodata');
|
||||
expect(getInstanceStateFromMetricSeries(makeSeries('NoData'))).toBe('nodata');
|
||||
it('returns NoData when grafana_alertstate is nodata', () => {
|
||||
expect(getInstanceStateFromMetricSeries(makeSeries('nodata'))).toBe(GrafanaAlertState.NoData);
|
||||
expect(getInstanceStateFromMetricSeries(makeSeries('NoData'))).toBe(GrafanaAlertState.NoData);
|
||||
});
|
||||
|
||||
it('returns "error" when grafana_alertstate is error', () => {
|
||||
expect(getInstanceStateFromMetricSeries(makeSeries('error'))).toBe('error');
|
||||
expect(getInstanceStateFromMetricSeries(makeSeries('Error'))).toBe('error');
|
||||
it('returns Error when grafana_alertstate is error', () => {
|
||||
expect(getInstanceStateFromMetricSeries(makeSeries('error'))).toBe(GrafanaAlertState.Error);
|
||||
expect(getInstanceStateFromMetricSeries(makeSeries('Error'))).toBe(GrafanaAlertState.Error);
|
||||
});
|
||||
|
||||
it('returns null for other states (alerting, pending, recovering)', () => {
|
||||
expect(getInstanceStateFromMetricSeries(makeSeries('alerting'))).toBe(null);
|
||||
expect(getInstanceStateFromMetricSeries(makeSeries('pending'))).toBe(null);
|
||||
expect(getInstanceStateFromMetricSeries(makeSeries('recovering'))).toBe(null);
|
||||
it('returns the correct GrafanaAlertState for all states', () => {
|
||||
expect(getInstanceStateFromMetricSeries(makeSeries('alerting'))).toBe(GrafanaAlertState.Alerting);
|
||||
expect(getInstanceStateFromMetricSeries(makeSeries('pending'))).toBe(GrafanaAlertState.Pending);
|
||||
expect(getInstanceStateFromMetricSeries(makeSeries('recovering'))).toBe(GrafanaAlertState.Recovering);
|
||||
expect(getInstanceStateFromMetricSeries(makeSeries('normal'))).toBe(GrafanaAlertState.Normal);
|
||||
});
|
||||
|
||||
it('returns null when value field has no grafana_alertstate label', () => {
|
||||
@@ -129,7 +131,7 @@ describe('instanceStateUtils', () => {
|
||||
expect(result.current).toBe(null);
|
||||
});
|
||||
|
||||
it('returns nodata when series has grafana_alertstate nodata', () => {
|
||||
it('returns GrafanaAlertState.NoData when series has grafana_alertstate nodata', () => {
|
||||
mockUseQueryRunner.mockReturnValue({
|
||||
useState: () => ({
|
||||
data: {
|
||||
@@ -152,10 +154,10 @@ describe('instanceStateUtils', () => {
|
||||
}),
|
||||
});
|
||||
const { result } = renderHook(() => useInstanceAlertState('rule-1', {}));
|
||||
expect(result.current).toBe('nodata');
|
||||
expect(result.current).toBe(GrafanaAlertState.NoData);
|
||||
});
|
||||
|
||||
it('returns error when series has grafana_alertstate error', () => {
|
||||
it('returns GrafanaAlertState.Error when series has grafana_alertstate error', () => {
|
||||
mockUseQueryRunner.mockReturnValue({
|
||||
useState: () => ({
|
||||
data: {
|
||||
@@ -178,7 +180,7 @@ describe('instanceStateUtils', () => {
|
||||
}),
|
||||
});
|
||||
const { result } = renderHook(() => useInstanceAlertState('rule-1', {}));
|
||||
expect(result.current).toBe('error');
|
||||
expect(result.current).toBe(GrafanaAlertState.Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import { type DataFrame, type Labels } from '@grafana/data';
|
||||
import { useQueryRunner } from '@grafana/scenes-react';
|
||||
import { GrafanaAlertState } from 'app/types/unified-alerting-dto';
|
||||
|
||||
import { DATASOURCE_UID, METRIC_NAME } from '../constants';
|
||||
|
||||
@@ -26,11 +27,21 @@ export function buildInstanceStateQueryExpr(ruleUID: string, instanceLabels: Lab
|
||||
|
||||
const GRAFANA_ALERTSTATE_LABEL = 'grafana_alertstate';
|
||||
|
||||
// Maps the lowercase grafana_alertstate label values to GrafanaAlertState title-case enum values.
|
||||
const ALERTSTATE_LABEL_TO_GRAFANA_STATE: Record<string, GrafanaAlertState> = {
|
||||
alerting: GrafanaAlertState.Alerting,
|
||||
pending: GrafanaAlertState.Pending,
|
||||
normal: GrafanaAlertState.Normal,
|
||||
recovering: GrafanaAlertState.Recovering,
|
||||
nodata: GrafanaAlertState.NoData,
|
||||
error: GrafanaAlertState.Error,
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads grafana_alertstate from the first series in the query result.
|
||||
* The backend writes it lowercase (nodata, error, alerting, pending, recovering).
|
||||
* Reads grafana_alertstate from the first series in the query result and maps it to GrafanaAlertState.
|
||||
* The backend writes it lowercase (alerting, pending, normal, recovering, nodata, error).
|
||||
*/
|
||||
export function getInstanceStateFromMetricSeries(series: DataFrame[] | undefined): 'nodata' | 'error' | null {
|
||||
export function getInstanceStateFromMetricSeries(series: DataFrame[] | undefined): GrafanaAlertState | null {
|
||||
if (!series?.length) {
|
||||
return null;
|
||||
}
|
||||
@@ -40,17 +51,15 @@ export function getInstanceStateFromMetricSeries(series: DataFrame[] | undefined
|
||||
if (typeof stateLabel !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const normalized = stateLabel.toLowerCase();
|
||||
return normalized === 'nodata' || normalized === 'error' ? normalized : null;
|
||||
return ALERTSTATE_LABEL_TO_GRAFANA_STATE[stateLabel.toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
export type InstanceAlertState = 'nodata' | 'error' | null;
|
||||
|
||||
/**
|
||||
* Queries GRAFANA_ALERTS for the current instance state when state history Prometheus is configured.
|
||||
* Returns nodata/error when the instance is in that state, otherwise null.
|
||||
* Returns the full GrafanaAlertState (Alerting, Pending, Normal, NoData, Error, Recovering), or null
|
||||
* if the datasource is not configured or the state cannot be determined.
|
||||
*/
|
||||
export function useInstanceAlertState(ruleUID: string, instanceLabels: Labels): InstanceAlertState {
|
||||
export function useInstanceAlertState(ruleUID: string, instanceLabels: Labels): GrafanaAlertState | null {
|
||||
const query = useMemo(() => {
|
||||
if (!DATASOURCE_UID) {
|
||||
return null;
|
||||
|
||||
@@ -52,6 +52,7 @@ export const AlertRuleRow = ({
|
||||
<OpenDrawerButton
|
||||
aria-label={t('alerting.triage.open-rule-details', 'Open rule details')}
|
||||
onClick={handleDrawerOpen}
|
||||
text={t('alerting.open-drawer-icon-button.rule-details', 'Rule details')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,9 @@ import { IconButton, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { Spacer } from '../../components/Spacer';
|
||||
import { useWorkbenchContext } from '../WorkbenchContext';
|
||||
|
||||
// Width of the md IconButton used as the expand/collapse chevron, in pixels.
|
||||
const CHEVRON_WIDTH_PX = 24;
|
||||
|
||||
interface GenericRowProps {
|
||||
width: number;
|
||||
title: ReactNode;
|
||||
@@ -121,7 +124,7 @@ const LeftCell = ({ title, metadata = null, actions = null, isOpen = true, onTog
|
||||
|
||||
return (
|
||||
<Stack direction="row" alignItems="center" gap={0.5}>
|
||||
{onToggle && (
|
||||
{onToggle ? (
|
||||
<IconButton
|
||||
name={isOpen ? 'angle-down' : 'angle-right'}
|
||||
onClick={onToggle}
|
||||
@@ -130,6 +133,8 @@ const LeftCell = ({ title, metadata = null, actions = null, isOpen = true, onTog
|
||||
size="md"
|
||||
aria-label={t('alerting.group-wrapper.toggle', 'Toggle group')}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.chevronPlaceholder} />
|
||||
)}
|
||||
<Stack direction="column" alignItems="flex-start" gap={0} flex={1}>
|
||||
<Stack direction="row" alignItems="center" gap={1} width="100%">
|
||||
@@ -182,5 +187,9 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
borderLeft: `1px solid ${theme.colors.border.weak}`,
|
||||
paddingLeft: theme.spacing(1),
|
||||
}),
|
||||
chevronPlaceholder: css({
|
||||
width: CHEVRON_WIDTH_PX,
|
||||
flexShrink: 0,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { css } from '@emotion/css';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import { type GrafanaTheme2 } from '@grafana/data';
|
||||
import { Icon, Text, useStyles2 } from '@grafana/ui';
|
||||
import { Stack } from '@grafana/ui';
|
||||
|
||||
import { FiringCount, PendingCount } from '../scene/BadgeCounts';
|
||||
import { type InstanceCounts } from '../types';
|
||||
|
||||
interface RowActionsProps {
|
||||
@@ -12,63 +11,15 @@ interface RowActionsProps {
|
||||
}
|
||||
|
||||
export function RowActions({ counts, actionButton }: RowActionsProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { firing, pending } = counts;
|
||||
|
||||
return (
|
||||
<div className={styles.grid}>
|
||||
<div className={styles.slot}>
|
||||
{pending > 0 && (
|
||||
<Text color="warning">
|
||||
<span className={styles.badge}>
|
||||
<Icon name="circle" size="xs" />
|
||||
<CountText value={pending} />
|
||||
</span>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.slot}>
|
||||
{firing > 0 && (
|
||||
<Text color="error">
|
||||
<span className={styles.badge}>
|
||||
<Icon name="exclamation-circle" size="xs" />
|
||||
<CountText value={firing} />
|
||||
</span>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.slot}>{actionButton}</div>
|
||||
</div>
|
||||
<Stack direction="row" gap={1} alignItems="center">
|
||||
<Stack direction="row" gap={0.5} alignItems="center">
|
||||
{pending > 0 && <PendingCount count={pending} />}
|
||||
{firing > 0 && <FiringCount count={firing} />}
|
||||
</Stack>
|
||||
{actionButton}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function CountText({ value }: { value: number }) {
|
||||
const styles = useStyles2(getStyles);
|
||||
return <span className={styles.countText}>{value}</span>;
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
grid: css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 2fr',
|
||||
gap: theme.spacing(0.5),
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
}),
|
||||
slot: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
}),
|
||||
badge: css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(0.25),
|
||||
}),
|
||||
countText: css({
|
||||
...theme.typography.bodySmall,
|
||||
display: 'inline-block',
|
||||
minWidth: '1.5em',
|
||||
textAlign: 'center',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -120,6 +120,7 @@ export function InstanceRow({
|
||||
<OpenDrawerButton
|
||||
aria-label={t('alerting.triage.open-in-sidebar', 'Open in sidebar')}
|
||||
onClick={handleDrawerOpen}
|
||||
text={t('alerting.open-drawer-icon-button.instance-details', 'Instance details')}
|
||||
/>
|
||||
}
|
||||
content={
|
||||
@@ -142,7 +143,12 @@ export function InstanceRow({
|
||||
to: typeof timeRange.raw.to === 'string' ? timeRange.raw.to : timeRange.raw.to.toISOString(),
|
||||
}}
|
||||
>
|
||||
<InstanceDetailsDrawer ruleUID={ruleUID} instanceLabels={instance.labels} onClose={handleDrawerClose} />
|
||||
<InstanceDetailsDrawer
|
||||
ruleUID={ruleUID}
|
||||
instanceLabels={instance.labels}
|
||||
commonLabels={commonLabels}
|
||||
onClose={handleDrawerClose}
|
||||
/>
|
||||
</SceneContextProvider>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import { Trans } from '@grafana/i18n';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { Button } from '@grafana/ui';
|
||||
|
||||
interface OpenDrawerButtonProps {
|
||||
onClick: () => void;
|
||||
text: string;
|
||||
['aria-label']: string;
|
||||
}
|
||||
|
||||
export const OpenDrawerButton = memo(function OpenDrawerButton({
|
||||
onClick,
|
||||
text = t('alerting.open-drawer-icon-button.details', 'Details'),
|
||||
['aria-label']: ariaLabel,
|
||||
}: OpenDrawerButtonProps) {
|
||||
return (
|
||||
<Button variant="secondary" fill="outline" size="sm" aria-label={ariaLabel} onClick={onClick}>
|
||||
<Trans i18nKey="alerting.open-drawer-icon-button.details">Details</Trans>
|
||||
{text}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { LegendDisplayMode, StackingMode, TooltipDisplayMode } from '@grafana/ui
|
||||
import { overrideToFixedColor } from '../../home/Insights';
|
||||
|
||||
import { summaryChartQuery } from './queries';
|
||||
import { useQueryFilter } from './utils';
|
||||
import { cleanAlertStateFilter, useQueryFilter } from './utils';
|
||||
|
||||
/**
|
||||
* Viz config for the summary chart - used by the React component
|
||||
@@ -36,9 +36,11 @@ export const summaryChartVizConfig = VizConfigBuilders.timeseries()
|
||||
|
||||
export function SummaryChartReact() {
|
||||
const filter = useQueryFilter();
|
||||
// summaryChartQuery groups by alertstate, so remove any user-supplied alertstate matcher.
|
||||
const cleanFilter = cleanAlertStateFilter(filter);
|
||||
|
||||
const dataProvider = useQueryRunner({
|
||||
queries: [summaryChartQuery(filter)],
|
||||
queries: [summaryChartQuery(cleanFilter)],
|
||||
});
|
||||
|
||||
return <VizPanel title="" viz={summaryChartVizConfig} dataProvider={dataProvider} hoverHeader={true} />;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { FIELD_NAMES } from '../constants';
|
||||
|
||||
import { normalizeFrame } from './dataTransform';
|
||||
import { summaryRuleCountQuery } from './queries';
|
||||
import { useQueryFilter } from './utils';
|
||||
import { cleanAlertStateFilter, useQueryFilter } from './utils';
|
||||
|
||||
type AlertState = PromAlertingRuleState.Firing | PromAlertingRuleState.Pending;
|
||||
|
||||
@@ -88,11 +88,8 @@ function SummaryStatsContent() {
|
||||
const styles = useStyles2(getCompactStatStyles);
|
||||
const filter = useQueryFilter();
|
||||
|
||||
// Strip alertstate from filter since the dedup queries add their own alertstate matchers
|
||||
const cleanFilter = filter
|
||||
.replace(/alertstate\s*=~?\s*"(firing|pending)"[,\s]*/, '')
|
||||
.replace(/,\s*$/, '')
|
||||
.replace(/^\s*,/, '');
|
||||
// Strip alertstate from filter since the dedup queries add their own alertstate matchers.
|
||||
const cleanFilter = cleanAlertStateFilter(filter);
|
||||
|
||||
const ruleDataProvider = useQueryRunner({
|
||||
queries: [summaryRuleCountQuery(cleanFilter)],
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
GroupByVariable,
|
||||
SceneControlsSpacer,
|
||||
SceneFlexLayout,
|
||||
SceneReactObject,
|
||||
SceneRefreshPicker,
|
||||
SceneTimePicker,
|
||||
SceneTimeRange,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
behaviors,
|
||||
} from '@grafana/scenes';
|
||||
import { EmbeddedSceneWithContext } from '@grafana/scenes-react';
|
||||
import { useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { DATASOURCE_UID } from '../constants';
|
||||
|
||||
@@ -23,6 +25,11 @@ import { defaultTimeRange } from './utils';
|
||||
|
||||
const cursorSync = new behaviors.CursorSync({ key: 'triage-cursor-sync', sync: DashboardCursorSync.Crosshair });
|
||||
|
||||
function TimePickerSpacer() {
|
||||
const theme = useTheme2();
|
||||
return <div style={{ width: theme.spacing(20) }} />;
|
||||
}
|
||||
|
||||
export const triageScene = new EmbeddedSceneWithContext({
|
||||
// this will allow us to share the cursor between all vizualizations
|
||||
$behaviors: [cursorSync],
|
||||
@@ -30,23 +37,14 @@ export const triageScene = new EmbeddedSceneWithContext({
|
||||
new VariableValueSelectors({}),
|
||||
new TriageSavedSearchesControl({}),
|
||||
new SceneControlsSpacer(),
|
||||
// Keep a fixed spacer before the time picker to align with row content.
|
||||
new SceneReactObject({ component: TimePickerSpacer }),
|
||||
new SceneTimePicker({}),
|
||||
new SceneRefreshPicker({}),
|
||||
],
|
||||
$timeRange: new SceneTimeRange(defaultTimeRange),
|
||||
$variables: new SceneVariableSet({
|
||||
variables: [
|
||||
new GroupByVariable({
|
||||
name: 'groupBy',
|
||||
label: 'Group by',
|
||||
datasource: {
|
||||
type: 'prometheus',
|
||||
uid: DATASOURCE_UID,
|
||||
},
|
||||
allowCustomValue: true,
|
||||
applyMode: 'manual',
|
||||
getTagKeysProvider: getGroupByTagKeysProvider,
|
||||
}),
|
||||
new AdHocFiltersVariable({
|
||||
name: 'filters',
|
||||
label: 'Filters',
|
||||
@@ -65,6 +63,17 @@ export const triageScene = new EmbeddedSceneWithContext({
|
||||
getTagKeysProvider: getAdHocTagKeysProvider,
|
||||
getTagValuesProvider: getAdHocTagValuesProvider,
|
||||
}),
|
||||
new GroupByVariable({
|
||||
name: 'groupBy',
|
||||
label: 'Group by',
|
||||
datasource: {
|
||||
type: 'prometheus',
|
||||
uid: DATASOURCE_UID,
|
||||
},
|
||||
allowCustomValue: true,
|
||||
applyMode: 'manual',
|
||||
getTagKeysProvider: getGroupByTagKeysProvider,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
body: new SceneFlexLayout({
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useState } from 'react';
|
||||
import Skeleton from 'react-loading-skeleton';
|
||||
import { useToggle } from 'react-use';
|
||||
|
||||
import { type GrafanaTheme2 } from '@grafana/data';
|
||||
import { Trans, t } from '@grafana/i18n';
|
||||
import { useQueryRunner, useSceneContext } from '@grafana/scenes-react';
|
||||
import { FilterInput, Icon, ScrollContainer, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { Button, FilterInput, Icon, LoadingBar, ScrollContainer, Stack, Text, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { COMBINED_FILTER_LABEL_KEYS } from '../../constants';
|
||||
import { countInstances } from '../SummaryStats';
|
||||
import { summaryInstanceCountQuery } from '../queries';
|
||||
import { type LabelStats, useLabelsBreakdown } from '../useLabelsBreakdown';
|
||||
import { addOrReplaceFilter, removeFilter, useFilterValue, useQueryFilter, useRegexFilterValue } from '../utils';
|
||||
import {
|
||||
addOrReplaceFilter,
|
||||
cleanAlertStateFilter,
|
||||
removeFilter,
|
||||
useClearAllFilters,
|
||||
useFilterValue,
|
||||
useQueryFilter,
|
||||
useRegexFilterValue,
|
||||
} from '../utils';
|
||||
|
||||
import { AllLabelsContent } from './LabelsContent';
|
||||
import { SeverityFilter } from './SeverityFilter';
|
||||
@@ -19,13 +28,37 @@ import { canonicalSeverity } from './severity';
|
||||
|
||||
export const LABELS_COLUMN_WIDTH = 250;
|
||||
const COLLAPSED_WIDTH = 36;
|
||||
const SKELETON_ROW_COUNT = 6;
|
||||
|
||||
function LabelsSkeleton() {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<div className={styles.skeletonList}>
|
||||
{Array.from({ length: SKELETON_ROW_COUNT }, (_, i) => (
|
||||
<div key={i} className={styles.skeletonRow}>
|
||||
<Skeleton width={16} height={16} />
|
||||
<span className={styles.skeletonName}>
|
||||
<Skeleton width="100%" height={16} />
|
||||
</span>
|
||||
<div className={styles.skeletonBadges}>
|
||||
<Skeleton width={22} height={16} />
|
||||
<Skeleton width={22} height={16} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Always-visible labels column rendered to the left of the main workbench content.
|
||||
* Contains a state filter (firing / pending) and the full label breakdown.
|
||||
*/
|
||||
export function LabelsColumn() {
|
||||
const { labels } = useLabelsBreakdown();
|
||||
const { labels, isLoading } = useLabelsBreakdown();
|
||||
const isFirstLoad = isLoading && labels.length === 0;
|
||||
const isSubsequentLoad = isLoading && labels.length > 0;
|
||||
const [open, toggleOpen] = useToggle(true);
|
||||
const [labelFilter, setLabelFilter] = useState('');
|
||||
const styles = useStyles2(getStyles);
|
||||
@@ -43,30 +76,38 @@ export function LabelsColumn() {
|
||||
const activeValue = activeSidebarFilterValues[filter.key];
|
||||
return { ...filter, values, activeValue };
|
||||
}).filter((filter) => filter.values.length > 0 || Boolean(filter.activeValue));
|
||||
const { hasActiveFilters, clearAllFilters } = useClearAllFilters();
|
||||
|
||||
return (
|
||||
<div className={cx(styles.column, !open && styles.columnCollapsed)}>
|
||||
<div className={styles.collapseButtonRow}>
|
||||
<Tooltip
|
||||
content={
|
||||
open
|
||||
? t('alerting.triage.collapse-sidebar', 'Collapse sidebar')
|
||||
: t('alerting.triage.expand-sidebar', 'Expand sidebar')
|
||||
}
|
||||
placement="right"
|
||||
>
|
||||
<button
|
||||
className={styles.collapseButton}
|
||||
onClick={toggleOpen}
|
||||
aria-label={
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Tooltip
|
||||
content={
|
||||
open
|
||||
? t('alerting.triage.collapse-sidebar', 'Collapse sidebar')
|
||||
: t('alerting.triage.expand-sidebar', 'Expand sidebar')
|
||||
}
|
||||
placement="right"
|
||||
>
|
||||
<Icon name={open ? 'angle-left' : 'angle-right'} size="sm" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
className={styles.collapseButton}
|
||||
onClick={toggleOpen}
|
||||
aria-label={
|
||||
open
|
||||
? t('alerting.triage.collapse-sidebar', 'Collapse sidebar')
|
||||
: t('alerting.triage.expand-sidebar', 'Expand sidebar')
|
||||
}
|
||||
>
|
||||
<Icon name={open ? 'angle-left' : 'angle-right'} size="sm" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{open && (
|
||||
<Button size="sm" variant="primary" fill="text" onClick={clearAllFilters} disabled={!hasActiveFilters}>
|
||||
<Trans i18nKey="alerting.triage.clear-filters">Clear filters</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
{open && (
|
||||
<ScrollContainer scrollbarWidth="thin">
|
||||
@@ -112,7 +153,9 @@ export function LabelsColumn() {
|
||||
className={styles.labelFilterInput}
|
||||
/>
|
||||
</div>
|
||||
{labels.length > 0 && <AllLabelsContent allLabels={labels} labelFilter={labelFilter} />}
|
||||
<div className={styles.loadingBar}>{isSubsequentLoad && <LoadingBar width={LABELS_COLUMN_WIDTH} />}</div>
|
||||
{isFirstLoad && <LabelsSkeleton />}
|
||||
{!isFirstLoad && labels.length > 0 && <AllLabelsContent allLabels={labels} labelFilter={labelFilter} />}
|
||||
</div>
|
||||
</ScrollContainer>
|
||||
)}
|
||||
@@ -265,10 +308,7 @@ export function useInstanceCounts(): { firing: number; pending: number } | undef
|
||||
const filter = useQueryFilter();
|
||||
|
||||
// Strip alertstate from filter since the instance count query adds its own alertstate matchers
|
||||
const cleanFilter = filter
|
||||
.replace(/alertstate\s*=~?\s*"(firing|pending)"[,\s]*/, '')
|
||||
.replace(/,\s*$/, '')
|
||||
.replace(/^\s*,/, '');
|
||||
const cleanFilter = cleanAlertStateFilter(filter);
|
||||
|
||||
const instanceDataProvider = useQueryRunner({
|
||||
queries: [summaryInstanceCountQuery(cleanFilter)],
|
||||
@@ -302,8 +342,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
borderRight: 'none',
|
||||
}),
|
||||
collapseButtonRow: css({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
padding: theme.spacing(0.5),
|
||||
flexShrink: 0,
|
||||
}),
|
||||
@@ -352,6 +390,32 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
labelFilterInput: css({
|
||||
width: '100%',
|
||||
}),
|
||||
loadingBar: css({
|
||||
// Keep a constant row height to avoid layout shifts during loading.
|
||||
height: 1,
|
||||
overflow: 'hidden',
|
||||
}),
|
||||
skeletonList: css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
padding: theme.spacing(1, 1.5),
|
||||
}),
|
||||
skeletonRow: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing(0.75),
|
||||
}),
|
||||
skeletonName: css({
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}),
|
||||
skeletonBadges: css({
|
||||
display: 'flex',
|
||||
gap: theme.spacing(0.5),
|
||||
marginLeft: 'auto',
|
||||
flexShrink: 0,
|
||||
}),
|
||||
stateButton: css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -49,6 +49,19 @@ export function useQueryFilter(): string {
|
||||
return filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips `alertstate` matchers from a Prometheus filter string.
|
||||
*
|
||||
* Queries that already group or filter by `alertstate` internally (e.g. `count by (alertstate)`)
|
||||
* must not also receive an `alertstate` matcher from the user-facing AdHoc filter.
|
||||
*/
|
||||
export function cleanAlertStateFilter(filter: string): string {
|
||||
return filter
|
||||
.replace(/alertstate\s*=~?\s*"[^"]*"[,\s]*/g, '')
|
||||
.replace(/,\s*$/, '')
|
||||
.replace(/^\s*,/, '');
|
||||
}
|
||||
|
||||
type AdHocFilterOperator = '=' | '!=' | '=~' | '!~' | '=|' | '!=|';
|
||||
|
||||
/**
|
||||
@@ -90,6 +103,13 @@ export function removeFilter(sceneContext: SceneObject, key: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAllFilters(sceneContext: SceneObject) {
|
||||
const filtersVariable = sceneGraph.lookupVariable(VARIABLES.filters, sceneContext);
|
||||
if (filtersVariable instanceof AdHocFiltersVariable) {
|
||||
filtersVariable.setState({ filters: [] });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the structured filters array from the AdHocFiltersVariable, reactively.
|
||||
*/
|
||||
@@ -103,6 +123,18 @@ function useAdHocFilters() {
|
||||
return filtersVariable.useState().filters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether any filters are active, and a function to clear all of them.
|
||||
*/
|
||||
export function useClearAllFilters(): { hasActiveFilters: boolean; clearAllFilters: () => void } {
|
||||
const sceneContext = useSceneContext();
|
||||
const filters = useAdHocFilters();
|
||||
return {
|
||||
hasActiveFilters: filters.length > 0,
|
||||
clearAllFilters: () => clearAllFilters(sceneContext),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current exact-match (=) value of a filter by key, or undefined if not set or not an exact match.
|
||||
*/
|
||||
|
||||
@@ -2419,7 +2419,9 @@
|
||||
"recipient-notification-fires": "Select who should receive a notification when an alert rule fires."
|
||||
},
|
||||
"open-drawer-icon-button": {
|
||||
"details": "Details"
|
||||
"details": "Details",
|
||||
"instance-details": "Instance details",
|
||||
"rule-details": "Rule details"
|
||||
},
|
||||
"option-customfield": {
|
||||
"label-custom-template": "Custom template",
|
||||
@@ -3565,6 +3567,7 @@
|
||||
}
|
||||
},
|
||||
"triage": {
|
||||
"clear-filters": "Clear filters",
|
||||
"collapse": "Collapse",
|
||||
"collapse-all": "Collapse all",
|
||||
"collapse-sidebar": "Collapse sidebar",
|
||||
@@ -3581,6 +3584,7 @@
|
||||
"no-label": "No {{label}}"
|
||||
},
|
||||
"instance-details-drawer": {
|
||||
"alert-instance-label": "Alert Instance",
|
||||
"declare-incident": "Declare incident",
|
||||
"declare-incident-no-permission": "You do not have permission to access Incident",
|
||||
"instance-details": "Instance details",
|
||||
|
||||
Reference in New Issue
Block a user