Пишем свой источник данных для Grafana

в 10:49, , рубрики: Grafana, визуализация данных, системное администрирование

Обычно, для отображения информации с веб-сервера данные загружают в систему мониторинга, а затем передают в Grafana. О том, как сделать это напрямую и о некоторых нюансах на пути к цели — под катом.

Пишем свой источник данных для Grafana - 1

Disclaimer
Из-за нежелания автора углубляться в изучение устаревшего AngularJS, используемого Grafana для интерфейса, и практически полного отсутствия документации по разработке плагинов, данная статья может содержать неверные высказывания, следы арахиса и других орехов.

Подготовка

Разработка плагинов для Grafana ведется на JavaScript (es6) или TypeScript и подразумевает использование Node.js совместно с каким либо сборщиком, напр. grunt.

Типичное содержимое папки проекта

/dist
   ...           // Дистрибутив плагина. Grafana использует только эту папку.
/src
   /img
      logo.svg   // иконка, в любом формате 
   /partials             // дополнительные шаблоны
      config.html        // настройки для подключения. Стандартный, http.
      query.editor.html  // отображение строк метрик при редактировании. Ключевой.
   datasource.js // Класс реализующий получение данных из источника
   module.js     // Точка входа в плагин
   plugin.json   // Мета-данные плагина
   query_ctrl.js // Класс, связывающий html-шаблоны и данные
   README.md     // Описание, отображаемое при просмотре деталей плагина в Grafana
gruntfile.js // Набор команд для сборщика
LICENSE.txt  // Лицензия
package.json // Мета-данные Node.js проекта, включающего зависимости
README.md    // Описание Node.js проекта 

Первым делом создаем папку проекта, куда добавляем файлы package.json, gruntfile.js и другие.

Примерное содержимое package.json

{
  "name": "имя-проекта",
  "version": "0.1.0",
  "description": "короткое-описание-проекта",
  "repository": {
    "type": "git",
    "url": "git+https://ссылка-github-репозитария"
  },
  "author": "ваше-имя",
  "license": "MIT",
  "devDependencies": {
    "babel": "~6.5.1",
    "grunt": "~0.4.5",
    "grunt-babel": "~6.0.0",
    "grunt-contrib-clean": "~0.6.0",
    "grunt-contrib-copy": "~0.8.2",
    "grunt-contrib-uglify": "~0.11.0",
    "grunt-contrib-watch": "^0.6.1",
    "grunt-execute": "~0.2.2",
    "grunt-sass": "^1.1.0",
    "grunt-systemjs-builder": "^0.2.5",
    "load-grunt-tasks": "~3.2.0",
    "babel-plugin-transform-es2015-for-of": "^6.6.0",
    "babel-plugin-transform-es2015-modules-systemjs": "^6.24.1",
    "babel-preset-es2015": "^6.24.1"
  },
  "dependencies": {},
  "homepage": "https://домашняя-страница-проекта"
}

Примерное содержимое gruntfile.js
module.exports = function(grunt) {

  require('load-grunt-tasks')(grunt);

  grunt.loadNpmTasks('grunt-execute');
  grunt.loadNpmTasks('grunt-contrib-clean');
  grunt.loadNpmTasks('grunt-build-number');

  grunt.initConfig({

    clean: ["dist"],

    copy: {
      src_to_dist: {
        cwd: 'src',
        expand: true,
        src: [
          '**/*',
          '!*.js',
          '!module.js',
          '!**/*.scss'
        ],
        dest: 'dist/'
      },
      pluginDef: {
        expand: true,
        src: ['plugin.json'],
        dest: 'dist/',
      }
    },

    watch: {
      rebuild_all: {
        files: ['src/**/*', 'plugin.json'],
        tasks: ['default'],
        options: {spawn: false}
      },
    },

    babel: {
      options: {
        sourceMap: true,
        presets:  ["es2015"],
        plugins: ['transform-es2015-modules-systemjs', "transform-es2015-for-of"],
      },
      dist: {
        files: [{
          cwd: 'src',
          expand: true,
          src: [
            '*.js',
            'module.js',
          ],
          dest: 'dist/'
        }]
      },
    },

    sass: {
      options: {
        sourceMap: true
      },
      dist: {
        files: {
          
        }
      }
    }

  });

  grunt.registerTask('default', [
    'clean',
    'copy:src_to_dist',
    'copy:pluginDef',
    'babel',
    'sass'
  ]);
}

После того, как package.json создан, можно установить все необходимые для разработки зависимости и сборщик, выполнив в папке проекта

npm install --only=dev
npm install grunt -g

В результате будет создана папка node_modules, содержащая примерно 50мб вспомогательных файлов, и станет доступна команда grunt для сборки дистрибутива в папку dist.

Далее создаем создаем папку src с необходимой структурой. В файле plugin.json задаем id проекта как автор-источник-datasource, а также какую информацию он будет предоставлять, задавая значения переменных metrics, alerting и annotations. Подробнее о plugin.jsonздесь.

