Commit 7d393bd8 authored by Sarah Yasonik's avatar Sarah Yasonik Committed by Dmitriy Zaporozhets

Expose metrics element for FE consumption

Adds GFM Pipline filters to insert a placeholder in the generated
HTML from GFM based on the presence of a metrics dashboard link.

The front end should look for the class 'js-render-metrics' to
determine if it should replace the element with metrics charts.
The data element 'data-dashboard-url' should be the endpoint
the front end should hit in order to obtain a dashboard layout
in order to appropriately render the charts.
parent 476c9f0b
---
title: Expose placeholder element for metrics charts in GFM
merge_request: 29861
author:
type: added
# frozen_string_literal: true
module Banzai
module Filter
# HTML filter that inserts a node for each occurence of
# a given link format. To transform references to DB
# resources in place, prefer to inherit from AbstractReferenceFilter.
class InlineEmbedsFilter < HTML::Pipeline::Filter
# Find every relevant link, create a new node based on
# the link, and insert this node after any html content
# surrounding the link.
def call
return doc unless Feature.enabled?(:gfm_embedded_metrics, context[:project])
doc.xpath(xpath_search).each do |node|
next unless element = element_to_embed(node)
# We want this to follow any surrounding content. For example,
# if a link is inline in a paragraph.
node.parent.children.last.add_next_sibling(element)
end
doc
end
# Implement in child class.
#
# Return a Nokogiri::XML::Element to embed in the
# markdown.
def create_element(params)
end
# Implement in child class unless overriding #embed_params
#
# Returns the regex pattern used to filter
# to only matching urls.
def link_pattern
end
# Returns the xpath query string used to select nodes
# from the html document on which the embed is based.
#
# Override to select nodes other than links.
def xpath_search
'descendant-or-self::a[@href]'
end
# Creates a new element based on the parameters
# obtained from the target link
def element_to_embed(node)
return unless params = embed_params(node)
create_element(params)
end
# Returns a hash of named parameters based on the
# provided regex with string keys.
#
# Override to select nodes other than links.
def embed_params(node)
url = node['href']
link_pattern.match(url) { |m| m.named_captures }
end
end
end
end
# frozen_string_literal: true
module Banzai
module Filter
# HTML filter that inserts a placeholder element for each
# reference to a metrics dashboard.
class InlineMetricsFilter < Banzai::Filter::InlineEmbedsFilter
# Placeholder element for the frontend to use as an
# injection point for charts.
def create_element(params)
doc.document.create_element(
'div',
class: 'js-render-metrics',
'data-dashboard-url': metrics_dashboard_url(params)
)
end
# Endpoint FE should hit to collect the appropriate
# chart information
def metrics_dashboard_url(params)
Gitlab::Metrics::Dashboard::Url.build_dashboard_url(
params['namespace'],
params['project'],
params['environment'],
embedded: true
)
end
# Search params for selecting metrics links. A few
# simple checks is enough to boost performance without
# the cost of doing a full regex match.
def xpath_search
"descendant-or-self::a[contains(@href,'metrics') and \
starts-with(@href, '#{Gitlab.config.gitlab.url}')]"
end
# Regular expression matching metrics urls
def link_pattern
Gitlab::Metrics::Dashboard::Url.regex
end
end
end
end
# frozen_string_literal: true
module Banzai
module Filter
# HTML filter that removes embeded elements that the current user does
# not have permission to view.
class InlineMetricsRedactorFilter < HTML::Pipeline::Filter
include Gitlab::Utils::StrongMemoize
METRICS_CSS_CLASS = '.js-render-metrics'
# Finds all embeds based on the css class the FE
# uses to identify the embedded content, removing
# only unnecessary nodes.
def call
return doc unless Feature.enabled?(:gfm_embedded_metrics, context[:project])
nodes.each do |node|
path = paths_by_node[node]
user_has_access = user_access_by_path[path]
node.remove unless user_has_access
end
doc
end
private
def user
context[:current_user]
end
# Returns all nodes which the FE will identify as
# a metrics dashboard placeholder element
#
# @return [Nokogiri::XML::NodeSet]
def nodes
@nodes ||= doc.css(METRICS_CSS_CLASS)
end
# Maps a node to the full path of a project.
# Memoized so we only need to run the regex to get
# the project full path from the url once per node.
#
# @return [Hash<Nokogiri::XML::Node, String>]
def paths_by_node
strong_memoize(:paths_by_node) do
nodes.each_with_object({}) do |node, paths|
paths[node] = path_for_node(node)
end
end
end
# Gets a project's full_path from the dashboard url
# in the placeholder node. The FE will use the attr
# `data-dashboard-url`, so we want to check against that
# attribute directly in case a user has manually
# created a metrics element (rather than supporting
# an alternate attr in InlineMetricsFilter).
#
# @return [String]
def path_for_node(node)
url = node.attribute('data-dashboard-url').to_s
Gitlab::Metrics::Dashboard::Url.regex.match(url) do |m|
"#{$~[:namespace]}/#{$~[:project]}"
end
end
# Maps a project's full path to a Project object.
# Contains all of the Projects referenced in the
# metrics placeholder elements of the current document
#
# @return [Hash<String, Project>]
def projects_by_path
strong_memoize(:projects_by_path) do
Project.eager_load(:route, namespace: [:route])
.where_full_path_in(paths_by_node.values.uniq)
.index_by(&:full_path)
end
end
# Returns a mapping representing whether the current user
# has permission to view the metrics for the project.
# Determined in a batch
#
# @return [Hash<Project, Boolean>]
def user_access_by_path
strong_memoize(:user_access_by_path) do
projects_by_path.each_with_object({}) do |(path, project), access|
access[path] = Ability.allowed?(user, :read_environment, project)
end
end
end
end
end
end
...@@ -25,6 +25,7 @@ module Banzai ...@@ -25,6 +25,7 @@ module Banzai
Filter::VideoLinkFilter, Filter::VideoLinkFilter,
Filter::ImageLazyLoadFilter, Filter::ImageLazyLoadFilter,
Filter::ImageLinkFilter, Filter::ImageLinkFilter,
Filter::InlineMetricsFilter,
Filter::TableOfContentsFilter, Filter::TableOfContentsFilter,
Filter::AutolinkFilter, Filter::AutolinkFilter,
Filter::ExternalLinkFilter, Filter::ExternalLinkFilter,
......
...@@ -13,6 +13,7 @@ module Banzai ...@@ -13,6 +13,7 @@ module Banzai
def self.internal_link_filters def self.internal_link_filters
[ [
Filter::RedactorFilter, Filter::RedactorFilter,
Filter::InlineMetricsRedactorFilter,
Filter::RelativeLinkFilter, Filter::RelativeLinkFilter,
Filter::IssuableStateFilter, Filter::IssuableStateFilter,
Filter::SuggestionFilter Filter::SuggestionFilter
......
# frozen_string_literal: true
# Manages url matching for metrics dashboards.
module Gitlab
module Metrics
module Dashboard
class Url
class << self
# Matches urls for a metrics dashboard. This could be
# either the /metrics endpoint or the /metrics_dashboard
# endpoint.
#
# EX - https://<host>/<namespace>/<project>/environments/<env_id>/metrics
def regex
%r{
(?<url>
#{Regexp.escape(Gitlab.config.gitlab.url)}
\/#{Project.reference_pattern}
(?:\/\-)?
\/environments
\/(?<environment>\d+)
\/metrics
(?<query>
\?[a-z0-9_=-]+
(&[a-z0-9_=-]+)*
)?
(?<anchor>\#[a-z0-9_-]+)?
)
}x
end
# Builds a metrics dashboard url based on the passed in arguments
def build_dashboard_url(*args)
Gitlab::Routing.url_helpers.metrics_dashboard_namespace_project_environment_url(*args)
end
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Banzai::Filter::InlineMetricsFilter do
include FilterSpecHelper
let(:input) { %(<a href="#{url}">example</a>) }
let(:doc) { filter(input) }
context 'when the document has an external link' do
let(:url) { 'https://foo.com' }
it 'leaves regular non-metrics links unchanged' do
expect(doc.to_s).to eq input
end
end
context 'when the document has a metrics dashboard link' do
let(:params) { ['foo', 'bar', 12] }
let(:url) { urls.metrics_namespace_project_environment_url(*params) }
it 'leaves the original link unchanged' do
expect(doc.at_css('a').to_s).to eq input
end
it 'appends a metrics charts placeholder with dashboard url after metrics links' do
node = doc.at_css('.js-render-metrics')
expect(node).to be_present
dashboard_url = urls.metrics_dashboard_namespace_project_environment_url(*params, embedded: true)
expect(node.attribute('data-dashboard-url').to_s).to eq dashboard_url
end
context 'when the metrics dashboard link is part of a paragraph' do
let(:paragraph) { %(This is an <a href="#{url}">example</a> of metrics.) }
let(:input) { %(<p>#{paragraph}</p>) }
it 'appends the charts placeholder after the enclosing paragraph' do
expect(doc.at_css('p').to_s).to include paragraph
expect(doc.at_css('.js-render-metrics')).to be_present
end
context 'when the feature is disabled' do
before do
stub_feature_flags(gfm_embedded_metrics: false)
end
it 'does nothing' do
expect(doc.to_s).to eq input
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Banzai::Filter::InlineMetricsRedactorFilter do
include FilterSpecHelper
set(:project) { create(:project) }
let(:url) { urls.metrics_dashboard_project_environment_url(project, 1, embedded: true) }
let(:input) { %(<a href="#{url}">example</a>) }
let(:doc) { filter(input) }
context 'when the feature is disabled' do
before do
stub_feature_flags(gfm_embedded_metrics: false)
end
it 'does nothing' do
expect(doc.to_s).to eq input
end
end
context 'without a metrics charts placeholder' do
it 'leaves regular non-metrics links unchanged' do
expect(doc.to_s).to eq input
end
end
context 'with a metrics charts placeholder' do
let(:input) { %(<div class="js-render-metrics" data-dashboard-url="#{url}"></div>) }
context 'no user is logged in' do
it 'redacts the placeholder' do
expect(doc.to_s).to be_empty
end
end
context 'the user does not have permission do see charts' do
let(:doc) { filter(input, current_user: build(:user)) }
it 'redacts the placeholder' do
expect(doc.to_s).to be_empty
end
end
context 'the user has requisite permissions' do
let(:user) { create(:user) }
let(:doc) { filter(input, current_user: user) }
it 'leaves the placeholder' do
project.add_maintainer(user)
expect(doc.to_s).to eq input
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::Metrics::Dashboard::Url do
describe '#regex' do
it 'returns a regular expression' do
expect(described_class.regex).to be_a Regexp
end
it 'matches a metrics dashboard link with named params' do
url = Gitlab::Routing.url_helpers.metrics_namespace_project_environment_url('foo', 'bar', 1, start: 123345456, anchor: 'title')
expected_params = {
'url' => url,
'namespace' => 'foo',
'project' => 'bar',
'environment' => '1',
'query' => '?start=123345456',
'anchor' => '#title'
}
expect(described_class.regex).to match url
described_class.regex.match(url) do |m|
expect(m.named_captures).to eq expected_params
end
end
it 'does not match other gitlab urls that contain the term metrics' do
url = Gitlab::Routing.url_helpers.active_common_namespace_project_prometheus_metrics_url('foo', 'bar', :json)
expect(described_class.regex).not_to match url
end
it 'does not match other gitlab urls' do
url = Gitlab.config.gitlab.url
expect(described_class.regex).not_to match url
end
it 'does not match non-gitlab urls' do
url = 'https://www.super_awesome_site.com/'
expect(described_class.regex).not_to match url
end
end
describe '#build_dashboard_url' do
it 'builds the url for the dashboard endpoint' do
url = described_class.build_dashboard_url('foo', 'bar', 1)
expect(url).to match described_class.regex
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