Commit fe89f48c authored by syasonik's avatar syasonik

Support default for unspecified grafana panel id

When embedding Grafana charts in GitLab Flavored Markdown, a user
may forget to include a panelId in the url if they have misunderstood
Grafana's UI. This change defaults the panel to the first valid option
on the dashboard.
parent e7935f19
...@@ -138,7 +138,9 @@ module Metrics ...@@ -138,7 +138,9 @@ module Metrics
end end
# Identifies the name of the datasource for a dashboard # Identifies the name of the datasource for a dashboard
# based on the panelId query parameter found in the url # based on the panelId query parameter found in the url.
#
# If no panel is specified, defaults to the first valid panel.
class DatasourceNameParser class DatasourceNameParser
def initialize(grafana_url, grafana_dashboard) def initialize(grafana_url, grafana_dashboard)
@grafana_url, @grafana_dashboard = grafana_url, grafana_dashboard @grafana_url, @grafana_dashboard = grafana_url, grafana_dashboard
...@@ -146,15 +148,29 @@ module Metrics ...@@ -146,15 +148,29 @@ module Metrics
def parse def parse
@grafana_dashboard[:dashboard][:panels] @grafana_dashboard[:dashboard][:panels]
.find { |panel| panel[:id].to_s == query_params[:panelId] } .find { |panel| panel_id ? matching_panel?(panel) : valid_panel?(panel) }
.try(:[], :datasource) .try(:[], :datasource)
end end
private private
def panel_id
query_params[:panelId]
end
def query_params def query_params
Gitlab::Metrics::Dashboard::Url.parse_query(@grafana_url) Gitlab::Metrics::Dashboard::Url.parse_query(@grafana_url)
end end
def matching_panel?(panel)
panel[:id].to_s == panel_id
end
def valid_panel?(panel)
::Grafana::Validator
.new(@grafana_dashboard, nil, panel, query_params)
.valid?
end
end end
end end
end end
---
title: Default to first valid panel in unspecified Grafana embeds
merge_request: 21932
author:
type: changed
...@@ -820,7 +820,7 @@ Prerequisites for embedding from a Grafana instance: ...@@ -820,7 +820,7 @@ Prerequisites for embedding from a Grafana instance:
![Grafana Metric Panel](img/grafana_panel_v12_5.png) ![Grafana Metric Panel](img/grafana_panel_v12_5.png)
1. In the upper-left corner of the page, select a specific value for each variable required for the queries in the chart. 1. In the upper-left corner of the page, select a specific value for each variable required for the queries in the chart.
![Select Query Variables](img/select_query_variables_v12_5.png) ![Select Query Variables](img/select_query_variables_v12_5.png)
1. In Grafana, click on a panel's title, then click **Share** to open the panel's sharing dialog to the **Link** tab. 1. In Grafana, click on a panel's title, then click **Share** to open the panel's sharing dialog to the **Link** tab. If you click the _dashboard's_ share panel instead, GitLab will attempt to embed the first supported panel on the dashboard (if available).
1. If your Prometheus queries use Grafana's custom template variables, ensure that "Template variables" option is toggled to **On**. Of Grafana global template variables, only `$__interval`, `$__from`, and `$__to` are currently supported. Toggle **On** the "Current time range" option to specify the time range of the chart. Otherwise, the default range will be the last 8 hours. 1. If your Prometheus queries use Grafana's custom template variables, ensure that "Template variables" option is toggled to **On**. Of Grafana global template variables, only `$__interval`, `$__from`, and `$__to` are currently supported. Toggle **On** the "Current time range" option to specify the time range of the chart. Otherwise, the default range will be the last 8 hours.
![Grafana Sharing Dialog](img/grafana_sharing_dialog_v12_5.png) ![Grafana Sharing Dialog](img/grafana_sharing_dialog_v12_5.png)
1. Click **Copy** to copy the URL to the clipboard. 1. Click **Copy** to copy the URL to the clipboard.
......
...@@ -17,8 +17,6 @@ module Banzai ...@@ -17,8 +17,6 @@ module Banzai
def embed_params(node) def embed_params(node)
query_params = Gitlab::Metrics::Dashboard::Url.parse_query(node['href']) query_params = Gitlab::Metrics::Dashboard::Url.parse_query(node['href'])
return unless query_params.include?(:panelId)
time_window = Grafana::TimeWindow.new(query_params[:from], query_params[:to]) time_window = Grafana::TimeWindow.new(query_params[:from], query_params[:to])
url = url_with_window(node['href'], query_params, time_window.in_milliseconds) url = url_with_window(node['href'], query_params, time_window.in_milliseconds)
......
...@@ -13,12 +13,7 @@ module Gitlab ...@@ -13,12 +13,7 @@ module Gitlab
# Reformats the specified panel in the Gitlab # Reformats the specified panel in the Gitlab
# dashboard-yml format # dashboard-yml format
def transform! def transform!
InputFormatValidator.new( validate_input!
grafana_dashboard,
datasource,
panel,
query_params
).validate!
new_dashboard = formatted_dashboard new_dashboard = formatted_dashboard
...@@ -28,6 +23,17 @@ module Gitlab ...@@ -28,6 +23,17 @@ module Gitlab
private private
def validate_input!
::Grafana::Validator.new(
grafana_dashboard,
datasource,
panel,
query_params
).validate!
rescue ::Grafana::Validator::Error => e
raise ::Gitlab::Metrics::Dashboard::Errors::DashboardProcessingError, e.message
end
def formatted_dashboard def formatted_dashboard
{ panel_groups: [{ panels: [formatted_panel] }] } { panel_groups: [{ panels: [formatted_panel] }] }
end end
...@@ -56,11 +62,25 @@ module Gitlab ...@@ -56,11 +62,25 @@ module Gitlab
def panel def panel
strong_memoize(:panel) do strong_memoize(:panel) do
grafana_dashboard[:dashboard][:panels].find do |panel| grafana_dashboard[:dashboard][:panels].find do |panel|
panel[:id].to_s == query_params[:panelId] query_params[:panelId] ? matching_panel?(panel) : valid_panel?(panel)
end end
end end
end end
# Determines whether a given panel is the one
# specified by the linked grafana url
def matching_panel?(panel)
panel[:id].to_s == query_params[:panelId]
end
# Determines whether any given panel has the potenial
# to return valid results from grafana/prometheus
def valid_panel?(panel)
::Grafana::Validator
.new(grafana_dashboard, datasource, panel, query_params)
.valid?
end
# Grafana url query parameters. Includes information # Grafana url query parameters. Includes information
# on which panel to select and time range. # on which panel to select and time range.
def query_params def query_params
...@@ -141,83 +161,6 @@ module Gitlab ...@@ -141,83 +161,6 @@ module Gitlab
params[:grafana_url] params[:grafana_url]
end end
end end
class InputFormatValidator
include ::Gitlab::Metrics::Dashboard::Errors
attr_reader :grafana_dashboard, :datasource, :panel, :query_params
UNSUPPORTED_GRAFANA_GLOBAL_VARS = %w(
$__interval_ms
$__timeFilter
$__name
$timeFilter
$interval
).freeze
def initialize(grafana_dashboard, datasource, panel, query_params)
@grafana_dashboard = grafana_dashboard
@datasource = datasource
@panel = panel
@query_params = query_params
end
def validate!
validate_query_params!
validate_datasource!
validate_panel_type!
validate_variable_definitions!
validate_global_variables!
end
private
def validate_datasource!
return if datasource[:access] == 'proxy' && datasource[:type] == 'prometheus'
raise_error 'Only Prometheus datasources with proxy access in Grafana are supported.'
end
def validate_query_params!
return if [:panelId, :from, :to].all? { |param| query_params.include?(param) }
raise_error 'Grafana query parameters must include panelId, from, and to.'
end
def validate_panel_type!
return if panel[:type] == 'graph' && panel[:lines]
raise_error 'Panel type must be a line graph.'
end
def validate_variable_definitions!
return unless grafana_dashboard[:dashboard][:templating]
return if grafana_dashboard[:dashboard][:templating][:list].all? do |variable|
query_params[:"var-#{variable[:name]}"].present?
end
raise_error 'All Grafana variables must be defined in the query parameters.'
end
def validate_global_variables!
return unless panel_contains_unsupported_vars?
raise_error 'Prometheus must not include'
end
def panel_contains_unsupported_vars?
panel[:targets].any? do |target|
UNSUPPORTED_GRAFANA_GLOBAL_VARS.any? do |variable|
target[:expr].include?(variable)
end
end
end
def raise_error(message)
raise DashboardProcessingError.new(message)
end
end
end end
end end
end end
......
# frozen_string_literal: true
# Performs checks on whether resources from Grafana can be handled
# We have certain restrictions on which formats we accept.
# Some are technical requirements, others are simplifications.
module Grafana
class Validator
Error = Class.new(StandardError)
attr_reader :grafana_dashboard, :datasource, :panel, :query_params
UNSUPPORTED_GRAFANA_GLOBAL_VARS = %w(
$__interval_ms
$__timeFilter
$__name
$timeFilter
$interval
).freeze
def initialize(grafana_dashboard, datasource, panel, query_params)
@grafana_dashboard = grafana_dashboard
@datasource = datasource
@panel = panel
@query_params = query_params
end
def validate!
validate_query_params!
validate_panel_type!
validate_variable_definitions!
validate_global_variables!
validate_datasource! if datasource
end
def valid?
validate!
true
rescue ::Grafana::Validator::Error
false
end
private
# See defaults in Banzai::Filter::InlineGrafanaMetricsFilter.
def validate_query_params!
return if [:from, :to].all? { |param| query_params.include?(param) }
raise_error 'Grafana query parameters must include from and to.'
end
# We may choose to support other panel types in future.
def validate_panel_type!
return if panel && panel[:type] == 'graph' && panel[:lines]
raise_error 'Panel type must be a line graph.'
end
# We must require variable definitions to create valid prometheus queries.
def validate_variable_definitions!
return unless grafana_dashboard[:dashboard][:templating]
return if grafana_dashboard[:dashboard][:templating][:list].all? do |variable|
query_params[:"var-#{variable[:name]}"].present?
end
raise_error 'All Grafana variables must be defined in the query parameters.'
end
# We may choose to support further Grafana variables in future.
def validate_global_variables!
return unless panel_contains_unsupported_vars?
raise_error "Prometheus must not include #{UNSUPPORTED_GRAFANA_GLOBAL_VARS}"
end
# We may choose to support additional datasources in future.
def validate_datasource!
return if datasource[:access] == 'proxy' && datasource[:type] == 'prometheus'
raise_error 'Only Prometheus datasources with proxy access in Grafana are supported.'
end
def panel_contains_unsupported_vars?
panel[:targets].any? do |target|
UNSUPPORTED_GRAFANA_GLOBAL_VARS.any? do |variable|
target[:expr].include?(variable)
end
end
end
def raise_error(message)
raise Validator::Error, message
end
end
end
...@@ -46,11 +46,9 @@ describe Banzai::Filter::InlineGrafanaMetricsFilter do ...@@ -46,11 +46,9 @@ describe Banzai::Filter::InlineGrafanaMetricsFilter do
end end
context 'when "panelId" parameter is missing' do context 'when "panelId" parameter is missing' do
let(:dashboard_path) { '/d/XDaNK6amz/gitlab-omnibus-redis' } let(:dashboard_path) { '/d/XDaNK6amz/gitlab-omnibus-redis?from=1570397739557&to=1570484139557' }
it 'leaves the markdown unchanged' do it_behaves_like 'a metrics embed filter'
expect(unescape(doc.to_s)).to eq(input)
end
end end
context 'when time window parameters are missing' do context 'when time window parameters are missing' do
...@@ -86,6 +84,14 @@ describe Banzai::Filter::InlineGrafanaMetricsFilter do ...@@ -86,6 +84,14 @@ describe Banzai::Filter::InlineGrafanaMetricsFilter do
end end
end end
context 'when no parameters are provided' do
let(:dashboard_path) { '/d/XDaNK6amz/gitlab-omnibus-redis' }
it 'inserts a placeholder' do
expect(embed_url).to be_present
end
end
private private
# Nokogiri escapes the URLs, but we don't care about that # Nokogiri escapes the URLs, but we don't care about that
......
...@@ -11,8 +11,9 @@ describe Gitlab::Metrics::Dashboard::Stages::GrafanaFormatter do ...@@ -11,8 +11,9 @@ describe Gitlab::Metrics::Dashboard::Stages::GrafanaFormatter do
describe '#transform!' do describe '#transform!' do
let(:grafana_dashboard) { JSON.parse(fixture_file('grafana/simplified_dashboard_response.json'), symbolize_names: true) } let(:grafana_dashboard) { JSON.parse(fixture_file('grafana/simplified_dashboard_response.json'), symbolize_names: true) }
let(:datasource) { JSON.parse(fixture_file('grafana/datasource_response.json'), symbolize_names: true) } let(:datasource) { JSON.parse(fixture_file('grafana/datasource_response.json'), symbolize_names: true) }
let(:expected_dashboard) { JSON.parse(fixture_file('grafana/expected_grafana_embed.json'), symbolize_names: true) }
let(:dashboard) { described_class.new(project, {}, params).transform! } subject(:dashboard) { described_class.new(project, {}, params).transform! }
let(:params) do let(:params) do
{ {
...@@ -23,83 +24,34 @@ describe Gitlab::Metrics::Dashboard::Stages::GrafanaFormatter do ...@@ -23,83 +24,34 @@ describe Gitlab::Metrics::Dashboard::Stages::GrafanaFormatter do
end end
context 'when the query and resources are configured correctly' do context 'when the query and resources are configured correctly' do
let(:expected_dashboard) { JSON.parse(fixture_file('grafana/expected_grafana_embed.json'), symbolize_names: true) } it { is_expected.to eq expected_dashboard }
it 'generates a gitlab-yml formatted dashboard' do
expect(dashboard).to eq(expected_dashboard)
end
end end
context 'when the inputs are invalid' do context 'when a panelId is not included in the grafana_url' do
shared_examples_for 'processing error' do before do
it 'raises a processing error' do params[:grafana_url].gsub('&panelId=8', '')
expect { dashboard }
.to raise_error(Gitlab::Metrics::Dashboard::Stages::InputFormatValidator::DashboardProcessingError)
end
end
context 'when the datasource is not proxyable' do
before do
params[:datasource][:access] = 'not-proxy'
end
it_behaves_like 'processing error'
end end
context 'when query param "panelId" is not specified' do it { is_expected.to eq expected_dashboard }
before do
params[:grafana_url].gsub!('panelId=8', '')
end
it_behaves_like 'processing error'
end
context 'when query param "from" is not specified' do
before do
params[:grafana_url].gsub!('from=1570397739557', '')
end
it_behaves_like 'processing error'
end
context 'when query param "to" is not specified' do context 'when there is also no valid panel in the dashboard' do
before do before do
params[:grafana_url].gsub!('to=1570484139557', '') params[:grafana_dashboard][:dashboard][:panels] = []
end end
it_behaves_like 'processing error' it 'raises a processing error' do
end expect { dashboard }.to raise_error(::Gitlab::Metrics::Dashboard::Errors::DashboardProcessingError)
context 'when the panel is not a graph' do
before do
params[:grafana_dashboard][:dashboard][:panels][0][:type] = 'singlestat'
end end
it_behaves_like 'processing error'
end end
end
context 'when the panel is not a line graph' do context 'when an input is invalid' do
before do before do
params[:grafana_dashboard][:dashboard][:panels][0][:lines] = false params[:datasource][:access] = 'not-proxy'
end
it_behaves_like 'processing error'
end
context 'when the query dashboard includes undefined variables' do
before do
params[:grafana_url].gsub!('&var-instance=localhost:9121', '')
end
it_behaves_like 'processing error'
end end
context 'when the expression contains unsupported global variables' do it 'raises a processing error' do
before do expect { dashboard }.to raise_error(::Gitlab::Metrics::Dashboard::Errors::DashboardProcessingError)
params[:grafana_dashboard][:dashboard][:panels][0][:targets][0][:expr] = 'sum(important_metric[$__interval_ms])'
end
it_behaves_like 'processing error'
end end
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
describe Grafana::Validator do
let(:grafana_dashboard) { JSON.parse(fixture_file('grafana/simplified_dashboard_response.json'), symbolize_names: true) }
let(:datasource) { JSON.parse(fixture_file('grafana/datasource_response.json'), symbolize_names: true) }
let(:panel) { grafana_dashboard[:dashboard][:panels].first }
let(:query_params) do
{
from: '1570397739557',
to: '1570484139557',
panelId: '8',
'var-instance': 'localhost:9121'
}
end
describe 'validate!' do
shared_examples_for 'processing error' do |message|
it 'raises a processing error' do
expect { subject }
.to raise_error(::Grafana::Validator::Error, message)
end
end
subject { described_class.new(grafana_dashboard, datasource, panel, query_params).validate! }
it 'does not raise an error' do
expect { subject }.not_to raise_error
end
context 'when query param "from" is not specified' do
before do
query_params.delete(:from)
end
it_behaves_like 'processing error', 'Grafana query parameters must include from and to.'
end
context 'when query param "to" is not specified' do
before do
query_params.delete(:to)
end
it_behaves_like 'processing error', 'Grafana query parameters must include from and to.'
end
context 'when the panel is not provided' do
let(:panel) { nil }
it_behaves_like 'processing error', 'Panel type must be a line graph.'
end
context 'when the panel is not a graph' do
before do
panel[:type] = 'singlestat'
end
it_behaves_like 'processing error', 'Panel type must be a line graph.'
end
context 'when the panel is not a line graph' do
before do
panel[:lines] = false
end
it_behaves_like 'processing error', 'Panel type must be a line graph.'
end
context 'when the query dashboard includes undefined variables' do
before do
query_params.delete(:'var-instance')
end
it_behaves_like 'processing error', 'All Grafana variables must be defined in the query parameters.'
end
context 'when the expression contains unsupported global variables' do
before do
grafana_dashboard[:dashboard][:panels][0][:targets][0][:expr] = 'sum(important_metric[$__interval_ms])'
end
it_behaves_like 'processing error', "Prometheus must not include #{described_class::UNSUPPORTED_GRAFANA_GLOBAL_VARS}"
end
context 'when the datasource is not proxyable' do
before do
datasource[:access] = 'not-proxy'
end
it_behaves_like 'processing error', 'Only Prometheus datasources with proxy access in Grafana are supported.'
end
# Skipping datasource validation allows for checks to be
# run without a secondary call to Grafana API
context 'when the datasource is not provided' do
let(:datasource) { nil }
it 'does not raise an error' do
expect { subject }.not_to raise_error
end
end
end
describe 'valid?' do
subject { described_class.new(grafana_dashboard, datasource, panel, query_params).valid? }
context 'with valid arguments' do
it { is_expected.to be true }
end
context 'with invalid arguments' do
let(:query_params) { {} }
it { is_expected.to be false }
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment