Alerting: Triage (#110339)

* WIP column layout

* add columns on top of splitter

* small layout fixes

* WIP grouped items

* WIP

* WIP

* WIP

* small refactoring

* some more WIP

* WIP

* refactoring some functions

* use "unknown" state intstead of assuming "normal" state

* Add groupBy filter

* Add label-based filter

* Add basic displaying of grouped data

* lint

* Fix timeline alertstate selection

* Add AlertInstanceScene for displaying instances

* Add WorkbenchContext to pass column width and domain deeper into the tree

* Update single rule query, merge pending and firing metrics into one instance

* use area chart for summary

* Add not working rule summary

* step interpolation for summary chart

* Add rule state chart

* Update panel settings for alert rule summary

* Reactify SummaryChart component

* WIP react version

* Reactify AlertRuleDetails component

* Reactify AlertRuleSummary component

* refactor summary chart a bit

* clean up – Reactify

* set min y-scale for rule summary

* remove macro code

* small fixes

* remove line width for summary

* attempt to make the instances a native chart

* native chart for instances part 2

* sync cursor crosshair

* extract instance row into separate component

* Add minHeight for rows without labels

* Add Summary component

* Move scrolling to rows container

* Add lazy rendering of rows

* Use default page size

* minor layout tweaks

* typescript fixes

* simplify the grouping / data frame processing

* big cleanup of code

* split up rows components

* further split row components

* moving files around

* use text summary stats

* link to alert rule

* various eslint and typescript fixes

* do not compute common labels for single series

* small UI updates

* add depth support and colored labels

* simplify with props type

* Reuse Workbenck query to populate alert rule summaries

* add custom folder row component and set as default

* small UI tweaks

* remove unused sticky

* Hide triage page behind a feature toggle

* Add loading states to workbench and alert rows

* Update translations

* ✨

* Fix lint errors

* Fix EditorColumnHeader rendering, remove unused code

* Update translations

* Move EditorColumnHeader to shared components directory

* add type string for union discrimination of row

---------

Co-authored-by: Konrad Lalik <konradlalik@gmail.com>
This commit is contained in:
Gilles De Mey
2025-10-07 13:39:13 +02:00
committed by GitHub
co-authored by Konrad Lalik
parent f5f34cd587
commit 488eafe02e
28 changed files with 1429 additions and 43 deletions
+3 -2
View File
@@ -3,7 +3,6 @@ import { Navigate } from 'react-router-dom-v5-compat';
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { config } from 'app/core/config';
import { GrafanaRouteComponent, RouteDescriptor } from 'app/core/navigation/types';
import { AlertingPageWrapper } from 'app/features/alerting/unified/components/AlertingPageWrapper';
import { AccessControlAction } from 'app/types/accessControl';
import { PERMISSIONS_CONTACT_POINTS } from './unified/components/contact-points/permissions';
@@ -338,7 +337,9 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
routes.push({
path: '/alerting/triage',
roles: evaluateAccess([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead]),
component: () => <AlertingPageWrapper />,
component: importAlertingComponent(
() => import(/* webpackChunkName: "AlertingTriage" */ 'app/features/alerting/unified/triage/Triage')
),
});
}
@@ -0,0 +1,59 @@
import { css } from '@emotion/css';
import * as React from 'react';
import { type MergeExclusive } from 'type-fest';
import { GrafanaTheme2 } from '@grafana/data';
import { Label, Stack, useStyles2 } from '@grafana/ui';
interface BaseProps {
id?: string;
}
interface ChildrenProps extends BaseProps {
children: React.ReactNode;
}
interface LabelActionsProps extends BaseProps {
label: string;
actions?: React.ReactNode;
}
type Props = MergeExclusive<ChildrenProps, LabelActionsProps>;
export function EditorColumnHeader({ label, actions, id, children }: Props) {
const styles = useStyles2(editorColumnStyles);
if (children) {
return <div className={styles.container}>{children}</div>;
}
return (
<div className={styles.container}>
<Label className={styles.label} id={id}>
{label}
</Label>
{actions && (
<Stack direction="row" gap={1}>
{actions}
</Stack>
)}
</div>
);
}
const editorColumnStyles = (theme: GrafanaTheme2) => ({
container: css({
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: theme.spacing(1, 2),
backgroundColor: theme.colors.background.secondary,
border: `1px solid ${theme.colors.border.medium}`,
borderTopLeftRadius: theme.shape.radius.default,
borderTopRightRadius: theme.shape.radius.default,
}),
label: css({
margin: 0,
}),
});
@@ -1,37 +0,0 @@
import { css } from '@emotion/css';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Label, Stack, useStyles2 } from '@grafana/ui';
type Props = { label: string; actions?: React.ReactNode; id?: string };
export function EditorColumnHeader({ label, actions, id }: Props) {
const styles = useStyles2(editorColumnStyles);
return (
<div className={styles.container}>
<Label className={styles.label} id={id}>
{label}
</Label>
<Stack direction="row" gap={1}>
{actions}
</Stack>
</div>
);
}
const editorColumnStyles = (theme: GrafanaTheme2) => ({
container: css({
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: theme.spacing(1, 2),
backgroundColor: theme.colors.background.secondary,
borderBottom: `1px solid ${theme.colors.border.medium}`,
}),
label: css({
margin: 0,
}),
});
@@ -8,7 +8,7 @@ import { Trans, t } from '@grafana/i18n';
import { Button, CodeEditor, Dropdown, Menu, Stack, Toggletip, useStyles2 } from '@grafana/ui';
import { TestTemplateAlert } from 'app/plugins/datasource/alertmanager/types';
import { EditorColumnHeader } from '../contact-points/templates/EditorColumnHeader';
import { EditorColumnHeader } from '../EditorColumnHeader';
import { AlertInstanceModalSelector } from './AlertInstanceModalSelector';
import { AlertTemplatePreviewData } from './TemplateData';
@@ -32,9 +32,9 @@ import { TestTemplateAlert } from 'app/plugins/datasource/alertmanager/types';
import { AITemplateButtonComponent } from '../../enterprise-components/AI/AIGenTemplateButton/addAITemplateButton';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { makeAMLink, stringifyErrorLike } from '../../utils/misc';
import { EditorColumnHeader } from '../EditorColumnHeader';
import { ProvisionedResource, ProvisioningAlert } from '../Provisioning';
import { Spacer } from '../Spacer';
import { EditorColumnHeader } from '../contact-points/templates/EditorColumnHeader';
import {
NotificationTemplate,
useCreateNotificationTemplate,
@@ -10,7 +10,7 @@ import { Alert, Box, Button, CodeEditor, useStyles2 } from '@grafana/ui';
import { TemplatePreviewErrors, TemplatePreviewResponse, TemplatePreviewResult } from '../../api/templateApi';
import { AIFeedbackButtonComponent } from '../../enterprise-components/AI/addAIFeedbackButton';
import { stringifyErrorLike } from '../../utils/misc';
import { EditorColumnHeader } from '../contact-points/templates/EditorColumnHeader';
import { EditorColumnHeader } from '../EditorColumnHeader';
import { usePreviewTemplate } from './usePreviewTemplate';
@@ -8,7 +8,7 @@ import { Box, useStyles2 } from '@grafana/ui';
import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { EditorColumnHeader } from '../../../contact-points/templates/EditorColumnHeader';
import { EditorColumnHeader } from '../../../EditorColumnHeader';
import { TemplateEditor } from '../../TemplateEditor';
import { TemplatePreview } from '../../TemplatePreview';
@@ -0,0 +1,37 @@
import { scaleTime } from 'd3-scale';
import { useMemo } from 'react';
import { useMeasure } from 'react-use';
import { Stack, Text } from '@grafana/ui';
import { Domain } from './types';
interface TimelineProps {
domain: Domain;
}
export const TimelineHeader = ({ domain }: TimelineProps) => {
const [ref, { width }] = useMeasure<HTMLDivElement>();
const ticks = useMemo(() => {
const xScale = scaleTime().domain(domain).range([0, width]).nice(0);
const tickFormatter = xScale.tickFormat();
return xScale.ticks(5).map((value) => ({
value: tickFormatter(value),
xOffset: xScale(value),
}));
}, [domain, width]);
return (
<div ref={ref} style={{ width: '100%' }}>
<Stack flex={1} direction="row" justifyContent="space-between">
{ticks.map((tick) => (
<Text key={`${tick.value}-${tick.xOffset}`} variant="bodySmall" color="secondary">
{tick.value}
</Text>
))}
</Stack>
</div>
);
};
@@ -0,0 +1,19 @@
# Triage view
The triage view should serve several purposes and be a central place for users to manage their alert instances.
## Goals
- Observe the current state of their system
- Help correlate alerts with each other
- Be a launchpad for further investigation
## Non-goals
- Managing alert rules
## Technical goals
- Build re-usable components that can be used in other parts of Grafana and plugins
- These should be a mix of presentation components and data components
- Eventually most of this should live in the Grafana Alerting package
@@ -0,0 +1,25 @@
import { t } from '@grafana/i18n';
import { UrlSyncContextProvider } from '@grafana/scenes';
import { withErrorBoundary } from '@grafana/ui';
import { AlertingPageWrapper } from '../components/AlertingPageWrapper';
import { TriageScene, triageScene } from './scene/TriageScene';
export const TriagePage = () => {
return (
<AlertingPageWrapper
navId="alerting"
subTitle={t('alerting.pages.triage.subtitle', 'Learn about problems in your systems moments after they occur')}
pageNav={{
text: t('alerting.pages.triage.title', 'Triage'),
}}
>
<UrlSyncContextProvider scene={triageScene} updateUrlOnInit={true} createBrowserHistorySteps={true}>
<TriageScene key={triageScene.state.key} />
</UrlSyncContextProvider>
</AlertingPageWrapper>
);
};
export default withErrorBoundary(TriagePage);
@@ -0,0 +1,211 @@
import { css, cx } from '@emotion/css';
import { take } from 'lodash';
import { useState } from 'react';
import { useMeasure } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { SceneQueryRunner } from '@grafana/scenes';
import { ScrollContainer, useSplitter, useStyles2 } from '@grafana/ui';
import { DEFAULT_PER_PAGE_PAGINATION } from 'app/core/constants';
import { EditorColumnHeader } from '../components/EditorColumnHeader';
import LoadMoreHelper from '../rule-list/LoadMoreHelper';
import { TimelineHeader } from './Timeline';
import { WorkbenchProvider } from './WorkbenchContext';
import { AlertRuleRow } from './rows/AlertRuleRow';
import { FolderGroupRow } from './rows/FolderGroupRow';
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 { Domain, Filter, WorkbenchRow } from './types';
type WorkbenchProps = {
domain: Domain;
data: WorkbenchRow[];
groupBy?: string[]; // @TODO proper type
filterBy?: Filter[];
queryRunner: SceneQueryRunner;
};
const initialSize = 1 / 3;
// Helper function to recursively render WorkbenchRow items with children pattern
function renderWorkbenchRow(
row: WorkbenchRow,
leftColumnWidth: number,
domain: Domain,
key: React.Key,
depth = 0
): React.ReactElement {
if (row.type === 'alertRule') {
return <AlertRuleRow key={key} row={row} leftColumnWidth={leftColumnWidth} rowKey={key} depth={depth} />;
} else {
const children = row.rows.map((childRow, childIndex) =>
renderWorkbenchRow(childRow, leftColumnWidth, domain, `${key}-${generateRowKey(childRow, childIndex)}`, depth + 1)
);
// Check if this is a grafana_folder group and use FolderGroupRow
if (row.metadata.label === 'grafana_folder') {
return (
<FolderGroupRow key={key} row={row} leftColumnWidth={leftColumnWidth} rowKey={key} depth={depth}>
{children}
</FolderGroupRow>
);
}
return (
<GroupRow key={key} row={row} leftColumnWidth={leftColumnWidth} rowKey={key} depth={depth}>
{children}
</GroupRow>
);
}
}
/**
* The workbench displays groups of alerts, each group containing metadata and a chart.
* Alerts can be arbitrarily grouped by any number of labels. By default all instances are grouped by alertname.
*
* The page consist of a left column with metadata for the row and a right column with charts.
* Below is a rough layout of the page:
*
* The page is divided into two columns, the size of these columns is determined by the splitter.
* There is a useMeasure hook to measure the size of the left column, which is used to set the width of the group items.
* We do this because each row needs to be a flex container such that if the height of the left colorn changes, the
* right column will also change its height accordingly. This would not be possible if we used a simplified column layout.
*
* This also means we draw the rows _on top_ of the splitter, in other words the contents of the splitter are empty
* and we only use it to determine the width of the left column of the rows that are overlayed on top.
*
* Each group is a row with a left and a right column. Each row consists of two cells (the left and the right cell).
* The left cell contains the metadata for the group, the right cell contains the chart.
┌─────────────────────────┐ ┌───────────────────────────────────┐
│┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│─│─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐│
│ │
││ Row ││
│ │
│└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│─│─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘│
│┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│─│─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐│
│ ┌──────────────────────┐ ┌───────────────────────────────┐ │
│││ Cell │ │ Cell │││
│ └──────────────────────┘ └───────────────────────────────┘ │
│└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│─│─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘│
│ │ │ │
│ │││ │
│ │││ │
│ │││ │
│ │ │ │
│ │ │ │
│ │ │ │
└─────────────────────────┘ └───────────────────────────────────┘
*/
export function Workbench({ domain, data, queryRunner }: WorkbenchProps) {
const styles = useStyles2(getStyles);
const isLoading = !queryRunner.isDataReadyToDisplay();
const [pageIndex, setPageIndex] = useState<number>(1);
// 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
initialSize: initialSize,
dragPosition: 'middle',
});
// this will measure the size of the left most column of the splitter, so we can use it to set the width of the group items
const [ref, rect] = useMeasure<HTMLDivElement>();
const leftColumnWidth = rect.width;
const itemsToRender = pageIndex * DEFAULT_PER_PAGE_PAGINATION;
const dataSlice = take(data, itemsToRender);
const hasMore = data.length > itemsToRender;
return (
<div style={{ position: 'relative', display: 'flex', flexGrow: 1, width: '100%', height: '100%' }}>
{/* dummy splitter to handle flex width of group items */}
<div {...splitter.containerProps}>
<div {...splitter.primaryProps}>
<div ref={ref} className={cx(styles.flexFull, styles.minColumnWidth)} />
</div>
<div {...splitter.splitterProps} />
<div {...splitter.secondaryProps}>
<div className={cx(styles.flexFull, styles.minColumnWidth)} />
</div>
</div>
{/* content goes here */}
<div data-testid="groups-container" className={cx(splitter.containerProps.className, styles.groupsContainer)}>
<div className={cx(styles.groupItemWrapper(leftColumnWidth), styles.summaryContainer)}>
<SummaryStatsReact />
<SummaryChartReact />
</div>
<div className={cx(styles.groupItemWrapper(leftColumnWidth), styles.headerContainer)}>
<EditorColumnHeader label={t('alerting.left-column.label-instances', 'Instances')} />
<EditorColumnHeader>
<TimelineHeader domain={domain} />
</EditorColumnHeader>
</div>
{/* Render actual data */}
<div className={styles.virtualizedContainer}>
<WorkbenchProvider leftColumnWidth={leftColumnWidth} domain={domain} queryRunner={queryRunner}>
<ScrollContainer height="100%" width="100%" scrollbarWidth="none" showScrollIndicators>
{isLoading ? (
<>
<GenericRowSkeleton key="skeleton-1" width={leftColumnWidth} depth={0} />
<GenericRowSkeleton key="skeleton-2" width={leftColumnWidth} depth={0} />
<GenericRowSkeleton key="skeleton-3" width={leftColumnWidth} depth={0} />
</>
) : (
dataSlice.map((row, index) => {
const rowKey = generateRowKey(row, index);
return renderWorkbenchRow(row, leftColumnWidth, domain, rowKey);
})
)}
{hasMore && <LoadMoreHelper handleLoad={() => setPageIndex((prevIndex) => prevIndex + 1)} />}
</ScrollContainer>
</WorkbenchProvider>
</div>
</div>
</div>
);
}
export const getStyles = (theme: GrafanaTheme2) => {
const summaryHeight = 200;
return {
groupsContainer: css({
position: 'absolute',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
}),
groupItemWrapper: (width: number) =>
css({
display: 'grid',
gridTemplateColumns: `${width}px auto`,
gap: theme.spacing(2),
}),
virtualizedContainer: css({
display: 'flex',
flex: 1,
overflow: 'hidden', // Let AutoSizer handle the overflow
}),
summaryContainer: css({
gridTemplateRows: summaryHeight,
marginBottom: theme.spacing(2),
}),
headerContainer: css({
top: summaryHeight,
}),
flexFull: css({
flex: 1,
}),
minColumnWidth: css({
minWidth: 300,
}),
};
};
@@ -0,0 +1,34 @@
import React, { createContext, useContext } from 'react';
import { SceneQueryRunner } from '@grafana/scenes';
import { Domain } from './types';
interface WorkbenchContextValue {
leftColumnWidth: number;
domain: Domain;
queryRunner: SceneQueryRunner;
}
const WorkbenchContext = createContext<WorkbenchContextValue | undefined>(undefined);
export function useWorkbenchContext(): WorkbenchContextValue {
const context = useContext(WorkbenchContext);
if (!context) {
throw new Error('useWorkbenchContext must be used within a WorkbenchProvider');
}
return context;
}
interface WorkbenchProviderProps {
leftColumnWidth: number;
domain: Domain;
queryRunner: SceneQueryRunner;
children: React.ReactNode;
}
export function WorkbenchProvider({ leftColumnWidth, domain, queryRunner, children }: WorkbenchProviderProps) {
return (
<WorkbenchContext.Provider value={{ leftColumnWidth, domain, queryRunner }}>{children}</WorkbenchContext.Provider>
);
}
@@ -0,0 +1,10 @@
import { config } from '@grafana/runtime';
export const VARIABLES = {
groupBy: 'groupBy',
filters: 'filters',
};
export const DATASOURCE_UID = config.unifiedAlerting.stateHistory?.prometheusTargetDatasourceUID;
export const METRIC_NAME = config.unifiedAlerting.stateHistory?.prometheusMetricName ?? 'GRAFANA_ALERTS';
export const DEFAULT_FIELDS = ['alertname', 'grafana_folder', 'grafana_rule_uid', 'alertstate'] as const;
@@ -0,0 +1,55 @@
import React from 'react';
import { Stack, Text, TextLink } from '@grafana/ui';
import { MetaText } from '../../components/MetaText';
import { WithReturnButton } from '../../components/WithReturnButton';
import { rulesNav } from '../../utils/navigation';
import { AlertRuleInstances } from '../scene/AlertRuleInstances';
import { AlertRuleSummary } from '../scene/AlertRuleSummary';
import { AlertRuleRow as AlertRuleRowType } from '../types';
import { GenericRow } from './GenericRow';
interface AlertRuleRowProps {
row: AlertRuleRowType;
leftColumnWidth: number;
rowKey: React.Key;
depth?: number;
}
export const AlertRuleRow = ({ row, leftColumnWidth, rowKey, depth = 0 }: AlertRuleRowProps) => {
return (
<GenericRow
key={rowKey}
width={leftColumnWidth}
title={
<WithReturnButton
component={
<TextLink
inline={false}
href={rulesNav.detailsPageLink('grafana', {
ruleSourceName: 'grafana',
uid: row.metadata.ruleUID,
})}
>
{row.metadata.title}
</TextLink>
}
/>
}
metadata={
<Stack direction="row" gap={0.5} alignItems="center">
<MetaText icon="folder" />
<Text variant="bodySmall" color="secondary">
{row.metadata.folder}
</Text>
</Stack>
}
content={<AlertRuleSummary ruleUID={row.metadata.ruleUID} />}
depth={depth}
>
<AlertRuleInstances ruleUID={row.metadata.ruleUID} depth={depth + 1} />
</GenericRow>
);
};
@@ -0,0 +1,47 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack, Text, useStyles2 } from '@grafana/ui';
import { MetaText } from '../../components/MetaText';
import { GenericGroupedRow } from '../types';
import { GenericRow } from './GenericRow';
interface FolderGroupRowProps {
row: GenericGroupedRow;
leftColumnWidth: number;
rowKey: React.Key;
depth?: number;
children?: React.ReactNode;
}
export const FolderGroupRow = ({ row, leftColumnWidth, rowKey, depth = 0, children }: FolderGroupRowProps) => {
const styles = useStyles2(getStyles);
return (
<GenericRow
key={rowKey}
width={leftColumnWidth}
title={
<Stack direction="row" gap={0.5} alignItems="center">
<MetaText icon="folder" />
<Text color="primary">{row.metadata.value}</Text>
</Stack>
}
isOpenByDefault={true}
leftColumnClassName={styles.folderGroupRow}
rightColumnClassName={styles.folderGroupRow}
depth={depth}
>
{children}
</GenericRow>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
folderGroupRow: css({
backgroundColor: theme.colors.background.secondary,
}),
});
@@ -0,0 +1,132 @@
import { css, cx } from '@emotion/css';
import { ReactNode } from 'react';
import { useToggle } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { IconButton, Stack, useStyles2 } from '@grafana/ui';
import { Spacer } from '../../components/Spacer';
interface GenericRowProps {
width: number;
title: ReactNode;
metadata?: ReactNode;
actions?: ReactNode;
content?: ReactNode;
isOpenByDefault?: boolean;
children?: ReactNode;
// allow overriding / adding styles for the row
leftColumnClassName?: string;
rightColumnClassName?: string;
depth?: number; // for indentation of nested rows
}
export const GenericRow = ({
width,
title,
metadata,
actions,
content,
isOpenByDefault = false,
children,
leftColumnClassName,
rightColumnClassName,
depth = 0,
}: GenericRowProps) => {
const styles = useStyles2(getStyles);
const [isOpen, handleToggle] = useToggle(isOpenByDefault);
const hasChildren = Boolean(children);
const showChildContent = isOpen && hasChildren;
return (
<>
<div className={styles.groupItemWrapper(width)}>
<div className={cx(styles.leftColumn, styles.column, leftColumnClassName)}>
<div className={styles.columnContent(depth)}>
<LeftCell
title={title}
metadata={metadata}
actions={actions}
isOpen={isOpen}
onToggle={hasChildren ? handleToggle : undefined}
/>
</div>
</div>
<div style={{ minWidth: 'min-content', flexGrow: 1 }} className={cx(styles.column, rightColumnClassName)}>
{content && <div className={styles.columnContent()}>{content}</div>}
</div>
</div>
{showChildContent ? children : null}
</>
);
};
interface LeftCellProps {
title: ReactNode;
metadata?: ReactNode;
actions?: ReactNode;
isOpen?: boolean;
onToggle?: () => void;
}
const LeftCell = ({ title, metadata = null, actions = null, isOpen = true, onToggle }: LeftCellProps) => {
const styles = useStyles2(getStyles);
return (
<Stack direction="row" alignItems="center" gap={0.5}>
{onToggle && (
<IconButton
name={isOpen ? 'angle-down' : 'angle-right'}
onClick={() => onToggle()}
className={styles.dropdownIcon}
variant="secondary"
size="md"
aria-label={t('alerting.group-wrapper.toggle', 'Toggle group')}
/>
)}
<Stack direction="column" alignItems="flex-start" gap={0} flex={1}>
<Stack direction="row" alignItems="center" gap={1} width="100%">
{title}
{actions && <Spacer />}
{actions}
</Stack>
{metadata}
</Stack>
</Stack>
);
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
dropdownIcon: css({
alignSelf: 'flex-start',
marginTop: theme.spacing(0.5),
}),
column: css({
display: 'flex',
position: 'relative',
flexBasis: 0,
border: 'solid 1px transparent',
borderBottom: `1px solid ${theme.colors.border.medium}`,
borderLeft: `1px solid ${theme.colors.border.medium}`,
borderRight: `1px solid ${theme.colors.border.medium}`,
}),
leftColumn: css({
overflow: 'hidden',
}),
columnContent: (depth?: number) =>
css({
padding: 5,
width: '100%',
paddingLeft: depth ? `calc(${theme.spacing(depth)} + 5px)` : 5,
}),
groupItemWrapper: (width: number) =>
css({
display: 'grid',
gridTemplateColumns: `${width}px auto`,
gap: theme.spacing(2),
}),
};
};
@@ -0,0 +1,42 @@
import { css } from '@emotion/css';
import React from 'react';
import { AlertLabel } from '@grafana/alerting/unstable';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { GenericGroupedRow } from '../types';
import { GenericRow } from './GenericRow';
interface GroupRowProps {
row: GenericGroupedRow;
leftColumnWidth: number;
rowKey: React.Key;
depth?: number;
children?: React.ReactNode;
}
export const GroupRow = ({ row, leftColumnWidth, rowKey, depth = 0, children }: GroupRowProps) => {
const styles = useStyles2(getStyles);
return (
<GenericRow
key={rowKey}
width={leftColumnWidth}
title={<AlertLabel size="sm" labelKey={row.metadata.label} value={row.metadata.value} colorBy="key" />}
isOpenByDefault={true}
leftColumnClassName={styles.groupRow}
rightColumnClassName={styles.groupRow}
depth={depth}
>
{children}
</GenericRow>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
groupRow: css({
backgroundColor: theme.colors.background.secondary,
}),
});
@@ -0,0 +1,114 @@
import { css } from '@emotion/css';
import { isEmpty } from 'lodash';
import { useMemo } from 'react';
import { AlertLabels } from '@grafana/alerting/unstable';
import { DataFrame, GrafanaTheme2, Labels, LoadingState, TimeRange } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { SceneDataNode, VizConfigBuilders } from '@grafana/scenes';
import { VizPanel } from '@grafana/scenes-react';
import { GraphDrawStyle, VisibilityMode } from '@grafana/schema';
import {
AxisPlacement,
BarAlignment,
LegendDisplayMode,
StackingMode,
Text,
TooltipDisplayMode,
useStyles2,
} from '@grafana/ui';
import { overrideToFixedColor } from '../../home/Insights';
import { GenericRow } from './GenericRow';
interface Instance {
labels: Labels;
series: DataFrame[];
}
interface InstanceRowProps {
instance: Instance;
commonLabels: Labels;
leftColumnWidth: number;
timeRange: TimeRange;
depth?: number;
}
const chartConfig = VizConfigBuilders.timeseries()
.setCustomFieldConfig('drawStyle', GraphDrawStyle.Bars)
.setCustomFieldConfig('barWidthFactor', 1)
.setCustomFieldConfig('barAlignment', BarAlignment.After)
.setCustomFieldConfig('showPoints', VisibilityMode.Never)
.setCustomFieldConfig('fillOpacity', 60)
.setCustomFieldConfig('lineWidth', 0)
.setCustomFieldConfig('stacking', { mode: StackingMode.None })
.setCustomFieldConfig('axisPlacement', AxisPlacement.Hidden)
.setCustomFieldConfig('axisGridShow', false)
.setOption('tooltip', { mode: TooltipDisplayMode.Multi })
.setOption('legend', {
showLegend: false,
displayMode: LegendDisplayMode.Hidden,
})
.setMin(0)
.setMax(1)
.setOverrides((builder) =>
builder
.matchFieldsWithName('firing')
.overrideColor(overrideToFixedColor('firing'))
.matchFieldsWithName('pending')
.overrideColor(overrideToFixedColor('pending'))
)
.build();
export function InstanceRow({ instance, commonLabels, leftColumnWidth, timeRange, depth = 0 }: InstanceRowProps) {
const styles = useStyles2(getStyles);
const dataProvider = useMemo(
() =>
new SceneDataNode({
data: {
series: instance.series,
state: LoadingState.Done,
timeRange,
},
}),
[instance, timeRange]
);
return (
<GenericRow
width={leftColumnWidth}
title={
isEmpty(instance.labels) ? (
<div className={styles.wrapper}>
<Text color="secondary" variant="bodySmall">
<Trans i18nKey="alerting.triage.no-labels">No labels</Trans>
</Text>
</div>
) : (
<AlertLabels
labels={instance.labels}
displayCommonLabels={true}
labelSets={[instance.labels, commonLabels]}
size="xs"
/>
)
}
content={
<VizPanel title="" hoverHeader={true} viz={chartConfig} dataProvider={dataProvider} displayMode="transparent" />
}
depth={depth}
/>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
wrapper: css({
minHeight: theme.spacing(2.5),
display: 'flex',
alignItems: 'center',
}),
};
};
@@ -0,0 +1,13 @@
import { WorkbenchRow } from '../types';
// Generate unique keys for WorkbenchRow items
export function generateRowKey(row: WorkbenchRow, fallbackIndex: number): string {
if (row.type === 'alertRule') {
// Use ruleUID as primary key for AlertRuleRow
return `alert-${row.metadata.ruleUID}`;
} else {
// For GenericGroupedRow, create key from label and value
const groupedRow = row;
return `group-${groupedRow.metadata.label}-${groupedRow.metadata.value}`;
}
}
@@ -0,0 +1,113 @@
import { omit } from 'lodash';
import { useMemo } from 'react';
import Skeleton from 'react-loading-skeleton';
import { DataFrame, Labels, findCommonLabels } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { useQueryRunner, useTimeRange } from '@grafana/scenes-react';
import { Box } from '@grafana/ui';
import { useWorkbenchContext } from '../WorkbenchContext';
import { METRIC_NAME } from '../constants';
import { GenericRow } from '../rows/GenericRow';
import { InstanceRow } from '../rows/InstanceRow';
import { getDataQuery } from './utils';
function extractInstancesFromData(series: DataFrame[] | undefined) {
if (!series) {
return [];
}
// 1. Group series by labels, ignoring alertstate
const groups = new Map<string, { labels: Labels; series: DataFrame[] }>();
series.forEach((series) => {
const valueField = series.fields.find((f) => f.type !== 'time');
if (!valueField) {
return;
}
const keyLabels = omit(valueField.labels ?? {}, 'alertstate');
const key = JSON.stringify(keyLabels);
if (!groups.has(key)) {
groups.set(key, { labels: keyLabels, series: [] });
}
groups.get(key)!.series.push(series);
});
return Array.from(groups.values());
}
type AlertRuleInstancesProps = {
ruleUID: string;
depth?: number;
};
export function AlertRuleInstances({ ruleUID, depth = 0 }: AlertRuleInstancesProps) {
const { leftColumnWidth } = useWorkbenchContext();
const [timeRange] = useTimeRange();
const query = getDataQuery(
`count without (alertname, grafana_alertstate, grafana_folder, grafana_rule_uid) (${METRIC_NAME}{grafana_rule_uid="${ruleUID}"})`,
{ format: 'timeseries', legendFormat: '{{alertstate}}' }
);
const queryRunner = useQueryRunner({ queries: [query] });
const isLoading = !queryRunner.isDataReadyToDisplay();
const { data } = queryRunner.useState();
const instances = useMemo(() => extractInstancesFromData(data?.series), [data]);
if (isLoading) {
return <GenericRowSkeleton width={leftColumnWidth} depth={depth} />;
}
if (!instances.length && !isLoading) {
return (
<GenericRow
width={leftColumnWidth}
title={<Trans i18nKey="alerting.triage.alert-instances">Alert instances</Trans>}
depth={depth}
>
<div>
<Trans i18nKey="alerting.triage.no-instances-found">No alert instances found for rule: {ruleUID}</Trans>
</div>
</GenericRow>
);
}
const allSeriesLabels: Labels[] = instances.map((instance) => instance.labels);
const commonLabels = allSeriesLabels.length === 1 ? {} : findCommonLabels(allSeriesLabels);
return (
<>
{instances.map((instance) => (
<InstanceRow
key={JSON.stringify(instance.labels)}
instance={instance}
commonLabels={commonLabels}
leftColumnWidth={leftColumnWidth}
timeRange={timeRange}
depth={depth}
/>
))}
</>
);
}
export function GenericRowSkeleton({ width, depth }: { width: number; depth: number }) {
return (
<GenericRow
width={width}
title={
<Box flex={1}>
<Skeleton width="100%" />
</Box>
}
depth={depth}
content={<Skeleton width="100%" />}
/>
);
}
@@ -0,0 +1,93 @@
import { VizConfigBuilders } from '@grafana/scenes';
import { VizPanel, useDataTransformer } from '@grafana/scenes-react';
import {
AxisPlacement,
BarAlignment,
GraphDrawStyle,
LegendDisplayMode,
StackingMode,
TooltipDisplayMode,
VisibilityMode,
} from '@grafana/schema';
import { overrideToFixedColor } from '../../home/Insights';
import { useWorkbenchContext } from '../WorkbenchContext';
/**
* Viz config for the alert rule summary chart - used by the React component
*/
export const alertRuleSummaryVizConfig = VizConfigBuilders.timeseries()
.setCustomFieldConfig('drawStyle', GraphDrawStyle.Bars)
.setCustomFieldConfig('barWidthFactor', 1)
.setCustomFieldConfig('barAlignment', BarAlignment.After)
.setCustomFieldConfig('showPoints', VisibilityMode.Never)
.setCustomFieldConfig('fillOpacity', 60)
.setCustomFieldConfig('lineWidth', 0)
.setCustomFieldConfig('stacking', { mode: StackingMode.None })
.setCustomFieldConfig('axisPlacement', AxisPlacement.Hidden)
.setCustomFieldConfig('axisGridShow', false)
.setMin(0)
.setOption('tooltip', { mode: TooltipDisplayMode.Multi })
.setOption('legend', {
showLegend: false,
displayMode: LegendDisplayMode.Hidden,
})
.setOverrides((builder) =>
builder
.matchFieldsWithName('firing')
.overrideColor(overrideToFixedColor('firing'))
.matchFieldsWithName('pending')
.overrideColor(overrideToFixedColor('pending'))
)
.build();
export function AlertRuleSummary({ ruleUID }: { ruleUID: string }) {
// Use WorkbenchContext to access the parent query runner and reuse its data
const { queryRunner } = useWorkbenchContext();
// Transform parent data to filter by this specific rule and partition by alert state
const transformedData = useDataTransformer({
data: queryRunner,
transformations: [
{
id: 'filterByValue',
options: {
filters: [
{
config: {
id: 'equal',
options: {
value: ruleUID,
},
},
fieldName: 'grafana_rule_uid',
},
],
match: 'any',
type: 'include',
},
},
{
id: 'partitionByValues',
options: {
fields: ['alertstate'],
keepFields: false,
naming: {
asLabels: true,
},
},
},
],
});
return (
<VizPanel
title=""
viz={alertRuleSummaryVizConfig}
dataProvider={transformedData}
hoverHeader={true}
displayMode="transparent"
collapsible={false}
/>
);
}
@@ -0,0 +1,54 @@
import { SceneObjectBase, SceneObjectState, VizConfigBuilders } from '@grafana/scenes';
import { VizPanel, useQueryRunner } from '@grafana/scenes-react';
import { BarAlignment, GraphDrawStyle, VisibilityMode } from '@grafana/schema';
import { LegendDisplayMode, StackingMode, TooltipDisplayMode } from '@grafana/ui';
import { overrideToFixedColor } from '../../home/Insights';
import { METRIC_NAME } from '../constants';
import { getDataQuery, useQueryFilter } from './utils';
/**
* Viz config for the summary chart - used by the React component
*/
export const summaryChartVizConfig = VizConfigBuilders.timeseries()
.setCustomFieldConfig('drawStyle', GraphDrawStyle.Bars)
.setCustomFieldConfig('barWidthFactor', 1)
.setCustomFieldConfig('barAlignment', BarAlignment.Center)
.setCustomFieldConfig('fillOpacity', 60)
.setCustomFieldConfig('lineWidth', 0)
.setCustomFieldConfig('stacking', { mode: StackingMode.None })
.setCustomFieldConfig('showPoints', VisibilityMode.Never)
.setOption('legend', {
showLegend: false,
displayMode: LegendDisplayMode.Hidden,
})
.setOption('tooltip', { mode: TooltipDisplayMode.Multi })
.setMin(0)
.setOverrides((builder) =>
builder
.matchFieldsWithName('firing')
.overrideColor(overrideToFixedColor('firing'))
.matchFieldsWithName('pending')
.overrideColor(overrideToFixedColor('pending'))
)
.build();
export function SummaryChartReact() {
const filter = useQueryFilter();
const dataProvider = useQueryRunner({
queries: [
getDataQuery(`count by (alertstate) (${METRIC_NAME}{${filter}})`, {
legendFormat: '{{alertstate}}', // we need this so we can map states to the correct color in the vizConfig
}),
],
});
return <VizPanel title="" viz={summaryChartVizConfig} dataProvider={dataProvider} hoverHeader={true} />;
}
// simple wrapper so we can render the Chart using a Scene parent
export class SummaryChartScene extends SceneObjectBase<SceneObjectState> {
static Component = SummaryChartReact;
}
@@ -0,0 +1,65 @@
import { DataFrameView } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { useQueryRunner } from '@grafana/scenes-react';
import { Stack, Text } from '@grafana/ui';
import { Spacer } from '../../components/Spacer';
import { METRIC_NAME } from '../constants';
import { getDataQuery, useQueryFilter } from './utils';
interface Frame {
alertstate: 'firing' | 'pending';
Value: number;
}
export function SummaryStatsReact() {
const filter = useQueryFilter();
const dataProvider = useQueryRunner({
queries: [
getDataQuery(`count by (alertstate) (${METRIC_NAME}{${filter}})`, {
instant: true,
exemplar: false,
format: 'table',
}),
],
});
const isLoading = !dataProvider.isDataReadyToDisplay;
const data = dataProvider.useState().data;
const firstFrame = data?.series?.at(0);
if (isLoading || !firstFrame) {
return null;
}
const dfv = new DataFrameView<Frame>(firstFrame);
if (dfv.length === 0) {
return null;
}
const firingIndex = dfv.fields.alertstate.values.findIndex((state) => state === 'firing');
const firingCount = dfv.fields.Value.values[firingIndex] ?? 0;
const pendingIndex = dfv.fields.alertstate.values.findIndex((state) => state === 'pending');
const pendingCount = dfv.fields.Value.values[pendingIndex] ?? 0;
return (
<Stack direction="column" alignItems="flex-end" gap={0}>
<Spacer />
<Text color="error">
<Trans i18nKey="alerting.triage.firing-instances-count">{{ firingCount }} firing instances</Trans>
</Text>
<Text color="warning">
<Trans i18nKey="alerting.triage.pending-instances-count">{{ pendingCount }} pending instances</Trans>
</Text>
</Stack>
);
}
// simple wrapper so we can render the Chart using a Scene parent
export class SummaryStatsScene extends SceneObjectBase<SceneObjectState> {
static Component = SummaryStatsReact;
}
@@ -0,0 +1,69 @@
import { DashboardCursorSync } from '@grafana/data';
import {
AdHocFiltersVariable,
GroupByVariable,
SceneControlsSpacer,
SceneFlexLayout,
SceneRefreshPicker,
SceneTimePicker,
SceneTimeRange,
SceneVariableSet,
VariableValueSelectors,
behaviors,
} from '@grafana/scenes';
import { EmbeddedSceneWithContext } from '@grafana/scenes-react';
import { DATASOURCE_UID } from '../constants';
import { WorkbenchSceneObject } from './Workbench';
import { defaultTimeRange } from './utils';
const cursorSync = new behaviors.CursorSync({ key: 'triage-cursor-sync', sync: DashboardCursorSync.Crosshair });
export const triageScene = new EmbeddedSceneWithContext({
// this will allow us to share the cursor between all vizualizations
$behaviors: [cursorSync],
controls: [
new VariableValueSelectors({}),
new SceneControlsSpacer(),
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',
value: 'grafana_folder',
}),
new AdHocFiltersVariable({
name: 'filters',
label: 'Filters',
datasource: {
type: 'prometheus',
uid: DATASOURCE_UID,
},
applyMode: 'manual', // we will construct the label matchers for the PromQL queries ourselves
allowCustomValue: true,
useQueriesAsFilterForOptions: true,
supportsMultiValueOperators: true,
filters: [],
baseFilters: [],
layout: 'combobox',
}),
],
}),
body: new SceneFlexLayout({
direction: 'column',
children: [new WorkbenchSceneObject({})],
}),
});
export const TriageScene = () => <triageScene.Component model={triageScene} />;
@@ -0,0 +1,133 @@
import { ArrayValues } from 'type-fest';
import { DataFrame, PanelData } from '@grafana/data';
import { SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import { useQueryRunner, useTimeRange, useVariableValues } from '@grafana/scenes-react';
import { Workbench } from '../Workbench';
import { DEFAULT_FIELDS, METRIC_NAME, VARIABLES } from '../constants';
import { AlertRuleRow, GenericGroupedRow, WorkbenchRow } from '../types';
import { convertTimeRangeToDomain, getDataQuery, useQueryFilter } from './utils';
export class WorkbenchSceneObject extends SceneObjectBase<SceneObjectState> {
public static Component = WorkbenchRenderer;
}
export function WorkbenchRenderer() {
const [timeRange] = useTimeRange();
const domain = convertTimeRangeToDomain(timeRange);
const [groupByKeys = []] = useVariableValues<string>(VARIABLES.groupBy);
const countBy = [...DEFAULT_FIELDS, ...groupByKeys].join(',');
const queryFilter = useQueryFilter();
const runner = useQueryRunner({
queries: [
getDataQuery(`count by (${countBy}) (${METRIC_NAME}{${queryFilter}})`, {
format: 'table',
}),
],
});
const { data } = runner.useState();
const rows = data ? convertToWorkbenchRows(data, groupByKeys) : [];
return <Workbench data={rows} domain={domain} queryRunner={runner} />;
}
type DataPoint = Record<ArrayValues<typeof DEFAULT_FIELDS>, string> & Record<string, string | undefined>;
function createAlertRuleRows(dataPoints: DataPoint[]): AlertRuleRow[] {
const rules = new Map<
string,
{
alertname: string;
folder: string;
ruleUID: string;
}
>();
for (const dp of dataPoints) {
const ruleUID = dp.grafana_rule_uid;
if (!rules.has(ruleUID)) {
rules.set(ruleUID, {
alertname: dp.alertname,
folder: dp.grafana_folder,
ruleUID: ruleUID,
});
}
}
const result: AlertRuleRow[] = [];
for (const rule of rules.values()) {
result.push({
type: 'alertRule',
metadata: {
title: rule.alertname,
folder: rule.folder,
ruleUID: rule.ruleUID,
},
});
}
return result;
}
function groupData(dataPoints: DataPoint[], groupBy: string[], depth: number): WorkbenchRow[] {
if (depth >= groupBy.length) {
return createAlertRuleRows(dataPoints);
}
const groupByKey = groupBy[depth];
const grouped = new Map<string, DataPoint[]>();
for (const dp of dataPoints) {
const key = String(dp[groupByKey] ?? 'undefined');
if (!grouped.has(key)) {
grouped.set(key, []);
}
grouped.get(key)?.push(dp);
}
const result: GenericGroupedRow[] = [];
for (const [value, rows] of grouped.entries()) {
result.push({
type: 'group',
metadata: {
label: groupByKey,
value: value,
},
rows: groupData(rows, groupBy, depth + 1),
});
}
return result;
}
// @TODO narrower types for PanelData! (if possible)
export function convertToWorkbenchRows(data: PanelData, groupBy: string[] = []): WorkbenchRow[] {
if (!data.series.at(0)?.fields.length) {
return [];
}
const frame = data.series[0];
if (!isValidFrame(frame)) {
return [];
}
const allDataPoints = Array.from({ length: frame.length }, (_, i) => {
const dataPoint: DataPoint = Object.create(null);
frame.fields.forEach((field) => {
dataPoint[field.name] = field.values[i];
});
return dataPoint;
});
return groupData(allDataPoints, groupBy, 0);
}
function isValidFrame(frame: DataFrame) {
const requiredFieldNames = ['Time', ...DEFAULT_FIELDS];
const fieldNames = new Set(frame.fields.map((f) => f.name));
return requiredFieldNames.every((name) => fieldNames.has(name));
}
@@ -0,0 +1,54 @@
import { TimeRange } from '@grafana/data';
import { SceneDataQuery } from '@grafana/scenes';
import { useVariableValue, useVariableValues } from '@grafana/scenes-react';
import { DataSourceRef } from '@grafana/schema';
import { DATASOURCE_UID, VARIABLES } from '../constants';
import { Domain } from '../types';
export function getDataQuery(expression: string, options?: Partial<SceneDataQuery>): SceneDataQuery {
const datasourceRef: DataSourceRef = {
type: 'prometheus',
uid: DATASOURCE_UID,
};
const query: SceneDataQuery = {
refId: 'query',
expr: expression,
instant: false,
datasource: datasourceRef,
...options,
};
return query;
}
/**
* Turns an array of "groupBy" keys into a Prometheus matcher such as key!="",key2!="" .
* This way we can show only instances that have a label that was grouped on.
*/
export function stringifyGroupFilter(groupBy: string[]) {
return groupBy.map((key) => `${key}!=""`).join(',');
}
export const defaultTimeRange = {
from: 'now-4h',
to: 'now',
} as const;
export function convertTimeRangeToDomain(timeRange: TimeRange): Domain {
return [timeRange.from.toDate(), timeRange.to.toDate()];
}
/**
* This hook will create a Prometheus label matcher string from the "groupBy" and "filters" variables
*/
export function useQueryFilter(): string {
const [groupBy = []] = useVariableValues<string>(VARIABLES.groupBy);
const [filters = ''] = useVariableValue<string>(VARIABLES.filters);
const groupByFilter = stringifyGroupFilter(groupBy);
const queryFilter = [groupByFilter, filters].filter((s) => Boolean(s)).join(',');
return queryFilter;
}
@@ -0,0 +1,24 @@
export type Domain = [Date, Date];
export type Filter = [key: string, operator: '=' | '=!', value: string];
export type WorkbenchRow = GenericGroupedRow | AlertRuleRow;
export type TimelineEntry = [timestamp: number, state: 'firing' | 'pending'];
export interface AlertRuleRow {
type: 'alertRule';
metadata: {
title: string;
folder: string;
ruleUID: string;
};
}
export interface GenericGroupedRow {
type: 'group';
metadata: {
label: string;
value: string;
};
rows: WorkbenchRow[];
}
+19
View File
@@ -1535,6 +1535,9 @@
"group-status": {
"content-the-group-is-being-deleted": "The group is being deleted"
},
"group-wrapper": {
"toggle": "Toggle group"
},
"header": {
"tooltip-remove": "Remove expression \"{{refId}}\""
},
@@ -1710,6 +1713,9 @@
"placeholder-key": "key",
"placeholder-value": "value"
},
"left-column": {
"label-instances": "Instances"
},
"link-to-contact-points": {
"aria-label-view-or-create-contact-points": "View or create contact points",
"view-or-create-contact-points": "View or create contact points"
@@ -2025,6 +2031,12 @@
"body-selected-alertmanager-not-found": "The selected Alertmanager no longer exists or you may not have permission to access it. You can select a different Alertmanager from the dropdown.",
"title-selected-alertmanager-not-found": "Selected Alertmanager not found."
},
"pages": {
"triage": {
"subtitle": "Learn about problems in your systems moments after they occur",
"title": "Triage"
}
},
"panel-alert-tab-content": {
"alert": {
"title-errors-loading-rules": "Errors loading rules"
@@ -2916,6 +2928,13 @@
"title-warning": "Warning"
}
},
"triage": {
"alert-instances": "Alert instances",
"firing-instances-count": "{{firingCount}} firing instances",
"no-instances-found": "No alert instances found for rule: {ruleUID}",
"no-labels": "No labels",
"pending-instances-count": "{{pendingCount}} pending instances"
},
"type-selector-button": {
"add-expression": "Add expression"
},