Dashboard Schema V2: Support alerting for v2 schema in alert rule editor (#113891)

* implement alerting for v2 schema

* clean up

* clarify

* add support for library panels

* comment

* include missing library panel when v1 returns v2; cleanup

* Use type union

* remove unneccesary check

* add AlertStatesDataLayer so that alert state is displayed next to panel title

* fix test

* cleanup undefined
This commit is contained in:
Haris Rozajac
2025-11-24 09:51:54 -07:00
committed by GitHub
parent 3d95d2aa79
commit 11814426e4
5 changed files with 122 additions and 36 deletions
@@ -8,7 +8,6 @@ import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Button, Field, Input, Stack, Text, TextArea, useStyles2 } from '@grafana/ui';
import { DashboardModel } from '../../../../dashboard/state/DashboardModel';
import { AIImproveAnnotationsButtonComponent } from '../../enterprise-components/AI/AIGenImproveAnnotationsButton/addAIImproveAnnotationsButton';
import { RuleFormValues } from '../../types/rule-form';
import { Annotation, annotationLabels } from '../../utils/constants';
@@ -19,7 +18,7 @@ import DashboardAnnotationField from './DashboardAnnotationField';
import { DashboardPicker, PanelDTO, getVisualPanels } from './DashboardPicker';
import { NeedHelpInfo } from './NeedHelpInfo';
import { RuleEditorSection } from './RuleEditorSection';
import { useDashboardQuery } from './useDashboardQuery';
import { DashboardResponse, useDashboardQuery } from './useDashboardQuery';
const AnnotationsStep = () => {
const styles = useStyles2(getStyles);
@@ -40,22 +39,22 @@ const AnnotationsStep = () => {
const selectedDashboardUid = annotations.find((annotation) => annotation.key === Annotation.dashboardUID)?.value;
const selectedPanelId = Number(annotations.find((annotation) => annotation.key === Annotation.panelID)?.value);
const [selectedDashboard, setSelectedDashboard] = useState<DashboardModel | undefined>(undefined);
const [selectedDashboard, setSelectedDashboard] = useState<DashboardResponse | undefined>(undefined);
const [selectedPanel, setSelectedPanel] = useState<PanelDTO | undefined>(undefined);
const { dashboardModel, isFetching: isDashboardFetching } = useDashboardQuery(selectedDashboardUid);
const { dashboard, isFetching: isDashboardFetching } = useDashboardQuery(selectedDashboardUid);
useEffect(() => {
if (isDashboardFetching || !dashboardModel) {
if (isDashboardFetching || !dashboard) {
return;
}
setSelectedDashboard(dashboardModel);
setSelectedDashboard(dashboard);
const allPanels = getVisualPanels(dashboardModel);
const allPanels = getVisualPanels(dashboard);
const currentPanel = allPanels.find((panel) => panel.id === selectedPanelId);
setSelectedPanel(currentPanel);
}, [selectedPanelId, dashboardModel, isDashboardFetching]);
}, [selectedPanelId, dashboard, isDashboardFetching]);
const setSelectedDashboardAndPanelId = (dashboardUid: string, panelId: number) => {
const updatedAnnotations = produce(annotations, (draft) => {
@@ -3,11 +3,11 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { Icon, Text, useStyles2 } from '@grafana/ui';
import { DashboardDataDTO } from 'app/types/dashboard';
import { makeDashboardLink, makePanelLink } from '../../utils/misc';
import { PanelDTO } from './DashboardPicker';
import { PanelDTO, getDashboardTitle, getDashboardUid } from './DashboardPicker';
import { DashboardResponse } from './useDashboardQuery';
const DashboardAnnotationField = ({
dashboard,
@@ -17,7 +17,7 @@ const DashboardAnnotationField = ({
onEditClick,
onDeleteClick,
}: {
dashboard?: DashboardDataDTO;
dashboard?: DashboardResponse;
panel?: PanelDTO;
dashboardUid: string; //fallback
panelId: string; //fallback
@@ -26,8 +26,8 @@ const DashboardAnnotationField = ({
}) => {
const styles = useStyles2(getStyles);
const dashboardLink = makeDashboardLink(dashboard?.uid || dashboardUid);
const panelLink = makePanelLink(dashboard?.uid || dashboardUid, panel?.id?.toString() || panelId);
const dashboardLink = makeDashboardLink(getDashboardUid(dashboard) || dashboardUid);
const panelLink = makePanelLink(getDashboardUid(dashboard) || dashboardUid, panel?.id?.toString() || panelId);
return (
<div className={styles.container}>
{dashboard && (
@@ -38,7 +38,7 @@ const DashboardAnnotationField = ({
rel="noreferrer"
data-testid="dashboard-annotation"
>
{dashboard.title} <Icon name={'external-link-alt'} />
{getDashboardTitle(dashboard)} <Icon name={'external-link-alt'} />
</a>
)}
@@ -7,6 +7,7 @@ import { FixedSizeList } from 'react-window';
import { GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import {
Alert,
Button,
@@ -18,11 +19,13 @@ import {
clearButtonStyles,
useStyles2,
} from '@grafana/ui';
import { AnnoKeyFolderTitle } from 'app/features/apiserver/types';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { isDashboardV2Resource } from 'app/features/dashboard/api/utils';
import { getGrafanaSearcher } from 'app/features/search/service/searcher';
import { DashboardDTO } from 'app/types/dashboard';
import { DashboardModel } from '../../../../dashboard/state/DashboardModel';
import { useDashboardQuery } from './useDashboardQuery';
import { DashboardResponse, useDashboardQuery } from './useDashboardQuery';
export interface PanelDTO {
id?: number;
@@ -74,7 +77,7 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis
const [debouncedDashboardFilter, setDebouncedDashboardFilter] = useState('');
const [panelFilter, setPanelFilter] = useState('');
const { value, loading: isDashSearchFetching } = useFilteredDashboards(debouncedDashboardFilter);
const { dashboardModel, isFetching: isDashboardFetching } = useDashboardQuery(selectedDashboardUid);
const { dashboard, isFetching: isDashboardFetching } = useDashboardQuery(selectedDashboardUid);
const handleDashboardChange = useCallback((dashboardUid: string) => {
setSelectedDashboardUid(dashboardUid);
setSelectedPanelId(undefined);
@@ -82,7 +85,7 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis
const { dashboards: filteredDashboards = [], locationInfo: locationInfo = {} } = value || {};
const allDashboardPanels = getVisualPanels(dashboardModel);
const allDashboardPanels = getVisualPanels(dashboard);
const filteredPanels =
allDashboardPanels
@@ -197,7 +200,7 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis
contentClassName={styles.modalContent}
>
{/* This alert shows if the selected dashboard is not found in the first page of dashboards */}
{!selectedDashboardIsInPageResult && dashboardUid && dashboardModel && (
{!selectedDashboardIsInPageResult && dashboardUid && dashboard && (
<Alert
title={t('alerting.dashboard-picker.title-current-selection', 'Current selection')}
severity="info"
@@ -209,9 +212,9 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis
<Trans
i18nKey="alerting.dashboard-picker.current-selection-dashboard"
values={{
dashboardTitle: dashboardModel.title,
dashboardUid: dashboardModel.uid,
folderTitle: dashboardModel.meta?.folderTitle ?? fallbackDashboardsString,
dashboardTitle: getDashboardTitle(dashboard),
dashboardUid: getDashboardUid(dashboard),
folderTitle: getDashboardFolderTitle(dashboard) ?? fallbackDashboardsString,
}}
>
Dashboard: {'{{dashboardTitle}}'} ({'{{ dashboardUid }}'}) in folder {'{{ folderTitle }}'}
@@ -318,13 +321,35 @@ export const DashboardPicker = ({ dashboardUid, panelId, isOpen, onChange, onDis
);
};
export function getVisualPanels(dashboardModel: DashboardModel | undefined) {
if (!dashboardModel) {
export function getVisualPanels(dashboardDTO: DashboardResponse | undefined) {
if (!dashboardDTO || !('dashboard' in dashboardDTO)) {
return [];
}
const panelsWithoutRows = dashboardModel.panels.filter((panel) => panel.type !== 'row');
const panelsNestedInRows = dashboardModel.panels
// process v2 dashboard
if (isDashboardV2Resource(dashboardDTO)) {
return Object.values(dashboardDTO.spec.elements).map((element) => ({
id: element.spec.id,
title: element.spec.title,
type: element.kind === 'Panel' ? element.spec.vizConfig.group : 'LibraryPanel',
...(element.kind === 'LibraryPanel' && {
libraryPanel: {
uid: element.spec.libraryPanel.uid,
name: element.spec.libraryPanel.name,
},
}),
}));
}
// process v1 dashboard
const { dashboard } = dashboardDTO;
if (!dashboard || !dashboard.panels) {
return [];
}
const panelsWithoutRows = dashboard.panels.filter((panel) => panel.type !== 'row');
const panelsNestedInRows = dashboard.panels
.filter((rowPanel) => rowPanel.collapsed)
.flatMap((collapsedRow) => collapsedRow.panels ?? []);
@@ -332,6 +357,46 @@ export function getVisualPanels(dashboardModel: DashboardModel | undefined) {
return allDashboardPanels;
}
export function getDashboardTitle(dashboardDTO: DashboardResponse | undefined) {
if (!dashboardDTO || !('dashboard' in dashboardDTO)) {
return '';
}
if (isDashboardV2Resource(dashboardDTO)) {
return dashboardDTO.spec.title;
}
return dashboardDTO.dashboard.title;
}
export function getDashboardUid(dashboardDTO: DashboardResponse | undefined) {
if (!dashboardDTO || !('dashboard' in dashboardDTO)) {
return '';
}
if (isDashboardV2Resource(dashboardDTO)) {
return dashboardDTO.metadata.name;
}
return dashboardDTO.dashboard.uid;
}
export function getDashboardFolderTitle(
dashboardDTO: DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec> | undefined
) {
if (!dashboardDTO || !('dashboard' in dashboardDTO)) {
return undefined;
}
if (isDashboardV2Resource(dashboardDTO)) {
return dashboardDTO.metadata.annotations?.[AnnoKeyFolderTitle];
}
const { meta } = dashboardDTO;
return meta.folderTitle;
}
const isValidPanel = (panel: PanelDTO): boolean => {
const hasValidID = typeof panel.id === 'number';
const isValidPanelType = typeof panel.type === 'string';
@@ -1,36 +1,47 @@
import memoizeOne from 'memoize-one';
import { useEffect, useState } from 'react';
import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { DashboardWithAccessInfo } from 'app/features/dashboard/api/types';
import { isDashboardV2Resource } from 'app/features/dashboard/api/utils';
import { DashboardDTO } from 'app/types/dashboard';
import { DashboardModel } from '../../../../dashboard/state/DashboardModel';
const convertToDashboardModel = memoizeOne((dashboardDTO: DashboardDTO) => {
export type DashboardResponse = DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>;
const ensureV1PanelsHaveIds = memoizeOne((dashboardDTO: DashboardDTO): DashboardResponse => {
// RTKQuery freezes all returned objects. DashboardModel constructor runs migrations which might change the internal object
// Hence we need to add structuredClone to make a deep copy of the API response object
const { dashboard, meta } = structuredClone(dashboardDTO);
return new DashboardModel(dashboard, meta);
const dashboardDTOClone = structuredClone(dashboardDTO);
const model = new DashboardModel(dashboardDTOClone.dashboard, dashboardDTOClone.meta);
dashboardDTOClone.dashboard.panels = model.panels;
return dashboardDTOClone;
});
export function useDashboardQuery(dashboardUid?: string) {
const [dashboardModel, setDashboardModel] = useState<DashboardModel>();
const [dashboard, setDashboard] = useState<DashboardResponse>();
const [isFetching, setIsFetching] = useState(false);
useEffect(() => {
if (dashboardUid) {
setIsFetching(true);
getDashboardAPI()
.getDashboardDTO(dashboardUid)
.then((dashboard) => {
if (!('dashboard' in dashboard)) {
console.error('Something went wrong, unexpected dashboard format');
.then((dashboardDTO) => {
if ('dashboard' in dashboardDTO) {
setDashboard(ensureV1PanelsHaveIds(dashboardDTO));
} else if (isDashboardV2Resource(dashboardDTO)) {
setDashboard(dashboardDTO);
} else {
setDashboardModel(convertToDashboardModel(dashboard));
console.error('Something went wrong, unexpected dashboard format');
}
setIsFetching(false);
});
}
}, [dashboardUid]);
return { dashboardModel, isFetching };
return { dashboard, isFetching };
}
@@ -64,6 +64,7 @@ import { DashboardMeta } from 'app/types/dashboard';
import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior';
import { dashboardAnalyticsInitializer } from '../behaviors/DashboardAnalyticsInitializerBehavior';
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardControls } from '../scene/DashboardControls';
import { DashboardDataLayerSet } from '../scene/DashboardDataLayerSet';
@@ -123,6 +124,15 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
return new DashboardAnnotationsDataLayer(layerState);
});
// Create alert states data layer if unified alerting is enabled
let alertStatesLayer: AlertStatesDataLayer | undefined;
if (config.unifiedAlertingEnabled) {
alertStatesLayer = new AlertStatesDataLayer({
key: 'alert-states',
name: 'Alert States',
});
}
const isDashboardEditable = Boolean(dashboard.editable);
const canSave = dto.access.canSave !== false;
let dashboardId: number | undefined = undefined;
@@ -232,6 +242,7 @@ export function transformSaveModelSchemaV2ToScene(dto: DashboardWithAccessInfo<D
],
$data: new DashboardDataLayerSet({
annotationLayers,
alertStatesLayer,
}),
controls: new DashboardControls({
timePicker: new SceneTimePicker({