All skills
nodnarbnitram avatar

/grafana-plugin-scaffolding

@1ac816c

Scaffold and automate Grafana plugin projects using @grafana/create-plugin. Use when creating panel plugins, data source plugins, app plugins, or backend plugins. Handles project scaffolding, Docker dev environment setup, and plugin configuration.

Use this Skill: https://skilld.dev/gh/nodnarbnitram/claude-code-extensions/grafana-plugin-scaffolding

This session only. Nothing lands on disk.

referencessdk-patterns.md

≈3.1k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Grafana Plugin SDK Patterns (v12.x+)

Common patterns for Grafana plugin development using the official SDKs.

Frontend SDK Patterns

Panel Plugin Component

import React from 'react';
import { PanelProps } from '@grafana/data';
import { useTheme2 } from '@grafana/ui';
import { SimpleOptions } from './types';

interface Props extends PanelProps<SimpleOptions> {}

export const SimplePanel: React.FC<Props> = ({ options, data, width, height, fieldConfig, replaceVariables }) => {
  const theme = useTheme2();

  // Access data frames
  const frame = data.series[0];
  if (!frame) {
    return <div>No data</div>;
  }

  // Access field values
  const timeField = frame.fields.find(f => f.type === 'time');
  const valueField = frame.fields.find(f => f.type === 'number');

  return (
    <div style={{ width, height, padding: theme.spacing(1) }}>
      {/* Your visualization */}
    </div>
  );
};

Panel Options Configuration

import { PanelPlugin } from '@grafana/data';
import { SimplePanel } from './SimplePanel';
import { SimpleOptions } from './types';

export const plugin = new PanelPlugin<SimpleOptions>(SimplePanel)
  .setPanelOptions(builder => {
    builder
      .addTextInput({
        path: 'text',
        name: 'Display text',
        description: 'Text to display',
        defaultValue: 'Hello',
      })
      .addBooleanSwitch({
        path: 'showLabels',
        name: 'Show labels',
        defaultValue: true,
      })
      .addSelect({
        path: 'displayMode',
        name: 'Display mode',
        defaultValue: 'default',
        settings: {
          options: [
            { value: 'default', label: 'Default' },
            { value: 'compact', label: 'Compact' },
            { value: 'expanded', label: 'Expanded' },
          ],
        },
      })
      .addColorPicker({
        path: 'color',
        name: 'Color',
        defaultValue: 'green',
      });
  });

Data Source Class

import {
  DataSourceApi,
  DataSourceInstanceSettings,
  DataQueryRequest,
  DataQueryResponse,
  MutableDataFrame,
  FieldType,
} from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { MyQuery, MyDataSourceOptions } from './types';

export class DataSource extends DataSourceApi<MyQuery, MyDataSourceOptions> {
  baseUrl: string;

  constructor(instanceSettings: DataSourceInstanceSettings<MyDataSourceOptions>) {
    super(instanceSettings);
    this.baseUrl = instanceSettings.url || '';
  }

  async query(options: DataQueryRequest<MyQuery>): Promise<DataQueryResponse> {
    const { range } = options;
    const from = range!.from.valueOf();
    const to = range!.to.valueOf();

    const promises = options.targets.map(async target => {
      const response = await getBackendSrv().fetch({
        url: `${this.baseUrl}/api/data`,
        method: 'POST',
        data: {
          query: target.queryText,
          from,
          to,
        },
      }).toPromise();

      return new MutableDataFrame({
        refId: target.refId,
        fields: [
          { name: 'Time', type: FieldType.time, values: response.data.times },
          { name: 'Value', type: FieldType.number, values: response.data.values },
        ],
      });
    });

    return { data: await Promise.all(promises) };
  }

  async testDatasource(): Promise<{ status: string; message: string }> {
    try {
      await getBackendSrv().fetch({
        url: `${this.baseUrl}/api/health`,
        method: 'GET',
      }).toPromise();

      return { status: 'success', message: 'Data source is working' };
    } catch (error) {
      return { status: 'error', message: `Connection failed: ${error}` };
    }
  }
}

Config Editor

