VYPR
Critical severity9.0OSV Advisory· Published Sep 8, 2025· Updated Apr 15, 2026

CVE-2025-58746

CVE-2025-58746

Description

The Volkov Labs Business Links panel for Grafana provides an interface to navigate using external links, internal dashboards, time pickers, and dropdown menus. Prior to version 2.4.0, a malicious actor with Editor privileges can escalate their privileges to Administrator and perform arbitrary administrative actions. This is possible because the plugin allows arbitrary JavaScript code injection in the [Layout] → [Link] → [URL] field. Version 2.4.0 contains a fix for the issue.

Affected products

1

Patches

2
e5b77e694487

Add annotation toggle (#68)

https://github.com/volkovlabs/business-linksVitali PinchukSep 7, 2025via osv
32 files changed · +1365 19
  • CHANGELOG.md+3 2 modified
    @@ -2,11 +2,12 @@
     
     All notable changes to the **Business Links** panel are documented in this file. This changelog follows the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
     
    -## [2.4.0] - Unreleased
    +## [2.4.0] - 2025-09-07
     
     ### Added
     
    -- Added sanitize url check. ([#77](https://github.com/VolkovLabs/business-links/issues/77))
    +- **Sanitize URL Check**: Introduced a new feature to sanitize URLs to prevent potential security issues and ensure safe link handling. This addresses concerns related to malicious or malformed URLs. ([#77](https://github.com/VolkovLabs/business-links/issues/77))
    +- **Annotation Toggle**: Added the ability to toggle annotations on or off, giving users more control over the visibility of annotations in the interface. This enhances user experience by allowing customization based on preference. ([#68](https://github.com/VolkovLabs/business-links/issues/68))
     
     ## [2.3.0] - 2025-08-26
     
    
  • provisioning/dashboards/annotations.json+579 0 added
    @@ -0,0 +1,579 @@
    +{
    +  "annotations": {
    +    "list": [
    +      {
    +        "builtIn": 1,
    +        "datasource": {
    +          "type": "grafana",
    +          "uid": "-- Grafana --"
    +        },
    +        "enable": true,
    +        "hide": true,
    +        "iconColor": "rgba(0, 211, 255, 1)",
    +        "name": "Annotations & Alerts",
    +        "type": "dashboard"
    +      },
    +      {
    +        "datasource": {
    +          "type": "marcusolsson-static-datasource",
    +          "uid": "P1D2C73DC01F2359B"
    +        },
    +        "enable": true,
    +        "hide": false,
    +        "iconColor": "red",
    +        "name": "New annotation 1 ",
    +        "target": {
    +          "frame": {
    +            "fields": [
    +              {
    +                "config": {},
    +                "name": "time",
    +                "type": "time",
    +                "values": [1755610938490]
    +              },
    +              {
    +                "config": {},
    +                "name": "timeEnd",
    +                "type": "time",
    +                "values": [1755614538400]
    +              },
    +              {
    +                "config": {},
    +                "name": "title",
    +                "type": "string",
    +                "values": ["Test -1 "]
    +              },
    +              {
    +                "config": {},
    +                "name": "text",
    +                "type": "string",
    +                "values": ["Text Test - 1"]
    +              },
    +              {
    +                "config": {},
    +                "name": "id",
    +                "type": "string",
    +                "values": ["Test id - 1"]
    +              }
    +            ],
    +            "meta": {}
    +          },
    +          "refId": "Anno"
    +        }
    +      },
    +      {
    +        "datasource": {
    +          "type": "marcusolsson-static-datasource",
    +          "uid": "P1D2C73DC01F2359B"
    +        },
    +        "enable": true,
    +        "hide": false,
    +        "iconColor": "purple",
    +        "name": "New annotation 2 ",
    +        "target": {
    +          "frame": {
    +            "fields": [
    +              {
    +                "config": {},
    +                "name": "time",
    +                "type": "time",
    +                "values": [1755600240500]
    +              },
    +              {
    +                "config": {},
    +                "name": "timeEnd",
    +                "type": "time",
    +                "values": [1755600240500]
    +              },
    +              {
    +                "config": {},
    +                "name": "title",
    +                "type": "string",
    +                "values": ["Test - 2 "]
    +              },
    +              {
    +                "config": {},
    +                "name": "text",
    +                "type": "string",
    +                "values": ["Text - 2 test"]
    +              },
    +              {
    +                "config": {},
    +                "name": "id",
    +                "type": "string",
    +                "values": ["Ann - 2 "]
    +              }
    +            ],
    +            "meta": {}
    +          },
    +          "refId": "Anno"
    +        }
    +      },
    +      {
    +        "datasource": {
    +          "type": "marcusolsson-static-datasource",
    +          "uid": "P1D2C73DC01F2359B"
    +        },
    +        "enable": false,
    +        "hide": false,
    +        "iconColor": "green",
    +        "name": "New annotation 3",
    +        "target": {
    +          "frame": {
    +            "fields": [
    +              {
    +                "config": {},
    +                "name": "time",
    +                "type": "time",
    +                "values": [1755596054284]
    +              },
    +              {
    +                "config": {},
    +                "name": "timeEnd",
    +                "type": "time",
    +                "values": [1755596054284]
    +              },
    +              {
    +                "config": {},
    +                "name": "title",
    +                "type": "string",
    +                "values": [""]
    +              },
    +              {
    +                "config": {},
    +                "name": "text",
    +                "type": "string",
    +                "values": [""]
    +              },
    +              {
    +                "config": {},
    +                "name": "id",
    +                "type": "string",
    +                "values": [""]
    +              }
    +            ],
    +            "meta": {}
    +          },
    +          "refId": "Anno"
    +        }
    +      }
    +    ]
    +  },
    +  "editable": true,
    +  "fiscalYearStartMonth": 0,
    +  "graphTooltip": 0,
    +  "id": 16,
    +  "links": [],
    +  "panels": [
    +    {
    +      "datasource": {
    +        "type": "datasource",
    +        "uid": "grafana"
    +      },
    +      "fieldConfig": {
    +        "defaults": {
    +          "color": {
    +            "mode": "palette-classic"
    +          },
    +          "custom": {
    +            "axisBorderShow": false,
    +            "axisCenteredZero": false,
    +            "axisColorMode": "text",
    +            "axisLabel": "",
    +            "axisPlacement": "auto",
    +            "barAlignment": 0,
    +            "barWidthFactor": 0.6,
    +            "drawStyle": "line",
    +            "fillOpacity": 0,
    +            "gradientMode": "none",
    +            "hideFrom": {
    +              "legend": false,
    +              "tooltip": false,
    +              "viz": false
    +            },
    +            "insertNulls": false,
    +            "lineInterpolation": "linear",
    +            "lineWidth": 1,
    +            "pointSize": 5,
    +            "scaleDistribution": {
    +              "type": "linear"
    +            },
    +            "showPoints": "auto",
    +            "spanNulls": false,
    +            "stacking": {
    +              "group": "A",
    +              "mode": "none"
    +            },
    +            "thresholdsStyle": {
    +              "mode": "off"
    +            }
    +          },
    +          "mappings": [],
    +          "thresholds": {
    +            "mode": "absolute",
    +            "steps": [
    +              {
    +                "color": "green",
    +                "value": null
    +              },
    +              {
    +                "color": "red",
    +                "value": 80
    +              }
    +            ]
    +          }
    +        },
    +        "overrides": []
    +      },
    +      "gridPos": {
    +        "h": 8,
    +        "w": 12,
    +        "x": 0,
    +        "y": 0
    +      },
    +      "id": 8,
    +      "options": {
    +        "legend": {
    +          "calcs": [],
    +          "displayMode": "list",
    +          "placement": "bottom",
    +          "showLegend": true
    +        },
    +        "tooltip": {
    +          "hideZeros": false,
    +          "mode": "single",
    +          "sort": "none"
    +        }
    +      },
    +      "targets": [
    +        {
    +          "datasource": {
    +            "type": "datasource",
    +            "uid": "grafana"
    +          },
    +          "queryType": "randomWalk",
    +          "refId": "A"
    +        }
    +      ],
    +      "title": "New panel",
    +      "type": "timeseries"
    +    },
    +    {
    +      "datasource": {
    +        "type": "marcusolsson-static-datasource",
    +        "uid": "P1D2C73DC01F2359B"
    +      },
    +      "fieldConfig": {
    +        "defaults": {},
    +        "overrides": []
    +      },
    +      "gridPos": {
    +        "h": 5,
    +        "w": 12,
    +        "x": 0,
    +        "y": 8
    +      },
    +      "id": 7,
    +      "options": {
    +        "customPadding": 0,
    +        "dropdowns": [],
    +        "groups": [
    +          {
    +            "dynamicFontSize": false,
    +            "gridColumns": 10,
    +            "gridLayout": false,
    +            "gridRowHeight": 16,
    +            "highlightCurrentLink": false,
    +            "highlightCurrentTimepicker": false,
    +            "items": [
    +              {
    +                "alignContentPosition": "left",
    +                "annotationKey": "New annotation 1 ",
    +                "customIconUrl": "",
    +                "dashboardUrl": "",
    +                "dropdownConfig": {
    +                  "align": "left",
    +                  "buttonSize": "md",
    +                  "type": "dropdown"
    +                },
    +                "enable": true,
    +                "hideTooltipOnHover": false,
    +                "id": "bcdd6a06-3574-445c-985a-30806489d90b",
    +                "includeKioskMode": false,
    +                "includeTimeRange": false,
    +                "includeVariables": false,
    +                "linkType": "annotation",
    +                "mcpServers": [],
    +                "name": "annot-1",
    +                "showCustomIcons": false,
    +                "showLoadingForRawMessage": true,
    +                "showMenuOnHover": false,
    +                "tags": [],
    +                "target": "_self",
    +                "timePickerConfig": {
    +                  "highlightSecondsDiff": 30,
    +                  "type": "field"
    +                },
    +                "url": "",
    +                "useDefaultGrafanaMcp": false
    +              },
    +              {
    +                "alignContentPosition": "left",
    +                "annotationKey": "",
    +                "customIconUrl": "",
    +                "dashboardUrl": "",
    +                "dropdownConfig": {
    +                  "align": "left",
    +                  "buttonSize": "md",
    +                  "type": "dropdown"
    +                },
    +                "enable": true,
    +                "hideTooltipOnHover": false,
    +                "icon": "external-link-alt",
    +                "id": "493fea4a-40e2-4fcb-82eb-7dc521f7a0de",
    +                "includeKioskMode": false,
    +                "includeTimeRange": false,
    +                "includeVariables": false,
    +                "linkType": "single",
    +                "name": "New tab",
    +                "showCustomIcons": false,
    +                "showLoadingForRawMessage": true,
    +                "tags": [],
    +                "target": "_blank",
    +                "timePickerConfig": {
    +                  "highlightSecondsDiff": 30,
    +                  "type": "field"
    +                },
    +                "type": "link",
    +                "url": "https://volkovlabs.io/",
    +                "useDefaultGrafanaMcp": false
    +              },
    +              {
    +                "alignContentPosition": "left",
    +                "annotationKey": "",
    +                "customIconUrl": "",
    +                "dashboardUrl": "",
    +                "dropdownConfig": {
    +                  "align": "left",
    +                  "buttonSize": "md",
    +                  "type": "dropdown"
    +                },
    +                "enable": true,
    +                "hideTooltipOnHover": false,
    +                "icon": "link",
    +                "id": "3d0053cb-3f13-479c-9e95-1e880e9b959f",
    +                "includeKioskMode": false,
    +                "includeTimeRange": false,
    +                "includeVariables": false,
    +                "linkType": "single",
    +                "name": "Link",
    +                "showCustomIcons": false,
    +                "showLoadingForRawMessage": true,
    +                "tags": [],
    +                "target": "_self",
    +                "timePickerConfig": {
    +                  "highlightSecondsDiff": 30,
    +                  "type": "field"
    +                },
    +                "type": "link",
    +                "url": "https://volkovlabs.io/",
    +                "useDefaultGrafanaMcp": false
    +              },
    +              {
    +                "alignContentPosition": "left",
    +                "annotationKey": "",
    +                "customIconUrl": "",
    +                "dashboardUrl": "/d/bemcg9ufankzka/button-rows",
    +                "dropdownConfig": {
    +                  "align": "left",
    +                  "buttonSize": "md",
    +                  "type": "dropdown"
    +                },
    +                "enable": true,
    +                "hideTooltipOnHover": false,
    +                "id": "c6b02863-7c76-4e1a-8693-c629d2561048",
    +                "includeKioskMode": true,
    +                "includeTimeRange": false,
    +                "includeVariables": false,
    +                "linkType": "dashboard",
    +                "name": "kiosk mode support",
    +                "showCustomIcons": false,
    +                "showLoadingForRawMessage": true,
    +                "showMenuOnHover": false,
    +                "tags": [],
    +                "target": "_self",
    +                "timePickerConfig": {
    +                  "highlightSecondsDiff": 30,
    +                  "type": "field"
    +                },
    +                "url": "",
    +                "useDefaultGrafanaMcp": false
    +              },
    +              {
    +                "alignContentPosition": "left",
    +                "annotationKey": "New annotation 2 ",
    +                "customIconUrl": "",
    +                "dashboardUrl": "",
    +                "dropdownConfig": {
    +                  "align": "left",
    +                  "buttonSize": "md",
    +                  "type": "dropdown"
    +                },
    +                "enable": true,
    +                "hideTooltipOnHover": false,
    +                "id": "f8163211-c367-4f68-8c6a-24037a2f5dfe",
    +                "includeKioskMode": false,
    +                "includeTimeRange": false,
    +                "includeVariables": false,
    +                "linkType": "annotation",
    +                "mcpServers": [],
    +                "name": "Annot2",
    +                "showCustomIcons": false,
    +                "showMenuOnHover": false,
    +                "tags": [],
    +                "target": "_self",
    +                "timePickerConfig": {
    +                  "type": "field"
    +                },
    +                "url": ""
    +              },
    +              {
    +                "alignContentPosition": "left",
    +                "annotationKey": "New annotation 3",
    +                "customIconUrl": "",
    +                "dashboardUrl": "",
    +                "dropdownConfig": {
    +                  "align": "left",
    +                  "buttonSize": "md",
    +                  "type": "dropdown"
    +                },
    +                "enable": true,
    +                "hideTooltipOnHover": false,
    +                "id": "6ea5d9a3-52ba-4c7a-9484-1268977a1e55",
    +                "includeKioskMode": false,
    +                "includeTimeRange": false,
    +                "includeVariables": false,
    +                "linkType": "annotation",
    +                "mcpServers": [],
    +                "name": "annot3",
    +                "showCustomIcons": false,
    +                "showMenuOnHover": false,
    +                "tags": [],
    +                "target": "_self",
    +                "timePickerConfig": {
    +                  "type": "field"
    +                },
    +                "url": ""
    +              }
    +            ],
    +            "manualGridLayout": [],
    +            "name": "Links"
    +          }
    +        ],
    +        "groupsSorting": false,
    +        "sticky": false
    +      },
    +      "targets": [
    +        {
    +          "refId": "A"
    +        }
    +      ],
    +      "title": "External links",
    +      "type": "volkovlabs-links-panel"
    +    }
    +  ],
    +  "preload": false,
    +  "schemaVersion": 40,
    +  "tags": [],
    +  "templating": {
    +    "list": [
    +      {
    +        "current": {
    +          "text": "device1",
    +          "value": "device1"
    +        },
    +        "label": "device",
    +        "name": "device",
    +        "options": [
    +          {
    +            "selected": true,
    +            "text": "device1",
    +            "value": "device1"
    +          },
    +          {
    +            "selected": false,
    +            "text": "device2",
    +            "value": "device2"
    +          },
    +          {
    +            "selected": false,
    +            "text": "device3",
    +            "value": "device3"
    +          }
    +        ],
    +        "query": "device1,device2,device3",
    +        "type": "custom"
    +      },
    +      {
    +        "current": {
    +          "text": "USA",
    +          "value": "USA"
    +        },
    +        "label": "country",
    +        "name": "country",
    +        "options": [
    +          {
    +            "selected": true,
    +            "text": "USA",
    +            "value": "USA"
    +          },
    +          {
    +            "selected": false,
    +            "text": "Sweden",
    +            "value": "Sweden"
    +          },
    +          {
    +            "selected": false,
    +            "text": "Spain",
    +            "value": "Spain"
    +          },
    +          {
    +            "selected": false,
    +            "text": "Italy",
    +            "value": "Italy"
    +          }
    +        ],
    +        "query": "USA,Sweden,Spain,Italy",
    +        "type": "custom"
    +      },
    +      {
    +        "current": {
    +          "text": "Sweden",
    +          "value": "Sweden"
    +        },
    +        "datasource": {
    +          "type": "grafana-postgresql-datasource",
    +          "uid": "timescale"
    +        },
    +        "definition": "select distinct country from devices;",
    +        "label": "countrytest",
    +        "name": "countrytest",
    +        "options": [],
    +        "query": "select distinct country from devices;",
    +        "refresh": 1,
    +        "regex": "",
    +        "type": "query"
    +      }
    +    ]
    +  },
    +  "time": {
    +    "from": "2025-08-17T18:00:00.000Z",
    +    "to": "2025-08-19T17:59:59.000Z"
    +  },
    +  "timepicker": {},
    +  "timezone": "browser",
    +  "title": "Annotations",
    +  "uid": "01e3bbce-64b5-4c1a-a753-c3bebd6ff836",
    +  "version": 4,
    +  "weekStart": ""
    +}
    
  • src/components/editors/GroupsEditor/components/GroupEditor/components/LinkEditor/LinkEditor.test.tsx+27 0 modified
    @@ -323,6 +323,33 @@ describe('LinkEditor', () => {
         );
       });
     
    +  it('Should allow change annotation layer for', () => {
    +    const annotationsMock = [
    +      { state: { key: 'annotation/1', name: 'annotation-1' } },
    +      { state: { key: 'annotation/2', name: 'annotation-2' } },
    +      { state: { key: 'annotation/3', name: 'annotation-3' } },
    +    ] as any;
    +
    +    render(
    +      getComponent({
    +        optionId: 'dropdowns',
    +        value: createLinkConfig({ linkType: LinkType.ANNOTATION, dashboardUrl: 'annotation-1' }),
    +        annotationsLayers: annotationsMock,
    +      })
    +    );
    +
    +    expect(selectors.fieldAnnotationLayer()).toBeInTheDocument();
    +    expect(selectors.fieldAnnotationLayer()).toHaveValue('annotation-1');
    +
    +    fireEvent.change(selectors.fieldAnnotationLayer(), { target: { values: 'annotation-3' } });
    +
    +    expect(onChange).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        annotationKey: 'annotation-3',
    +      })
    +    );
    +  });
    +
       it('Should allow change dropdown for DROPDOWN type', () => {
         const dropdowns = ['dropdown-1', 'dropdown-2', 'dropdown-3'];
         render(
    
  • src/components/editors/GroupsEditor/components/GroupEditor/components/LinkEditor/LinkEditor.tsx+51 3 modified
    @@ -16,6 +16,7 @@ import { FieldsGroup } from '@/components';
     import { TEST_IDS } from '@/constants';
     import {
       AlignContentPositionType,
    +  AnnotationLayer,
       ButtonSize,
       DashboardMeta,
       DropdownAlign,
    @@ -74,12 +75,24 @@ interface Props extends EditorProps<LinkConfig> {
        * @type {boolean}
        */
       isHighlightTimePicker?: boolean;
    +
    +  /**
    +   * Available dropdowns
    +   *
    +   * @type {AnnotationLayer[]}
    +   */
    +  annotationsLayers?: AnnotationLayer[];
     }
     
     /**
      * Link Type Option Map
      */
     const linkTypeOptionMap = {
    +  [LinkType.ANNOTATION]: {
    +    value: LinkType.ANNOTATION,
    +    label: 'Annotation toggler',
    +    description: 'Allows to display and hide annotations.',
    +  },
       [LinkType.SINGLE]: {
         value: LinkType.SINGLE,
         label: 'Link',
    @@ -121,6 +134,7 @@ const linkTypeOptionMap = {
      * Link Type Options
      */
     export const linkTypeOptions = [
    +  linkTypeOptionMap[LinkType.ANNOTATION],
       linkTypeOptionMap[LinkType.LLMAPP],
       linkTypeOptionMap[LinkType.DASHBOARD],
       linkTypeOptionMap[LinkType.HTML],
    @@ -134,6 +148,7 @@ export const linkTypeOptions = [
      * Link Type Options in dropdown editor
      */
     export const linkTypeOptionsInDropdown = [
    +  linkTypeOptionMap[LinkType.ANNOTATION],
       linkTypeOptionMap[LinkType.LLMAPP],
       linkTypeOptionMap[LinkType.DASHBOARD],
       linkTypeOptionMap[LinkType.SINGLE],
    @@ -260,7 +275,26 @@ export const alignContentPositionOptions = [
     /**
      * Link Editor
      */
    -export const LinkEditor: React.FC<Props> = ({ value, onChange, isGrid, data, dashboards, optionId, dropdowns }) => {
    +export const LinkEditor: React.FC<Props> = ({
    +  value,
    +  onChange,
    +  isGrid,
    +  data,
    +  dashboards,
    +  optionId,
    +  dropdowns,
    +  annotationsLayers,
    +}) => {
    +  /**
    +   * Annotations Layers options
    +   */
    +  const availableAnnotationsOptions = useMemo(() => {
    +    return annotationsLayers?.map((layer) => ({
    +      value: layer.state.name,
    +      label: layer.state.name,
    +    }));
    +  }, [annotationsLayers]);
    +
       /**
        * Errors State
        */
    @@ -322,6 +356,19 @@ export const LinkEditor: React.FC<Props> = ({ value, onChange, isGrid, data, das
               />
             </InlineField>
     
    +        {value.linkType === LinkType.ANNOTATION && (
    +          <InlineField label="Annotation" grow={true} labelWidth={20}>
    +            <Select
    +              options={availableAnnotationsOptions}
    +              value={value.annotationKey}
    +              onChange={(event) => {
    +                onChange({ ...value, annotationKey: event.value! });
    +              }}
    +              {...TEST_IDS.linkEditor.fieldAnnotationLayer.apply()}
    +            />
    +          </InlineField>
    +        )}
    +
             {value.linkType === LinkType.LLMAPP && (
               <>
                 <InlineField label="Initial Context" grow={true} labelWidth={20}>
    @@ -611,7 +658,7 @@ export const LinkEditor: React.FC<Props> = ({ value, onChange, isGrid, data, das
               )}
           </FieldsGroup>
     
    -      {value.linkType !== LinkType.HTML && (
    +      {value.linkType !== LinkType.HTML && value.linkType !== LinkType.ANNOTATION && (
             <FieldsGroup label="Configuration">
               <InlineField label="Use custom icon" grow={true} labelWidth={20}>
                 <InlineSwitch
    @@ -708,7 +755,8 @@ export const LinkEditor: React.FC<Props> = ({ value, onChange, isGrid, data, das
           {value.linkType !== LinkType.TIMEPICKER &&
             value.linkType !== LinkType.HTML &&
             value.linkType !== LinkType.DROPDOWN &&
    -        value.linkType !== LinkType.LLMAPP && (
    +        value.linkType !== LinkType.LLMAPP &&
    +        value.linkType !== LinkType.ANNOTATION && (
               <FieldsGroup label="Include">
                 <InlineField
                   label="Support kiosk mode"
    
  • src/components/editors/GroupsEditor/components/GroupEditor/GroupEditor.tsx+25 1 modified
    @@ -9,6 +9,7 @@ import { v4 as uuidv4 } from 'uuid';
     import { GRID_COLUMN_SIZE, GRID_ROW_SIZE, TEST_IDS } from '@/constants';
     import {
       AlignContentPositionType,
    +  AnnotationLayer,
       DashboardMeta,
       EditorProps,
       GroupConfig,
    @@ -64,6 +65,18 @@ interface Props extends EditorProps<GroupConfig> {
        */
       dropdowns?: string[];
     
    +  /**
    +   * Annotation Layers
    +   *
    +   * @type {AnnotationLayer[]}
    +   */
    +  annotationsLayers: AnnotationLayer[];
    +
    +  /**
    +   * Data
    +   *
    +   * @type {DataFrame[]}
    +   */
       data: DataFrame[];
     }
     
    @@ -75,7 +88,16 @@ const testIds = TEST_IDS.groupEditor;
     /**
      * Group Editor
      */
    -export const GroupEditor: React.FC<Props> = ({ value, name, data, onChange, dashboards, optionId, dropdowns }) => {
    +export const GroupEditor: React.FC<Props> = ({
    +  value,
    +  name,
    +  data,
    +  onChange,
    +  dashboards,
    +  optionId,
    +  dropdowns,
    +  annotationsLayers,
    +}) => {
       /**
        * Styles and Theme
        */
    @@ -177,6 +199,7 @@ export const GroupEditor: React.FC<Props> = ({ value, name, data, onChange, dash
             alignContentPosition: AlignContentPositionType.LEFT,
             hideTooltipOnHover: false,
             mcpServers: [],
    +        annotationKey: '',
           },
         ]);
         setNewLinkName('');
    @@ -448,6 +471,7 @@ export const GroupEditor: React.FC<Props> = ({ value, name, data, onChange, dash
                               dropdowns={dropdowns}
                               dashboards={dashboards}
                               isGrid={value.gridLayout}
    +                          annotationsLayers={annotationsLayers}
                             />
                           </Collapse>
                         </div>
    
  • src/components/editors/GroupsEditor/GroupsEditor.test.tsx+9 1 modified
    @@ -5,7 +5,7 @@ import React from 'react';
     
     import { TEST_IDS } from '@/constants';
     import { createGroupConfig, createLinkConfig, getAllDashboards } from '@/utils';
    -
    +import { useAnnotations } from '@/hooks';
     import { GroupEditor } from './components';
     import { GroupsEditor } from './GroupsEditor';
     
    @@ -39,6 +39,13 @@ jest.mock('@/utils', () => ({
       getAllDashboards: jest.fn(),
     }));
     
    +/**
    + * Mock hooks
    + */
    +jest.mock('@/hooks', () => ({
    +  useAnnotations: jest.fn(),
    +}));
    +
     describe('GroupsEditor', () => {
       /**
        * Default
    @@ -98,6 +105,7 @@ describe('GroupsEditor', () => {
       beforeEach(() => {
         jest.mocked(GroupEditor).mockImplementation(GroupEditorMock);
         jest.mocked(getAllDashboards).mockReturnValue(dashboardsMock);
    +    jest.mocked(useAnnotations).mockReturnValue([]);
       });
     
       it('Should render tables', async () => {
    
  • src/components/editors/GroupsEditor/GroupsEditor.tsx+7 1 modified
    @@ -6,6 +6,7 @@ import { Collapse } from '@volkovlabs/components';
     import React, { useCallback, useEffect, useMemo, useState } from 'react';
     
     import { TEST_IDS } from '@/constants';
    +import { useAnnotations } from '@/hooks';
     import { DashboardMeta, GroupConfig, PanelOptions } from '@/types';
     import { getAllDashboards, reorder } from '@/utils';
     
    @@ -51,14 +52,18 @@ export const GroupsEditor: React.FC<Props> = ({ context: { options, data }, onCh
       const [editName, setEditName] = useState('');
       const [dashboards, setDashboards] = useState<DashboardMeta[]>([]);
     
    +  /**
    +   * Annotations Layers
    +   */
    +  const annotationsLayers = useAnnotations();
    +
       /**
        * List of available dropdowns
        */
       const availableDropdowns = useMemo(() => {
         return options?.dropdowns?.map((dropdown) => dropdown.name);
       }, [options?.dropdowns]);
     
    -  /**
       /**
        * Change Items
        */
    @@ -313,6 +318,7 @@ export const GroupsEditor: React.FC<Props> = ({ context: { options, data }, onCh
                                 dashboards={dashboards}
                                 optionId={id}
                                 dropdowns={availableDropdowns}
    +                            annotationsLayers={annotationsLayers}
                                 onChange={(newGroup: GroupConfig) => {
                                   const updatedGroups = value.map((group) => {
                                     if (group.name === newGroup.name) {
    
  • src/components/LinksPanel/components/AnnotationElement/AnnotationElement.styles.ts+18 0 added
    @@ -0,0 +1,18 @@
    +import { css } from '@emotion/css';
    +import { GrafanaTheme2 } from '@grafana/data';
    +
    +/**
    + * Styles
    + */
    +export const getStyles = (theme: GrafanaTheme2, { dynamicFontSize }: { dynamicFontSize: boolean }) => {
    +  return {
    +    annotationItem: css`
    +      padding: ${theme.spacing(0.5)};
    +      gap: ${theme.spacing(1)};
    +      display: flex;
    +      align-items: center;
    +      justify-content: space-around;
    +      ${dynamicFontSize && `font-size: clamp(8px, calc(var(--btn-width) / 10), 14px);`}
    +    `,
    +  };
    +};
    
  • src/components/LinksPanel/components/AnnotationElement/AnnotationElement.test.tsx+115 0 added
    @@ -0,0 +1,115 @@
    +import { act, fireEvent, render, screen } from '@testing-library/react';
    +import { getJestSelectors } from '@volkovlabs/jest-selectors';
    +import React from 'react';
    +
    +import { TEST_IDS } from '@/constants';
    +import { VisualLinkType } from '@/types';
    +import { createVisualLinkConfig } from '@/utils';
    +
    +import { AnnotationElement } from './AnnotationElement';
    +
    +/**
    + * Props
    + */
    +type Props = React.ComponentProps<typeof AnnotationElement>;
    +
    +/**
    + * Element
    + */
    +describe('Annotation Element', () => {
    +  /**
    +   * Selectors
    +   */
    +  const getSelectors = getJestSelectors({ ...TEST_IDS.annotationElement });
    +
    +  const selectors = getSelectors(screen);
    +
    +  /**
    +   * Panel Options
    +   */
    +  const defaultVisualLink = createVisualLinkConfig({
    +    type: VisualLinkType.ANNOTATION,
    +    name: 'Picker',
    +    annotationLayer: undefined,
    +  });
    +
    +  /**
    +   * Get Tested Component
    +   */
    +  const getComponent = (props: Partial<Props>) => {
    +    return <AnnotationElement link={defaultVisualLink} {...(props as any)} />;
    +  };
    +
    +  afterEach(() => {
    +    jest.clearAllMocks();
    +  });
    +
    +  describe('Renders', () => {
    +    it('Should not render if no layer ', async () => {
    +      await act(async () => render(getComponent({})));
    +      expect(selectors.root(true)).not.toBeInTheDocument();
    +    });
    +
    +    it('Should render main elements ', async () => {
    +      const setState = jest.fn();
    +      const runLayer = jest.fn();
    +
    +      await act(async () =>
    +        render(
    +          getComponent({
    +            link: {
    +              ...defaultVisualLink,
    +              annotationLayer: {
    +                state: {
    +                  name: 'annotation-1',
    +                  key: 'annotation - 1',
    +                  isEnabled: true,
    +                  isHidden: false,
    +                },
    +                setState: setState,
    +                runLayer: runLayer,
    +              },
    +            },
    +            dynamicFontSize: true,
    +          })
    +        )
    +      );
    +      expect(selectors.root()).toBeInTheDocument();
    +      expect(selectors.label()).toBeInTheDocument();
    +      expect(selectors.enableField()).toBeInTheDocument();
    +    });
    +
    +    it('Should render call change state and run layer on change ', async () => {
    +      const setState = jest.fn();
    +      const runLayer = jest.fn();
    +
    +      await act(async () =>
    +        render(
    +          getComponent({
    +            link: {
    +              ...defaultVisualLink,
    +              annotationLayer: {
    +                state: {
    +                  name: 'annotation-1',
    +                  key: 'annotation - 1',
    +                  isEnabled: true,
    +                  isHidden: false,
    +                },
    +                setState: setState,
    +                runLayer: runLayer,
    +              },
    +            },
    +          })
    +        )
    +      );
    +      expect(selectors.root()).toBeInTheDocument();
    +      expect(selectors.label()).toBeInTheDocument();
    +      expect(selectors.enableField()).toBeInTheDocument();
    +
    +      fireEvent.click(selectors.enableField());
    +
    +      expect(setState).toHaveBeenCalled();
    +      expect(runLayer).toHaveBeenCalled();
    +    });
    +  });
    +});
    
  • src/components/LinksPanel/components/AnnotationElement/AnnotationElement.tsx+72 0 added
    @@ -0,0 +1,72 @@
    +import { InlineSwitch, Label, useStyles2 } from '@grafana/ui';
    +import React from 'react';
    +
    +import { TEST_IDS } from '@/constants';
    +import { VisualLink } from '@/types/links';
    +
    +import { getStyles } from './AnnotationElement.styles';
    +
    +/**
    + * Test Ids
    + */
    +const testIds = TEST_IDS.annotationElement;
    +
    +/**
    + * Properties
    + */
    +interface Props {
    +  /**
    +   * Link
    +   *
    +   * @type {VisualLink}
    +   */
    +  link: VisualLink;
    +
    +  /**
    +   * Dynamic font size
    +   *
    +   * @type {boolean}
    +   */
    +  dynamicFontSize?: boolean;
    +}
    +
    +/**
    + * Annotation Element
    + */
    +export const AnnotationElement: React.FC<Props> = ({ link, dynamicFontSize = false }) => {
    +  /**
    +   * Styles
    +   */
    +  const styles = useStyles2(getStyles, { dynamicFontSize });
    +
    +  /**
    +   * Return
    +   */
    +  return link.annotationLayer ? (
    +    <div {...testIds.root.apply()} className={styles.annotationItem}>
    +      <Label {...testIds.label.apply()}>{link.annotationLayer.state.name}</Label>
    +      <InlineSwitch
    +        label={link.annotationLayer.state.name}
    +        value={link.annotationLayer.state.isEnabled}
    +        onChange={() => {
    +          /**
    +           * Set state for annotation
    +           */
    +          link.annotationLayer?.setState({
    +            ...link.annotationLayer.state,
    +            isEnabled: !link.annotationLayer.state.isEnabled,
    +          });
    +
    +          /**
    +           * need to rerun the layer to update the query and
    +           * see the annotation on the panel
    +           */
    +          link.annotationLayer?.runLayer();
    +        }}
    +        {...testIds.enableField.apply()}
    +      />
    +    </div>
    +  ) : (
    +    <></>
    +  );
    +};
    
  • src/components/LinksPanel/components/AnnotationElement/index.ts+1 0 added
    @@ -0,0 +1 @@
    +export * from './AnnotationElement';
    
  • src/components/LinksPanel/components/index.ts+1 0 modified
    @@ -1,3 +1,4 @@
    +export * from './AnnotationElement';
     export * from './ContentElement';
     export * from './LinkElement';
     export * from './LinksGridLayout';
    
  • src/components/LinksPanel/components/LinksGridLayout/LinksGridLayout.test.tsx+26 1 modified
    @@ -13,6 +13,7 @@ import { LinkElement } from '../LinkElement';
     import { MenuElement } from '../MenuElement';
     import { TimePickerElement } from '../TimePickerElement';
     import { LinksGridLayout } from './LinksGridLayout';
    +import { AnnotationElement } from '../AnnotationElement';
     
     /**
      * Props
    @@ -39,6 +40,7 @@ const inTestIds = {
       timePickerElement: createSelector('data-testid time-picker-element'),
       contentElement: createSelector('data-testid content-element'),
       menuElement: createSelector('data-testid menu-element'),
    +  annotationElement: createSelector('data-testid annotation-element'),
       buttonLevelsUpdate: createSelector('data-testid button-levels-update'),
     };
     
    @@ -60,6 +62,15 @@ jest.mock('../TimePickerElement', () => ({
       TimePickerElement: jest.fn(),
     }));
     
    +/**
    + * Mock Annotation Element
    + */
    +const AnnotationElementMock = () => <div {...inTestIds.annotationElement.apply()} />;
    +
    +jest.mock('../AnnotationElement', () => ({
    +  AnnotationElement: jest.fn(),
    +}));
    +
     /**
      * Menu Element
      */
    @@ -132,6 +143,7 @@ describe('Grid layout', () => {
         jest.mocked(TimePickerElement).mockImplementation(TimePickerMock);
         jest.mocked(ContentElement).mockImplementation(ContentElementMock);
         jest.mocked(MenuElement).mockImplementation(MenuElementMock);
    +    jest.mocked(AnnotationElement).mockImplementation(AnnotationElementMock);
         jest.mocked(locationService.getLocation).mockReturnValue({
           search: '?panel=16',
         } as Location);
    @@ -177,12 +189,24 @@ describe('Grid layout', () => {
           type: VisualLinkType.LLMAPP,
         });
     
    +    const defaultAnnotationElement = createVisualLinkConfig({
    +      name: 'Link6',
    +      type: VisualLinkType.ANNOTATION,
    +    });
    +
         await act(async () =>
           render(
             getComponent({
               width: 400,
               height: 400,
    -          links: [defaultLink, defaultTimePickerLink, defaultContentElement, defaultMenuElement, defaultLLMElement],
    +          links: [
    +            defaultLink,
    +            defaultTimePickerLink,
    +            defaultContentElement,
    +            defaultMenuElement,
    +            defaultLLMElement,
    +            defaultAnnotationElement,
    +          ],
               options: options,
               onOptionsChange: onOptionsChange,
               activeGroup: activeGroup,
    @@ -195,6 +219,7 @@ describe('Grid layout', () => {
         expect(selectors.root()).toHaveStyle('height: 362px');
         expect(selectors.timePickerElement()).toBeInTheDocument();
         expect(selectors.menuElement()).toBeInTheDocument();
    +    expect(selectors.annotationElement()).toBeInTheDocument();
       });
     
       it('Should render Layout and calculate max height if no title', async () => {
    
  • src/components/LinksPanel/components/LinksGridLayout/LinksGridLayout.tsx+2 0 modified
    @@ -11,6 +11,7 @@ import ReactGridLayout from 'react-grid-layout';
     import { GRID_COLUMN_SIZE, GRID_MARGIN_GAP, GRID_ROW_SIZE, PANEL_TITLE_HEIGHT, TEST_IDS } from '@/constants';
     import { GroupConfig, PanelOptions, VisualLink, VisualLinkType } from '@/types';
     
    +import { AnnotationElement } from '../AnnotationElement';
     import { ContentElement } from '../ContentElement';
     import { LinkElement } from '../LinkElement';
     import { MenuElement } from '../MenuElement';
    @@ -275,6 +276,7 @@ export const LinksGridLayout: React.FC<Props> = ({
                         replaceVariables={replaceVariables}
                       />
                     )}
    +                {link.type === VisualLinkType.ANNOTATION && <AnnotationElement key={link.name} link={link} />}
                     {link.type === VisualLinkType.LLMAPP && (
                       <LinkElement
                         link={link}
    
  • src/components/LinksPanel/components/LinksLayout/LinksLayout.tsx+4 0 modified
    @@ -5,6 +5,7 @@ import React from 'react';
     import { TEST_IDS } from '@/constants';
     import { GroupConfig, VisualLink, VisualLinkType } from '@/types';
     
    +import { AnnotationElement } from '../AnnotationElement';
     import { ContentElement } from '../ContentElement';
     import { LinkElement } from '../LinkElement';
     import { MenuElement } from '../MenuElement';
    @@ -70,6 +71,9 @@ export const LinksLayout: React.FC<Props> = ({ activeGroup, panelData, replaceVa
                   <ContentElement key={link.name} link={link} panelData={panelData} replaceVariables={replaceVariables} />
                 );
               }
    +          if (link.type === VisualLinkType.ANNOTATION) {
    +            return <AnnotationElement key={link.name} link={link} />;
    +          }
               return <LinkElement key={link.name} link={link} replaceVariables={replaceVariables} />;
             })}
         </div>
    
  • src/components/LinksPanel/components/MenuElement/MenuElement.tsx+6 1 modified
    @@ -7,6 +7,7 @@ import { TEST_IDS } from '@/constants';
     import { ButtonSize, DropdownAlign, DropdownType, LinkType } from '@/types';
     import { VisualLink } from '@/types/links';
     
    +import { AnnotationElement } from '../AnnotationElement';
     import { LinkElement } from '../LinkElement';
     import { TimePickerElement } from '../TimePickerElement';
     import { getStyles } from './MenuElement.styles';
    @@ -66,7 +67,7 @@ export const MenuElement: React.FC<Props> = ({ link, gridMode = false, dynamicFo
          * Nested Links without TIMEPICKER type
          */
         const filteredLinks = link.links.filter((nestedLinks) => {
    -      return nestedLinks.linkType !== LinkType.TIMEPICKER;
    +      return nestedLinks.linkType !== LinkType.TIMEPICKER && nestedLinks.linkType !== LinkType.ANNOTATION;
         });
     
         return {
    @@ -116,6 +117,10 @@ export const MenuElement: React.FC<Props> = ({ link, gridMode = false, dynamicFo
                   );
                 }
     
    +            if (nestedLink.linkType === LinkType.ANNOTATION) {
    +              return <AnnotationElement key={nestedLink.name} link={nestedLink as unknown as VisualLink} />;
    +            }
    +
                 if (nestedLink.linkType === LinkType.LLMAPP) {
                   return (
                     <LinkElement
    
  • src/components/LinksPanel/LinksPanel.test.tsx+9 1 modified
    @@ -3,7 +3,7 @@ import { getJestSelectors } from '@volkovlabs/jest-selectors';
     import React from 'react';
     
     import { TEST_IDS } from '@/constants';
    -import { useSavedState } from '@/hooks';
    +import { useSavedState, useAnnotations } from '@/hooks';
     import { LinkType } from '@/types';
     import { createGroupConfig, createLinkConfig, createPanelOptions, getAllDashboards } from '@/utils';
     
    @@ -22,10 +22,17 @@ jest.mock('@/utils', () => ({
       getAllDashboards: jest.fn(),
     }));
     
    +/**
    + * Mock hooks
    + */
     jest.mock('../../hooks/useSavedState', () => ({
       useSavedState: jest.fn(jest.requireActual('../../hooks/useSavedState').useSavedState),
     }));
     
    +jest.mock('../../hooks/useAnnotations', () => ({
    +  useAnnotations: jest.fn(),
    +}));
    +
     /**
      * Panel
      */
    @@ -84,6 +91,7 @@ describe('LinksPanel', () => {
           return str;
         });
         jest.mocked(useSavedState).mockImplementation(jest.requireActual('../../hooks/useSavedState').useSavedState);
    +    jest.mocked(useAnnotations).mockReturnValue([]);
       });
     
       afterEach(() => {
    
  • src/components/LinksPanel/LinksPanel.tsx+12 5 modified
    @@ -4,7 +4,7 @@ import { Alert, ToolbarButton, ToolbarButtonRow, useStyles2 } from '@grafana/ui'
     import React, { useEffect, useMemo, useRef, useState } from 'react';
     
     import { TEST_IDS } from '@/constants';
    -import { useContentPosition, useGrafanaLocationService, useSavedState } from '@/hooks';
    +import { useAnnotations, useContentPosition, useGrafanaLocationService, useSavedState } from '@/hooks';
     import { DashboardMeta, PanelOptions } from '@/types';
     import { getAllDashboards, prepareLinksToRender } from '@/utils';
     
    @@ -40,6 +40,11 @@ export const LinksPanel: React.FC<Props> = ({
        */
       const styles = useStyles2(getStyles);
     
    +  /**
    +   * Current annotations Layers
    +   */
    +  const annotationsLayers = useAnnotations();
    +
       /**
        * Ref`s
        */
    @@ -91,16 +96,18 @@ export const LinksPanel: React.FC<Props> = ({
           highlightCurrentLink: activeGroup?.highlightCurrentLink,
           highlightCurrentTimepicker: activeGroup?.highlightCurrentTimepicker,
           series: data.series,
    +      annotationsLayers: annotationsLayers,
         });
       }, [
         activeGroup,
    -    dashboards,
    -    currentDashboardId,
    -    data.series,
         options.dropdowns,
         replaceVariables,
         timeRange,
    -    location,
    +    dashboards,
    +    location.search,
    +    currentDashboardId,
    +    data.series,
    +    annotationsLayers,
       ]);
     
       /**
    
  • src/constants.ts+6 0 modified
    @@ -53,6 +53,11 @@ export const TEST_IDS = {
       timePickerElement: {
         buttonPicker: createSelector((name: unknown) => `data-testid time-picker-element button-picker-${name}`),
       },
    +  annotationElement: {
    +    enableField: createSelector(`data-testid annotation-element enable-field`),
    +    label: createSelector(`data-testid annotation-element label`),
    +    root: createSelector(`data-testid annotation-element root`),
    +  },
       menuElement: {
         root: createSelector('data-testid menu-element'),
         link: createSelector((name: unknown) => `data-testid menu-element link-${name}`),
    @@ -76,6 +81,7 @@ export const TEST_IDS = {
         fieldIncludeVariables: createSelector('data-testid link-editor field-include-variables'),
         fieldIncludeKioskMode: createSelector('data-testid link-editor field-include-kiosk-mode'),
         fieldDashboard: createSelector('data-testid link-editor field-dashboard'),
    +    fieldAnnotationLayer: createSelector('data-testid link-editor field-annotation-layer'),
         fieldDropdown: createSelector('data-testid link-editor field-dropdown'),
         fieldHoverPosition: createSelector('data-testid link-editor field-hover-position'),
         fieldIcon: createSelector('data-testid link-editor field-icon'),
    
  • src/hooks/index.ts+1 0 modified
    @@ -1,3 +1,4 @@
    +export * from './useAnnotations';
     export * from './useContentPosition';
     export * from './useDrawerLlmChat';
     export * from './useElementResize';
    
  • src/hooks/useAnnotations.test.ts+47 0 added
    @@ -0,0 +1,47 @@
    +import { renderHook } from '@testing-library/react';
    +import { useAnnotations } from './useAnnotations';
    +import { sceneGraph } from '@grafana/scenes';
    +import type { AnnotationDataLayer } from '@/types';
    +
    +/**
    + * Mock sceneGraph.
    + */
    +jest.mock('@grafana/scenes', () => ({
    +  sceneGraph: {
    +    getDataLayers: jest.fn(),
    +  },
    +}));
    +
    +describe('useAnnotations', () => {
    +  /**
    +   * Before
    +   */
    +  beforeEach(() => {
    +    jest.clearAllMocks();
    +    (window as any).__grafanaSceneContext = { mock: true };
    +  });
    +
    +  it('Should return, if dataLayers is empty', () => {
    +    (sceneGraph.getDataLayers as jest.Mock).mockReturnValue([]);
    +
    +    const { result } = renderHook(() => useAnnotations());
    +
    +    expect(result.current).toEqual([]);
    +    expect(sceneGraph.getDataLayers).toHaveBeenCalledWith((window as any).__grafanaSceneContext);
    +  });
    +
    +  it('Should return annotationLayers from dataLayers', () => {
    +    const fakeAnnotationLayers = [{ id: 'a1' }, { id: 'a2' }];
    +    const fakeDataLayers: AnnotationDataLayer[] = [
    +      {
    +        state: { annotationLayers: fakeAnnotationLayers },
    +      } as unknown as AnnotationDataLayer,
    +    ];
    +
    +    (sceneGraph.getDataLayers as jest.Mock).mockReturnValue(fakeDataLayers);
    +
    +    const { result } = renderHook(() => useAnnotations());
    +
    +    expect(result.current).toEqual(fakeAnnotationLayers);
    +  });
    +});
    
  • src/hooks/useAnnotations.ts+23 0 added
    @@ -0,0 +1,23 @@
    +import { sceneGraph, SceneObject, SceneObjectState } from '@grafana/scenes';
    +import { useCallback, useMemo } from 'react';
    +
    +import { AnnotationDataLayer } from '@/types';
    +
    +/**
    + * useAnnotations hook
    + * retrieve annotations in scene dashboards
    + */
    +export const useAnnotations = () => {
    +  const sceneModel = window.__grafanaSceneContext as SceneObject<SceneObjectState>;
    +
    +  const getAnnotations = useCallback((): AnnotationDataLayer[] => {
    +    return sceneGraph.getDataLayers(sceneModel) as unknown as AnnotationDataLayer[];
    +  }, [sceneModel]);
    +
    +  const layers = useMemo(() => getAnnotations(), [getAnnotations]);
    +  const annotationsLayers = layers.flatMap((layer) => {
    +    return layer.state.annotationLayers;
    +  });
    +
    +  return annotationsLayers;
    +};
    
  • src/migration.test.ts+46 0 modified
    @@ -443,6 +443,52 @@ describe('migration', () => {
           expect(items[2].includeKioskMode).toEqual(true);
         });
     
    +    it('Should normalize annotationKey mode', async () => {
    +      const item1 = createLinkConfig({
    +        annotationKey: undefined,
    +      });
    +      const item2 = createLinkConfig({
    +        annotationKey: 'test',
    +      });
    +
    +      const item3 = createLinkConfig({
    +        annotationKey: '',
    +      });
    +
    +      const group = createGroupConfig({
    +        items: [item1, item2, item3],
    +      });
    +
    +      const result = await getMigratedOptions({ options: { groups: [group] } } as any);
    +      const items = result.groups[0].items;
    +      expect(items[0].annotationKey).toEqual('');
    +      expect(items[1].annotationKey).toEqual('test');
    +      expect(items[2].annotationKey).toEqual('');
    +    });
    +
    +    it('Should normalize annotationKey mode for dropdowns', async () => {
    +      const item1 = createLinkConfig({
    +        annotationKey: undefined,
    +      });
    +      const item2 = createLinkConfig({
    +        annotationKey: 'test',
    +      });
    +
    +      const item3 = createLinkConfig({
    +        annotationKey: '',
    +      });
    +
    +      const group = createGroupConfig({
    +        items: [item1, item2, item3],
    +      });
    +
    +      const result = await getMigratedOptions({ options: { dropdowns: [group] } } as any);
    +      const items = result.dropdowns[0].items;
    +      expect(items[0].annotationKey).toEqual('');
    +      expect(items[1].annotationKey).toEqual('test');
    +      expect(items[2].annotationKey).toEqual('');
    +    });
    +
         it('Should normalize showCustomIcons to false and customIconUrl to empty string', async () => {
           const item = createLinkConfig({ id: 'item-without-icons' });
           const group = createGroupConfig({ items: [item] });
    
  • src/migration.ts+4 0 modified
    @@ -47,6 +47,7 @@ export const getMigratedOptions = async (panel: PanelModel<OutdatedPanelOptions>
             const normalizedUseDefaultGrafanaMcp =
               !item.useDefaultGrafanaMcp || item.useDefaultGrafanaMcp === undefined ? false : item.useDefaultGrafanaMcp;
     
    +        const normalizedAnnotationKey = !item.annotationKey ? '' : item.annotationKey;
             const normalizedShowLoadingForRawMessage =
               item.showLoadingForRawMessage === undefined ? true : item.showLoadingForRawMessage;
     
    @@ -61,6 +62,7 @@ export const getMigratedOptions = async (panel: PanelModel<OutdatedPanelOptions>
               alignContentPosition: normalizedAlignContentPosition,
               hideTooltipOnHover: normalizedHideTitleOnHover,
               useDefaultGrafanaMcp: normalizedUseDefaultGrafanaMcp,
    +          annotationKey: normalizedAnnotationKey,
               showLoadingForRawMessage: normalizedShowLoadingForRawMessage,
             };
           });
    @@ -141,6 +143,7 @@ export const getMigratedOptions = async (panel: PanelModel<OutdatedPanelOptions>
     
             const normalizedTimePickerConfig = migrateTimePickerConfiguration(item.timePickerConfig);
     
    +        const normalizedAnnotationKey = !item.annotationKey ? '' : item.annotationKey;
             const normalizedIncludeKioskMode =
               !item.includeKioskMode || item.includeKioskMode === undefined ? false : item.includeKioskMode;
     
    @@ -163,6 +166,7 @@ export const getMigratedOptions = async (panel: PanelModel<OutdatedPanelOptions>
               ...item,
               id: normalizedId,
               timePickerConfig: normalizedTimePickerConfig,
    +          annotationKey: normalizedAnnotationKey,
               includeKioskMode: normalizedIncludeKioskMode,
               showCustomIcons: normalizedShowCustomIcons,
               customIconUrl: normalizedCustomIconUrl,
    
  • src/types/annotations.ts+51 0 added
    @@ -0,0 +1,51 @@
    +/**
    + * Annotation Layer
    + */
    +export interface AnnotationLayer {
    +  /**
    +   * State
    +   */
    +  state: {
    +    /**
    +     * name
    +     */
    +    name: string;
    +
    +    /**
    +     * key - dynamically changed
    +     */
    +    key: string;
    +
    +    /**
    +     * is annotation Enabled
    +     */
    +    isEnabled: boolean;
    +
    +    /**
    +     * is annotation hidden
    +     */
    +    isHidden: boolean;
    +  };
    +
    +  /**
    +   * Set current annotation state
    +   */
    +  setState: (state: unknown) => void;
    +
    +  /**
    +   * Re run state for annotations
    +   */
    +  runLayer: () => void;
    +}
    +
    +/**
    + * Annotation DataLayer
    + */
    +export interface AnnotationDataLayer {
    +  /**
    +   * State
    +   */
    +  state: {
    +    annotationLayers: AnnotationLayer[];
    +  };
    +}
    
  • src/@types/global.d.ts+11 0 added
    @@ -0,0 +1,11 @@
    +import { SceneObject } from '@grafana/scenes';
    +
    +/**
    + * __grafanaSceneContext contains Dashboard Scene Object if scene enabled
    + */
    +declare global {
    +  interface Window {
    +    // eslint-disable-next-line @typescript-eslint/naming-convention
    +    __grafanaSceneContext?: SceneObject;
    +  }
    +}
    
  • src/types/index.ts+1 0 modified
    @@ -1,3 +1,4 @@
    +export * from './annotations';
     export * from './dashboards';
     export * from './editor';
     export * from './links';
    
  • src/types/links.ts+9 0 modified
    @@ -1,5 +1,6 @@
     import { DropzoneFile, IconName } from '@grafana/ui';
     
    +import { AnnotationLayer } from './annotations';
     import { LlmMessage, LlmRole } from './llm-integrations';
     import { AlignContentPositionType, DropdownConfig, HoverMenuPositionType, LinkConfig, McpServerConfig } from './panel';
     
    @@ -16,6 +17,7 @@ export interface NestedLinkConfig extends LinkConfig {
      * Visual Link Type
      */
     export enum VisualLinkType {
    +  ANNOTATION = 'annotation',
       LINK = 'link',
       TIMEPICKER = 'timepicker',
       HTML = 'html',
    @@ -180,6 +182,13 @@ export interface VisualLink {
        * @type {McpServerConfig[]}
        */
       mcpServers?: McpServerConfig[];
    +
    +  /**
    +   * Annotation Layer
    +   *
    +   * @type {AnnotationLayer}
    +   */
    +  annotationLayer?: AnnotationLayer | null;
     }
     
     /**
    
  • src/types/panel.ts+8 0 modified
    @@ -21,6 +21,7 @@ export type RecursivePartial<T> = {
      * Link type
      */
     export enum LinkType {
    +  ANNOTATION = 'annotation',
       SINGLE = 'single',
       DROPDOWN = 'dropdown',
       TAGS = 'tags',
    @@ -267,6 +268,13 @@ export interface LinkConfig {
        */
       dashboardUrl: string;
     
    +  /**
    +   * Annotation key
    +   *
    +   * @type {string}
    +   */
    +  annotationKey: string;
    +
       /**
        * Icon
        *
    
  • src/utils/links.test.ts+156 0 modified
    @@ -180,6 +180,7 @@ describe('prepareLinksToRender', () => {
               dropdownName: '',
               id: 'test-link0-id',
               dropdownConfig: createDropdownConfig(),
    +          annotationKey: '',
             },
           ],
         };
    @@ -218,6 +219,7 @@ describe('prepareLinksToRender', () => {
               dropdownName: '',
               id: 'test-link0-id',
               dropdownConfig: createDropdownConfig(),
    +          annotationKey: '',
             },
           ],
         };
    @@ -258,6 +260,7 @@ describe('prepareLinksToRender', () => {
               dropdownName: '',
               id: 'test-link0-id',
               dropdownConfig: createDropdownConfig(),
    +          annotationKey: '',
             },
           ],
         };
    @@ -296,6 +299,7 @@ describe('prepareLinksToRender', () => {
                 dropdownName: '',
                 id: 'test-link0-dp-id',
                 dropdownConfig: createDropdownConfig(),
    +            annotationKey: '',
               },
             ],
           },
    @@ -318,6 +322,7 @@ describe('prepareLinksToRender', () => {
               dropdownName: 'Nested',
               id: 'test-link0-id',
               dropdownConfig: createDropdownConfig(),
    +          annotationKey: '',
             },
           ],
         };
    @@ -356,6 +361,7 @@ describe('prepareLinksToRender', () => {
                 dropdownName: '',
                 id: 'test-link0-dp-id',
                 dropdownConfig: createDropdownConfig(),
    +            annotationKey: '',
               },
               {
                 name: 'Link-2',
    @@ -371,6 +377,7 @@ describe('prepareLinksToRender', () => {
                 dropdownName: '',
                 id: 'test-link0-dp-id-2',
                 dropdownConfig: createDropdownConfig(),
    +            annotationKey: '',
               },
             ],
           },
    @@ -393,6 +400,7 @@ describe('prepareLinksToRender', () => {
               dropdownName: 'Nested',
               id: 'test-link0-id',
               dropdownConfig: createDropdownConfig(),
    +          annotationKey: '',
             },
           ],
         };
    @@ -431,6 +439,7 @@ describe('prepareLinksToRender', () => {
               dashboardUrl: '/d/urldashboard1',
               dropdownName: '',
               id: 'test-link0-id',
    +          annotationKey: '',
             },
           ],
         };
    @@ -467,6 +476,7 @@ describe('prepareLinksToRender', () => {
               dashboardUrl: '/d/urldashboard1',
               dropdownName: '',
               id: 'test-link0-id',
    +          annotationKey: '',
             },
           ],
         };
    @@ -503,6 +513,7 @@ describe('prepareLinksToRender', () => {
               dashboardUrl: '',
               dropdownName: '',
               id: 'test-link0-id',
    +          annotationKey: '',
             },
           ],
         };
    @@ -517,6 +528,7 @@ describe('prepareLinksToRender', () => {
           dashboardId: '',
           series: [],
           highlightCurrentTimepicker: true,
    +      annotationsLayers: [],
         });
     
         expect(result).toHaveLength(1);
    @@ -546,6 +558,7 @@ describe('prepareLinksToRender', () => {
               htmlConfig: {
                 content: 'line',
               },
    +          annotationKey: '',
             },
           ],
         };
    @@ -559,6 +572,7 @@ describe('prepareLinksToRender', () => {
           params: '',
           dashboardId: '',
           series: [],
    +      annotationsLayers: [],
         });
     
         expect(result).toHaveLength(1);
    @@ -571,6 +585,144 @@ describe('prepareLinksToRender', () => {
         });
       });
     
    +  it('Should generate ANNOTATION link correctly with empty key', () => {
    +    const currentGroup = {
    +      name: 'Test',
    +      items: [
    +        {
    +          name: 'ANNOTATION',
    +          enable: true,
    +          linkType: LinkType.ANNOTATION,
    +          url: '',
    +          tags: [],
    +          dashboardUrl: '',
    +          dropdownName: '',
    +          id: 'test-link0-id',
    +          annotationKey: '',
    +        },
    +      ],
    +    } as any;
    +
    +    const result = prepareLinksToRender({
    +      currentGroup,
    +      dropdowns: [],
    +      replaceVariables,
    +      timeRange,
    +      dashboards,
    +      params: '',
    +      dashboardId: '',
    +      series: [],
    +      annotationsLayers: [],
    +    });
    +
    +    expect(result).toHaveLength(1);
    +    expect(result[0]).toMatchObject({
    +      id: 'test-link0-id',
    +      links: [],
    +      name: 'ANNOTATION',
    +      type: VisualLinkType.ANNOTATION,
    +      annotationLayer: null,
    +    });
    +  });
    +
    +  it('Should generate ANNOTATION link correctly with empty layers', () => {
    +    const currentGroup = {
    +      name: 'Test',
    +      items: [
    +        {
    +          name: 'ANNOTATION',
    +          enable: true,
    +          linkType: LinkType.ANNOTATION,
    +          url: '',
    +          tags: [],
    +          dashboardUrl: '',
    +          dropdownName: '',
    +          id: 'test-link0-id',
    +          annotationKey: 'annotation-layer',
    +        },
    +      ],
    +    } as any;
    +
    +    const result = prepareLinksToRender({
    +      currentGroup,
    +      dropdowns: [],
    +      replaceVariables,
    +      timeRange,
    +      dashboards,
    +      params: '',
    +      dashboardId: '',
    +      series: [],
    +      annotationsLayers: [],
    +    });
    +
    +    expect(result).toHaveLength(1);
    +    expect(result[0]).toMatchObject({
    +      id: 'test-link0-id',
    +      links: [],
    +      name: 'ANNOTATION',
    +      type: VisualLinkType.ANNOTATION,
    +      annotationLayer: null,
    +    });
    +  });
    +
    +  it('Should generate ANNOTATION link correctly with layer', () => {
    +    const currentGroup = {
    +      name: 'Test',
    +      items: [
    +        {
    +          name: 'ANNOTATION',
    +          enable: true,
    +          linkType: LinkType.ANNOTATION,
    +          url: '',
    +          tags: [],
    +          dashboardUrl: '',
    +          dropdownName: '',
    +          id: 'test-link0-id',
    +          annotationKey: 'annotation-layer',
    +        },
    +      ],
    +    } as any;
    +
    +    const result = prepareLinksToRender({
    +      currentGroup,
    +      dropdowns: [],
    +      replaceVariables,
    +      timeRange,
    +      dashboards,
    +      params: '',
    +      dashboardId: '',
    +      series: [],
    +      annotationsLayers: [
    +        {
    +          state: {
    +            name: 'annotation-layer',
    +            key: 'annotation-layer-key-1',
    +          },
    +        },
    +        {
    +          state: {
    +            name: 'annotation-layer-2',
    +            key: 'annotation-layer-key-2',
    +          },
    +        },
    +      ] as any,
    +    });
    +
    +    expect(result).toHaveLength(1);
    +    expect(result[0]).toMatchObject({
    +      id: 'test-link0-id',
    +      links: [],
    +      name: 'ANNOTATION',
    +      type: VisualLinkType.ANNOTATION,
    +      annotationLayer: {
    +        state: {
    +          name: 'annotation-layer',
    +          key: 'annotation-layer-key-1',
    +        },
    +      },
    +    });
    +  });
    +
       it('Should generate TIMEPICKER link correctly with dashboard time range if raw is not a string', () => {
         const currentTimeRange = {
           from: new Date('2023-01-01T00:00:00Z'),
    @@ -597,6 +749,7 @@ describe('prepareLinksToRender', () => {
               dropdownName: '',
               id: 'test-link0-id',
               includeKioskMode: false,
    +          annotationKey: '',
             },
           ],
         };
    @@ -610,6 +763,7 @@ describe('prepareLinksToRender', () => {
           params: '',
           dashboardId: '',
           series: [],
    +      annotationsLayers: [],
         });
     
         expect(result).toHaveLength(1);
    @@ -643,6 +797,7 @@ describe('prepareLinksToRender', () => {
               dashboardUrl: '/d/urldashboard1',
               dropdownName: '',
               id: 'llm-1',
    +          annotationKey: '',
             },
           ],
         };
    @@ -656,6 +811,7 @@ describe('prepareLinksToRender', () => {
           params: '',
           dashboardId: '1',
           series: [],
    +      annotationsLayers: [],
         });
     
         expect(result).toHaveLength(1);
    
  • src/utils/links.ts+33 3 modified
    @@ -1,6 +1,6 @@
     import { DataFrame, dateTime, InterpolateFunction, TimeRange } from '@grafana/data';
     
    -import { DashboardMeta, GroupConfig, LinkConfig, LinkType, TimeConfig, TimeConfigType } from '@/types';
    +import { AnnotationLayer, DashboardMeta, GroupConfig, LinkConfig, LinkType, TimeConfig, TimeConfigType } from '@/types';
     import { VisualLink, VisualLinkType } from '@/types/links';
     
     import { filterDashboardsByTags } from './dashboards';
    @@ -189,6 +189,7 @@ export const prepareLinksToRender = ({
       highlightCurrentLink,
       highlightCurrentTimepicker,
       series,
    +  annotationsLayers = [],
     }: {
       currentGroup?: GroupConfig;
       dropdowns: GroupConfig[];
    @@ -200,6 +201,7 @@ export const prepareLinksToRender = ({
       highlightCurrentLink?: boolean;
       highlightCurrentTimepicker?: boolean;
       series: DataFrame[];
    +  annotationsLayers?: AnnotationLayer[];
     }): VisualLink[] => {
       /**
        * Return empty [] if no groups
    @@ -272,6 +274,29 @@ export const prepareLinksToRender = ({
             break;
           }
     
    +      /**
    +       * Annotation
    +       */
    +      case LinkType.ANNOTATION: {
    +        let currentAnnotationLayer = null;
    +
    +        if (annotationsLayers.length > 0) {
    +          const layer = annotationsLayers.find((annotationLayer) => annotationLayer.state.name === item.annotationKey);
    +          if (layer) {
    +            currentAnnotationLayer = layer;
    +          }
    +        }
    +
    +        result.push({
    +          type: VisualLinkType.ANNOTATION,
    +          id: item.id,
    +          name: item.name,
    +          annotationLayer: currentAnnotationLayer,
    +          links: [],
    +        });
    +        break;
    +      }
    +
           /**
            * HTML
            */
    @@ -393,14 +418,19 @@ export const prepareLinksToRender = ({
                 highlightCurrentLink,
                 highlightCurrentTimepicker,
                 series,
    +            annotationsLayers,
               });
             }
     
             const dropdownLinks = nestedLinks.flatMap((nestedLink) => {
    -          if (nestedLink.type === VisualLinkType.TIMEPICKER || nestedLink.type === VisualLinkType.LLMAPP) {
    +          if (
    +            nestedLink.type === VisualLinkType.TIMEPICKER ||
    +            nestedLink.type === VisualLinkType.LLMAPP ||
    +            nestedLink.type === VisualLinkType.ANNOTATION
    +          ) {
                 return [
                   {
    -                linkType: nestedLink.type === VisualLinkType.TIMEPICKER ? LinkType.TIMEPICKER : LinkType.LLMAPP,
    +                linkType: nestedLink.type,
                     ...nestedLink,
                   },
                 ] as unknown as LinkConfig[];
    
  • src/utils/test.ts+2 0 modified
    @@ -51,6 +51,7 @@ export const createLinkConfig = (item: Partial<LinkConfig> = {}): LinkConfig =>
       alignContentPosition: AlignContentPositionType.LEFT,
       hideTooltipOnHover: false,
       mcpServers: [],
    +  annotationKey: '',
       ...item,
     });
     
    @@ -106,6 +107,7 @@ export const createNestedLinkConfig = (item: Partial<NestedLinkConfig> = {}): Ne
       dropdownConfig: createDropdownConfig(),
       includeKioskMode: false,
       mcpServers: [],
    +  annotationKey: '',
       ...item,
     });
     
    
9d203a6950de

Add sanitize url check (#77)

https://github.com/volkovlabs/business-linksVitali PinchukAug 28, 2025via osv
4 files changed · +88 6
  • CHANGELOG.md+6 0 modified
    @@ -2,6 +2,12 @@
     
     All notable changes to the **Business Links** panel are documented in this file. This changelog follows the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
     
    +## [2.4.0] - Unreleased
    +
    +### Added
    +
    +- Added sanitize url check. ([#77](https://github.com/VolkovLabs/business-links/issues/77))
    +
     ## [2.3.0] - 2025-08-26
     
     We're excited to announce the release of version 2.3.0 of Business Links! This update brings new features, enhancements, and bug fixes to improve your experience with the plugin in Grafana. Below are the key changes in this release.
    
  • package.json+1 1 modified
    @@ -87,5 +87,5 @@
         "test:e2e:docker": "docker compose --profile e2e up --exit-code-from test",
         "upgrade": "npm upgrade --save"
       },
    -  "version": "2.3.0"
    +  "version": "2.4.0"
     }
    
  • src/components/editors/GroupsEditor/components/GroupEditor/components/LinkEditor/LinkEditor.test.tsx+41 0 modified
    @@ -154,6 +154,47 @@ describe('LinkEditor', () => {
         );
       });
     
    +  it('Should sanitize wrong url and show error and keep wrong value in input to show user', () => {
    +    render(getComponent({ optionId: 'groups' }));
    +
    +    expect(selectors.fieldUrl()).toBeInTheDocument();
    +    expect(selectors.fieldUrl()).toHaveValue('');
    +
    +    fireEvent.change(selectors.fieldUrl(), { target: { value: 'javascript:alert(document.domain)' } });
    +
    +    expect(selectors.fieldUrl()).toHaveValue('javascript:alert(document.domain)');
    +    expect(onChange).not.toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        url: '',
    +      })
    +    );
    +  });
    +
    +  it('Should reset error and allow to enter new value after sanitize', () => {
    +    render(getComponent({ optionId: 'groups' }));
    +
    +    expect(selectors.fieldUrl()).toBeInTheDocument();
    +    expect(selectors.fieldUrl()).toHaveValue('');
    +
    +    fireEvent.change(selectors.fieldUrl(), { target: { value: 'javascript:alert(document.domain)' } });
    +
    +    expect(selectors.fieldUrl()).toHaveValue('javascript:alert(document.domain)');
    +    expect(onChange).not.toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        url: '',
    +      })
    +    );
    +
    +    fireEvent.change(selectors.fieldUrl(), { target: { value: 'new url' } });
    +
    +    expect(selectors.fieldUrl()).toHaveValue('new url');
    +    expect(onChange).toHaveBeenCalledWith(
    +      expect.objectContaining({
    +        url: 'new url',
    +      })
    +    );
    +  });
    +
       it('Should allow change contextPrompt for Business AI link type', () => {
         render(getComponent({ optionId: 'groups', value: createLinkConfig({ linkType: LinkType.LLMAPP }) }));
     
    
  • src/components/editors/GroupsEditor/components/GroupEditor/components/LinkEditor/LinkEditor.tsx+40 5 modified
    @@ -1,4 +1,4 @@
    -import { DataFrame, IconName, SelectableValue } from '@grafana/data';
    +import { DataFrame, IconName, SelectableValue, textUtil } from '@grafana/data';
     import {
       getAvailableIcons,
       InlineField,
    @@ -10,7 +10,7 @@ import {
       TextArea,
     } from '@grafana/ui';
     import { Slider } from '@volkovlabs/components';
    -import React, { useMemo } from 'react';
    +import React, { useMemo, useState } from 'react';
     
     import { FieldsGroup } from '@/components';
     import { TEST_IDS } from '@/constants';
    @@ -261,6 +261,15 @@ export const alignContentPositionOptions = [
      * Link Editor
      */
     export const LinkEditor: React.FC<Props> = ({ value, onChange, isGrid, data, dashboards, optionId, dropdowns }) => {
    +  /**
    +   * Errors State
    +   */
    +  const [errors, setErrors] = useState({
    +    url: '',
    +  });
    +
    +  const [currentUrl, setCurrentUrl] = useState(value.url);
    +
       /**
        * Icon Options
        */
    @@ -416,14 +425,40 @@ export const LinkEditor: React.FC<Props> = ({ value, onChange, isGrid, data, das
             )}
     
             {value.linkType === LinkType.SINGLE && (
    -          <InlineField label="URL" grow={true} labelWidth={20}>
    +          <InlineField label="URL" grow={true} labelWidth={20} error={errors.url} invalid={!!errors.url}>
                 <Input
    -              value={value.url}
    +              value={currentUrl}
                   onChange={(event) => {
    +                let urlValue = event.currentTarget.value;
    +
    +                if (errors.url) {
    +                  setErrors((prev) => ({
    +                    ...prev,
    +                    url: '',
    +                  }));
    +                }
    +
    +                if (!!event.currentTarget.value) {
    +                  urlValue = textUtil.sanitizeUrl(event.currentTarget.value);
    +
    +                  if (event.currentTarget.value !== urlValue) {
    +                    setErrors((prev) => ({
    +                      ...prev,
    +                      url: 'Wrong text content',
    +                    }));
    +
    +                    setCurrentUrl(event.currentTarget.value);
    +
    +                    return;
    +                  }
    +                }
    +
                     onChange({
                       ...value,
    -                  url: event.currentTarget.value,
    +                  url: urlValue,
                     });
    +
    +                setCurrentUrl(urlValue);
                   }}
                   {...TEST_IDS.linkEditor.fieldUrl.apply()}
                 />
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

2

News mentions

0

No linked articles in our index yet.