Примерное содержимое plugin.json

{
  "name": "Имя-источника",
  "id": "Уникальный-идентификатор-плагина",
  "type": "datasource",

  "metrics": true,
  "alerting": false,
  "annotations": false,

  "info": {
    "description": "Краткое-описание",
    "author": {
      "name": "Ваше-имя",		
      "url": "Ваш-сайт"
    },
    "logos": {
      "small": "img/logo.svg",
      "large": "img/logo.svg"
    },
    "links": [
      {
        "name": "GitHub",
        "url": "https://ссылка-github-репозитария"
      },
      {
        "name": "Лицензия",
        "url": "https://ссылка-на-файл-лицензии"
      }
    ],
    "version": "0.1.0",
    "updated": "2018-05-10"
  },

  "dependencies": {
    "grafanaVersion": "5.x",
    "plugins": []
  }
}

Стоит отметить, что плагин может реализовывать не только источник данных или новый тип панели, но и группу решений. В этом случае plugin.json будет иметь отличную структуру.

html элементы

В папку /src/partials добавляем файл config.html, содержащий блок, отображаемый при подключении к источнику. Обычно стандартного для http — достаточно.

Содержимое config.html

<datasource-http-settings current="ctrl.current"></datasource-http-settings>

В некоторых плагинах можно встретить query.options.html, содержащий настройки для метрик. С версии 4.5 данные настройки считываются из plugin.json.

Следующий файл — query.editor.html реализует как будут задаваться метрики (строки в интерфейсе). Обычно в них используются выпадающие списки, а не просто поле ввода. Для Angular элемент со списком, связываемый с переменной ctrl.target.myprop, выглядит так

<select
   ng-model="ctrl.target.myprop"
   ng-options="v.value as v.name for v in ctrl.myprops">
</select>

В случае, если список значений, содержащийся в ctrl.myprops, должен быть загружен асинхронно, то необходимо будет создать контроллер. В Grafana уже имеется компонент с нужной реализацией.

<gf-form-dropdown 
  model="ctrl.target.myprop"
  class = "max-width-12"
  lookup-text="true"
  allow-custom = "false"
  get-options = "ctrl.getMyProps()"
  on-change = "ctrl.panelCtrl.refresh()"
>
</gf-form-dropdown>

ctrl — это объект класса, реализуемого в query_ctrl.js, связанный с текущей метрикой.
ctrl.target содержит свойства метрики, которые будут отправлены на источник в запросе.
ctrl.panelCtrl.refresh() заставляет панель запросить данные заново.
lookup-text задает доступна ли для поля подсказка выпадающим списком.
allow-custom задает допустимо выбирать элементы не из выпадающего списка.
get-options метод для получения элементов выпадающего списка. При этом результат метода, возвращаемый как значение или promise, должен быть массивом элементов вида {text: "текст", value: "значение"}.
Обратите внимание, что model, get-options и on-change отличаются от исходных ng-model, ng-options и ng-change.

Помимо gf-form-dropdown еще есть metric-segment-model. Его использование можно увидеть здесь. Документации на компоненты — нет, поэтому их список и возможности можно узнать только изучая исходники.

Возможное содержимое query.editor.html

<query-editor-row query-ctrl="ctrl" class="mydatasource-datasource-query-row">
	<div class="gf-form-inline">
		<div class="gf-form max-width-12">
			<gf-form-dropdown 
				model="ctrl.target.myprop"
				class = "max-width-12"
				lookup-text="true"
				custom = "false"
				get-options="ctrl.getMyProps()"
				on-change = "ctrl.updateMyParams()"
				>
			</gf-form-dropdown>
		</div>

		<div class="gf-form" ng-if = "ctrl.panel.type == 'graph'">
			<label class="gf-form-label width-5">Name</label>			
			<input type="text"
				ng-model="ctrl.target.label"
				class="gf-form-input width-12"
				spellcheck="false"
			>
		</div>

		<div class="gf-form" ng-if = "ctrl.target.myparams.length > 0">
			<label class="gf-form-label width-5">Params</label>
			
			<input type="text"
				ng-repeat = "param in ctrl.target.myparams"
				ng-model="ctrl.target.myparams[param]"
				class="gf-form-input width-12"
				spellcheck="false"
				placeholder = "{{param}}"
				ng-change = "ctrl.panelCtrl.refresh();"			
			>
		</div>

		<div class="gf-form gf-form--grow">
			<div class="gf-form-label gf-form-label--grow"></div>
		</div>
	</div>
</query-editor-row>

Отмечу, что:

1. Последний элемент с классом gf-form--grow нужен для заливки незанятой части строки фоном.

2. Вы можете добавлять/скрывать элементы в строке метрики в зависимости от типа панели посредством условного отображения ng-if = "ctrl.panel.type == 'graph'".