import React, { ChangeEvent } from 'react';
import { InlineField, Input, SecretInput } from '@grafana/ui';
import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
import { MyDataSourceOptions, MySecureJsonData } from './types';

interface Props extends DataSourcePluginOptionsEditorProps<MyDataSourceOptions, MySecureJsonData> {}

export const ConfigEditor: React.FC<Props> = ({ options, onOptionsChange }) => {
  const { jsonData, secureJsonFields, secureJsonData } = options;

  const onPathChange = (event: ChangeEvent<HTMLInputElement>) => {
    onOptionsChange({
      ...options,
      jsonData: { ...jsonData, path: event.target.value },
    });
  };

  const onAPIKeyChange = (event: ChangeEvent<HTMLInputElement>) => {
    onOptionsChange({
      ...options,
      secureJsonData: { ...secureJsonData, apiKey: event.target.value },
    });
  };

  const onResetAPIKey = () => {
    onOptionsChange({
      ...options,
      secureJsonFields: { ...secureJsonFields, apiKey: false },
      secureJsonData: { ...secureJsonData, apiKey: '' },
    });
  };

  return (
    <>
      <InlineField label="Path" labelWidth={12}>
        <Input
          value={jsonData.path || ''}
          placeholder="/api/v1"
          width={40}
          onChange={onPathChange}
        />
      </InlineField>
      <InlineField label="API Key" labelWidth={12}>
        <SecretInput
          isConfigured={secureJsonFields.apiKey}
          value={secureJsonData?.apiKey || ''}
          placeholder="Your API key"
          width={40}
          onChange={onAPIKeyChange}
          onReset={onResetAPIKey}
        />
      </InlineField>
    </>
  );
};

Query Editor

import React, { ChangeEvent } from 'react';
import { InlineField, Input, Select } from '@grafana/ui';
import { QueryEditorProps, SelectableValue } from '@grafana/data';
import { DataSource } from './datasource';
import { MyDataSourceOptions, MyQuery } from './types';

type Props = QueryEditorProps<DataSource, MyQuery, MyDataSourceOptions>;

export const QueryEditor: React.FC<Props> = ({ query, onChange, onRunQuery }) => {
  const onQueryTextChange = (event: ChangeEvent<HTMLInputElement>) => {
    onChange({ ...query, queryText: event.target.value });
  };

  const onConstantChange = (event: ChangeEvent<HTMLInputElement>) => {
    onChange({ ...query, constant: parseFloat(event.target.value) });
    onRunQuery();
  };

  return (
    <>
      <InlineField label="Query Text" labelWidth={16} grow>
        <Input
          value={query.queryText || ''}
          onChange={onQueryTextChange}
          onBlur={onRunQuery}
        />
      </InlineField>
      <InlineField label="Constant" labelWidth={16}>
        <Input
          type="number"
          value={query.constant}
          onChange={onConstantChange}
          width={8}
        />
      </InlineField>
    </>
  );
};

Backend SDK Patterns (Go)

Data Source Implementation

package plugin

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"

    "github.com/grafana/grafana-plugin-sdk-go/backend"
    "github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
    "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
    "github.com/grafana/grafana-plugin-sdk-go/data"
)

type Datasource struct {
    settings   backend.DataSourceInstanceSettings
    httpClient *http.Client
}

func NewDatasource(ctx context.Context, settings backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
    opts, err := settings.HTTPClientOptions(ctx)
    if err != nil {
        return nil, fmt.Errorf("http client options: %w", err)
    }

    cl, err := httpclient.New(opts)
    if err != nil {
        return nil, fmt.Errorf("httpclient new: %w", err)
    }

    return &Datasource{
        settings:   settings,
        httpClient: cl,
    }, nil
}

func (d *Datasource) Dispose() {
    // Cleanup resources
}

func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
    response := backend.NewQueryDataResponse()

    for _, q := range req.Queries {
        res := d.query(ctx, req.PluginContext, q)
        response.Responses[q.RefID] = res
    }

    return response, nil
}

