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:
co-authored by
Konrad Lalik
parent
f5f34cd587
commit
488eafe02e
@@ -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,
|
||||
}),
|
||||
});
|
||||
-37
@@ -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';
|
||||
|
||||
|
||||
+1
-1
@@ -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[];
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user