Написание кода

Файлы module.js и query_ctrl.js достаточно просты, и могут быть написаны по аналогии с другими источниками данных, напр. Simple Json. Основная логика располагается в datasource.js.

Класс, описываемый в этом модуле, должен реализовывать как минимум два метода testDatasource() и query(options). Первый используется для тестирования соединения с источником при его регистрации (кнопка «Save and Test»), второй вызывается каждый раз, когда панель запрашивает данные. Остановлюсь на нем подробнее.

Пример options, передаваемого в метод query

{
   "timezone":"browser",
   "panelId":6,
   "dashboardId":1,
   "range":{
      "from":"2018-05-10T23:30:42.318Z",
      "to":"2018-05-10T23:47:11.566Z",
      "raw":{
         "from":"2018-05-10T23:30:42.318Z",
         "to":"2018-05-10T23:47:11.566Z"
      }
   },
   "rangeRaw":{
      "from":"2018-05-10T23:30:42.318Z",
      "to":"2018-05-10T23:47:11.566Z"
   },
   "interval":"2s",
   "intervalMs":2000,
   "targets":[
      {
         "myprop":"value1",
         "myparams":{
            "column":"val",
            "table":"t"
         },
         "refId":"A",
         "$$hashKey":"object:174"
      },
      {
         "refId":"B",
         "$$hashKey":"object:185",
         "myprop":"value2",
         "myparams":{
            "column":"val2",
            "table":"t2"
         },
         "datatype":"table"
      }
   ],
   "maxDataPoints":320,
   "scopedVars":{
      "__interval":{
         "text":"2s",
         "value":"2s"
      },
      "__interval_ms":{
         "text":2000,
         "value":2000
      }
   }
}

Из приведенного примера легко видеть, что данные для всех метрик запрашиваются одновременно. Основные поля — range, содержащее период за который требуется информация, и targets — список метрик, каждой из которой соответствует свойство target у объекта класса, определяемого в query_ctrl.

Список targets необходимо отфильтровать по свойству hide, чтобы не запрашивать результаты «скрытых» метрик, а так же удалить заведомо «неправильные» метрики, например с неопределенными параметрами. Затем по полученному списку запрашиваются данные для каждой метрики и полученное необходимо преобразовать в формат поддерживаемый Grafana.

Для одной метрики ответом может быть несколько результатов, напр. несколько графиков. Их можно складывать в общий массив, который потом пойдет в итоговый набор для отображения, где уже не важно для какой метрики был получен тот или иной результат.

Формат данных, отдаваемый query, для разных типов панелей различен, так если данные запрошены для графика, то результат требуется преобразовать к виду {target: Имя-линии, datapoints: массив-точек-[значение, время]}, а для таблицы, то {columns: массив-вида-{text: Имя-колонки, type: Тип-данных-колонки}, rows: массив значений}.

В Simple Json выбор формата предлагается решать дополнительным атрибутом метрики, что не очень хорошо.

Пишем свой источник данных для Grafana - 2

Поскольку можно делать это автоматически, добавив в target объекта атрибут type на основе this.panel.type и преобразовывать результат исходя из него. Несколько странно, что в options тип панели не передается.

Результатом метода query должен быть promise, возвращающий {data: массив-ответов}.

Для запроса данных используется метод backendSrv.datasourceRequest(options), который в зависимости от типа выбранного источника данных либо перенаправляет данные в Grafana или же выполняет запрос непосредственно браузером.

Пишем свой источник данных для Grafana - 3

В случае браузера опрашиваемый веб-сервер должен поддерживать CORS.

Если для получения результата для всех метрик необходимо выполнить несколько запросов к источнику, то можно воспользоваться Promise.all

var requests = this.targets.map((target) => ... );
var scope = requests.map((req) => this.backendSrv.datasourceRequest(req));

return Promise.all(scope).then(function (results) {
	// results преобразуем в data, согласно нужному типу
	...

	return Promise.resolve({data});
})

Для того, чтобы источник данных поддерживал переменные, нужно реализовать метод metricFindQuery(options), возвращающий массив (возможно через promise) с элементами вида {text: "текст", value: "значение"}. Кроме того, в query потребуется перебрать options.targets и для каждого элемента этого массива для всех его свойств, где может быть подставлена переменная, выполнить преобразование

target.myprop = this.templateSrv.replace(target.myprop, options.scopedVars, 'regex');

Для аннотаций требуется реализация annotationQuery(options).

Установка и публикация

Для установки достаточно скопировать плагин в папку %GRAFANA_PATH%/data/plugins для Windows или /var/lib/grafana/plugins для остальных систем и перезапустить Grafana.

Если вы хотите, чтобы ваш плагин добавили в список доступных, то надо сделать pull request в репозитарий плагинов или обратиться к разработчикам посредством форума.

Ссылки

Автор: little-brother

Источник


* - обязательные к заполнению поля