func (d *Datasource) query(ctx context.Context, pCtx backend.PluginContext, query backend.DataQuery) backend.DataResponse {
    var qm QueryModel
    if err := json.Unmarshal(query.JSON, &qm); err != nil {
        return backend.ErrDataResponse(backend.StatusBadRequest, fmt.Sprintf("json unmarshal: %v", err))
    }

    // Create data frame
    frame := data.NewFrame("response")
    frame.Fields = append(frame.Fields,
        data.NewField("time", nil, []time.Time{}),
        data.NewField("values", nil, []float64{}),
    )

    // Fetch and populate data...

    return backend.DataResponse{
        Frames: data.Frames{frame},
    }
}

func (d *Datasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
    resp, err := d.httpClient.Get(d.settings.URL + "/health")
    if err != nil {
        return &backend.CheckHealthResult{
            Status:  backend.HealthStatusError,
            Message: fmt.Sprintf("request error: %v", err),
        }, nil
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return &backend.CheckHealthResult{
            Status:  backend.HealthStatusError,
            Message: fmt.Sprintf("got status code %d", resp.StatusCode),
        }, nil
    }

    return &backend.CheckHealthResult{
        Status:  backend.HealthStatusOk,
        Message: "Data source is working",
    }, nil
}

Streaming Data

func (d *Datasource) SubscribeStream(ctx context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) {
    return &backend.SubscribeStreamResponse{
        Status: backend.SubscribeStreamStatusOK,
    }, nil
}

func (d *Datasource) RunStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender) error {
    ticker := time.NewTicker(time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case t := <-ticker.C:
            frame := data.NewFrame("stream")
            frame.Fields = append(frame.Fields,
                data.NewField("time", nil, []time.Time{t}),
                data.NewField("value", nil, []float64{rand.Float64()}),
            )

            if err := sender.SendFrame(frame, data.IncludeAll); err != nil {
                return err
            }
        }
    }
}

Resource Handler (Custom API)

func (d *Datasource) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
    switch req.Path {
    case "namespaces":
        return d.handleNamespaces(ctx, req, sender)
    case "metrics":
        return d.handleMetrics(ctx, req, sender)
    default:
        return sender.Send(&backend.CallResourceResponse{
            Status: http.StatusNotFound,
        })
    }
}

func (d *Datasource) handleNamespaces(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
    namespaces := []string{"ns-1", "ns-2", "ns-3"}
    body, _ := json.Marshal(map[string]interface{}{"namespaces": namespaces})

    return sender.Send(&backend.CallResourceResponse{
        Status: http.StatusOK,
        Headers: map[string][]string{
            "Content-Type": {"application/json"},
        },
        Body: body,
    })
}

Theming

import { useTheme2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';

// In component
const theme = useTheme2();

// Access colors
const backgroundColor = theme.colors.background.primary;
const textColor = theme.colors.text.primary;
const successColor = theme.colors.success.main;

// Visualization colors
const seriesColor = theme.visualization.getColorByName('green');

// Spacing
const padding = theme.spacing(2); // 16px

// Typography
const fontSize = theme.typography.fontSize;

Variable Support

import { getTemplateSrv } from '@grafana/runtime';

// Replace variables in query
const templateSrv = getTemplateSrv();
const resolvedQuery = templateSrv.replace(query.queryText, options.scopedVars);

// In panel
const { replaceVariables } = props;
const resolvedText = replaceVariables(options.text);

Source: SKILL.md on GitHub

1 warning6mo4 checks · Risk SAFE
  • Gen Agent Trust Hub7mo

    This skill provides a secure environment for scaffolding Grafana plugins using official developer tools. It utilizes industry-standard utilities like npx, npm, and Docker to automate project creation and local testing. All identified scripts follow best practices for safety, and no malicious patterns, exfiltration attempts, or unauthorized credential access were detected.

  • Socket6mo

    No alerts

  • Snyk7mo

    Risk: LOW · No issues

  • Runlayer7mo

    14/14 files flagged

Signed by skilld at 1ac816c. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Steadyupdated 6 months ago
What it can do
Runs commands Reads files Edits files
All 6 allowed tools
BashReadWriteEditGrepGlob

README badge

README badge for nodnarbnitram/claude-code-extensions/grafana-plugin-scaffolding