chore: adding action support

This commit is contained in:
Galen 2025-10-07 18:03:19 -05:00
parent 0b84ae5b54
commit 0e522362af
No known key found for this signature in database
10 changed files with 137 additions and 21 deletions

View File

@ -18,9 +18,13 @@ import {
AnnotationQuery,
getSearchFilterScopedVar,
FieldType,
ActionType,
HttpRequestMethod,
ActionVariableType,
} from '@grafana/data';
import { DataSourceWithBackend, getBackendSrv, getGrafanaLiveSrv, getTemplateSrv, TemplateSrv } from '@grafana/runtime';
// eslint-disable-next-line import/no-restricted-paths
import { Scenario, TestDataDataQuery, TestDataQueryType } from './dataquery';
import { queryMetricTree } from './metricTree';
import { generateRandomEdges, generateRandomNodes, generateShowcaseData, savedNodesResponse } from './nodeGraphUtils';
@ -179,17 +183,70 @@ export class TestDataDataSource extends DataSourceWithBackend<TestDataDataQuery>
req: DataQueryRequest<TestDataDataQuery>
): Observable<DataQueryResponse> {
const events = this.buildFakeAnnotationEvents(req.range, target.lines ?? 10);
const dataFrame = new ArrayDataFrame(events);
dataFrame.meta = { dataTopic: DataTopic.Annotations };
if (dataFrame.fields?.[1]) {
dataFrame.fields[1].config = {
...dataFrame.fields[1].config,
links: [
{
url: 'https://grafana.com',
title: 'Annotation Data link',
},
],
// The API call doesn't actually succeed, it appears to be impossible to call an internal Grafana API from an action at this time
actions: [
{
// type: ActionType.Infinity,
// infinity: {
// headers: [['Content-Type', 'application/json'], ['Accept', 'application/json, text/plain, */*'], ['Authorization', 'Bearer $serviceToken']],
// method: HttpRequestMethod.POST,
// url: '/api/annotations',
// body: `{"dashboardUID":"$dashboardUID","isRegion":true,"panelId":$panelID,"time":$__from,"timeEnd":$__to,"tags":["tag1","tag2"],"text":"Annotation Description"}`,
// // DatasourceUid does not interpolate variables so there's also no way to use the infinity action type with a grafana api either
// datasourceUid: '$infinityDatasourceUID',
// },
type: ActionType.Fetch,
fetch: {
// For some reason x-grafana-action is defined on all fetch requests but it causes all internal API calls to fail. Removing it would work, except defining it here doesn't remove the value, it appends to the existing value, so there's no way to get a fetch request to call a Grafana API within an action.
// If you copy this request as cURL from the browser and then drop into insomnia/postman and remove the `x-grafana-action` header then this call succeeds
headers: [
['Content-Type', 'application/json'],
['Accept', 'application/json, text/plain, */*'],
['Authorization', 'Bearer $serviceToken'],
['x-grafana-action', ''],
],
method: HttpRequestMethod.POST,
url: '/api/annotations',
body: `{"dashboardUID":"$dashboardUID","isRegion":true,"panelId":$panelID,"time":$__from,"timeEnd":$__to,"tags":["tag1","tag2"],"text":"Annotation Description"}`,
},
variables: [
{
key: 'dashboardUID',
name: 'dashboardUID',
type: ActionVariableType.String,
},
{
key: 'panelID',
name: 'panelID',
type: ActionVariableType.String,
},
{
key: 'serviceToken',
name: 'serviceToken',
type: ActionVariableType.String,
},
// {
// key: 'infinityDatasourceUID',
// name: 'infinityDatasourceUID',
// type: ActionVariableType.String,
// }
],
title: 'Add annotation to panel for this time range',
},
],
...dataFrame.fields[1].config,
};
}
return of({ key: target.refId, data: [dataFrame] }).pipe(delay(100));

View File

@ -321,6 +321,7 @@ export const CandlestickPanel = ({
/>
)}
<AnnotationsPlugin2
replaceVariables={replaceVariables}
annotations={data.annotations ?? []}
config={uplotConfig}
timeZone={timeZone}

View File

@ -230,6 +230,7 @@ export const HeatmapPanel = ({
/>
)}
<AnnotationsPlugin2
replaceVariables={replaceVariables}
annotations={data.annotations ?? []}
config={builder}
timeZone={timeZone}

View File

@ -150,6 +150,7 @@ export const StateTimelinePanel = ({
)}
{alignedFrame.fields[0].config.custom?.axisPlacement !== AxisPlacement.Hidden && (
<AnnotationsPlugin2
replaceVariables={replaceVariables}
annotations={data.annotations ?? []}
config={builder}
timeZone={timeZone}

View File

@ -163,6 +163,7 @@ export const StatusHistoryPanel = ({
)}
{alignedFrame.fields[0].config.custom?.axisPlacement !== AxisPlacement.Hidden && (
<AnnotationsPlugin2
replaceVariables={replaceVariables}
annotations={data.annotations ?? []}
config={builder}
timeZone={timeZone}

View File

@ -33,17 +33,28 @@ export const getFieldActions = (
const actions: Array<ActionModel<Field>> = [];
const actionLookup = new Set<string>();
const actionsModel = getActions(dataFrame, field, field.state!.scopedVars!, replaceVars, field.config.actions ?? [], {
valueRowIndex: rowIndex,
});
if (field.state?.scopedVars) {
const actionsModel = getActions(
dataFrame,
field,
field.state!.scopedVars!,
replaceVars,
field.config.actions ?? [],
{
valueRowIndex: rowIndex,
}
);
actionsModel.forEach((action) => {
const key = `${action.title}`;
if (!actionLookup.has(key)) {
actions.push(action);
actionLookup.add(key);
}
});
actionsModel.forEach((action) => {
const key = `${action.title}`;
if (!actionLookup.has(key)) {
actions.push(action);
actionLookup.add(key);
}
});
} else {
console.warn('no scoped vars!', field);
}
return actions;
};

View File

@ -191,6 +191,7 @@ export const TimeSeriesPanel = ({
{!isVerticallyOriented && (
<>
<AnnotationsPlugin2
replaceVariables={replaceVariables}
annotations={data.annotations ?? []}
config={uplotConfig}
timeZone={timeZone}

View File

@ -1,15 +1,31 @@
import { css } from '@emotion/css';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useReducer } from 'react';
import * as React from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import tinycolor from 'tinycolor2';
import uPlot from 'uplot';
import { arrayToDataFrame, colorManipulator, DataFrame, DataTopic, Field, LinkModel } from '@grafana/data';
import {
ActionModel,
arrayToDataFrame,
colorManipulator,
DataFrame,
DataTopic,
Field,
InterpolateFunction,
LinkModel,
} from '@grafana/data';
import { TimeZone } from '@grafana/schema';
import { DEFAULT_ANNOTATION_COLOR, getPortalContainer, UPlotConfigBuilder, useStyles2, useTheme2 } from '@grafana/ui';
import {
DEFAULT_ANNOTATION_COLOR,
getPortalContainer,
UPlotConfigBuilder,
usePanelContext,
useStyles2,
useTheme2,
} from '@grafana/ui';
import { getDataLinks } from '../../status-history/utils';
import { getDataLinks, getFieldActions } from '../../status-history/utils';
import { AnnotationMarker2 } from './annotations2/AnnotationMarker2';
@ -26,6 +42,7 @@ interface AnnotationsPluginProps {
newRange: TimeRange2 | null;
setNewRange: (newRage: TimeRange2 | null) => void;
canvasRegionRendering?: boolean;
replaceVariables: InterpolateFunction;
}
// TODO: batch by color, use Path2D objects
@ -64,6 +81,7 @@ export const AnnotationsPlugin2 = ({
config,
newRange,
setNewRange,
replaceVariables,
canvasRegionRendering = true,
}: AnnotationsPluginProps) => {
const [plot, setPlot] = useState<uPlot>();
@ -75,6 +93,9 @@ export const AnnotationsPlugin2 = ({
const [_, forceUpdate] = useReducer((x) => x + 1, 0);
const { canExecuteActions } = usePanelContext();
const userCanExecuteActions = useMemo(() => canExecuteActions?.() ?? false, [canExecuteActions]);
const annos = useMemo(() => {
let annos = annotations.filter(
(frame) => frame.name !== 'exemplar' && frame.length > 0 && frame.fields.some((f) => f.name === 'time')
@ -113,7 +134,6 @@ export const AnnotationsPlugin2 = ({
annoRef.current = annos;
const newRangeRef = useRef(newRange);
newRangeRef.current = newRange;
const xAxisRef = useRef<HTMLDivElement>();
useLayoutEffect(() => {
@ -248,14 +268,25 @@ export const AnnotationsPlugin2 = ({
// @TODO: Reset newRange after annotation is saved
if (isVisible) {
let isWip = frame.meta?.custom?.isWip;
const links: LinkModel[] = [];
const actions: Array<ActionModel<Field>> = [];
let links: LinkModel[] = [];
// @todo only grab links/actions from y-axis field, or from all fields?
frame.fields.forEach((field: Field) => {
// Get data links
links.push(...getDataLinks(field, i));
// Get actions
if (userCanExecuteActions) {
actions.push(...getFieldActions(frame, field, replaceVariables, i));
}
});
console.log('actions', actions, frame);
markers.push(
<AnnotationMarker2
actions={actions}
links={links}
annoIdx={i}
annoVals={vals}

View File

@ -5,7 +5,7 @@ import { useState } from 'react';
import * as React from 'react';
import { createPortal } from 'react-dom';
import { GrafanaTheme2, LinkModel } from '@grafana/data';
import { ActionModel, Field, GrafanaTheme2, LinkModel } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { TimeZone } from '@grafana/schema';
import { floatingUtils, useStyles2 } from '@grafana/ui';
@ -22,6 +22,7 @@ interface AnnoBoxProps {
exitWipEdit?: null | (() => void);
portalRoot: HTMLElement;
links: LinkModel[];
actions: Array<ActionModel<Field>>;
}
const STATE_DEFAULT = 0;
@ -37,6 +38,7 @@ export const AnnotationMarker2 = ({
timeZone,
portalRoot,
links,
actions,
}: AnnoBoxProps) => {
const styles = useStyles2(getStyles);
const placement = 'bottom';
@ -58,6 +60,7 @@ export const AnnotationMarker2 = ({
timeZone={timeZone}
onEdit={() => setState(STATE_EDITING)}
links={links}
actions={actions}
/>
) : state === STATE_EDITING ? (
<AnnotationEditor2

View File

@ -1,7 +1,15 @@
import { css } from '@emotion/css';
import * as React from 'react';
import { GrafanaTheme2, dateTimeFormat, systemDateFormats, textUtil, LinkModel } from '@grafana/data';
import {
GrafanaTheme2,
dateTimeFormat,
systemDateFormats,
textUtil,
LinkModel,
ActionModel,
Field,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { Stack, IconButton, Tag, usePanelContext, useStyles2 } from '@grafana/ui';
import { VizTooltipFooter } from '@grafana/ui/internal';
@ -13,11 +21,12 @@ interface Props {
timeZone: string;
onEdit: () => void;
links: LinkModel[];
actions: Array<ActionModel<Field>>;
}
const retFalse = () => false;
export const AnnotationTooltip2 = ({ annoVals, annoIdx, timeZone, onEdit, links }: Props) => {
export const AnnotationTooltip2 = ({ annoVals, annoIdx, timeZone, onEdit, links, actions }: Props) => {
const annoId = annoVals.id?.[annoIdx];
const styles = useStyles2(getStyles);
@ -109,7 +118,7 @@ export const AnnotationTooltip2 = ({ annoVals, annoIdx, timeZone, onEdit, links
</Stack>
</div>
</div>
<VizTooltipFooter dataLinks={links} />
<VizTooltipFooter dataLinks={links} actions={actions} />
</div>
);
};