diff options
author | Sarah Yasonik <syasonik@gitlab.com> | 2019-07-10 11:27:25 +0000 |
---|---|---|
committer | Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> | 2019-07-10 11:27:25 +0000 |
commit | 7d393bd85233bd6c8f003aec638e93c01deb9f8a (patch) | |
tree | 0dcad0bc26766b82f0d8d55762b768e65a41f186 | |
parent | 476c9f0bb6bb629d36efc7b62fcb12eda6ceee2d (diff) | |
download | gitlab-ce-7d393bd85233bd6c8f003aec638e93c01deb9f8a.tar.gz |
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.
-rw-r--r-- | changelogs/unreleased/embedded-metrics-be-2.yml | 5 | ||||
-rw-r--r-- | lib/banzai/filter/inline_embeds_filter.rb | 67 | ||||
-rw-r--r-- | lib/banzai/filter/inline_metrics_filter.rb | 43 | ||||
-rw-r--r-- | lib/banzai/filter/inline_metrics_redactor_filter.rb | 98 | ||||
-rw-r--r-- | lib/banzai/pipeline/gfm_pipeline.rb | 1 | ||||
-rw-r--r-- | lib/banzai/pipeline/post_process_pipeline.rb | 1 | ||||
-rw-r--r-- | lib/gitlab/metrics/dashboard/url.rb | 40 | ||||
-rw-r--r-- | spec/lib/banzai/filter/inline_metrics_filter_spec.rb | 55 | ||||
-rw-r--r-- | spec/lib/banzai/filter/inline_metrics_redactor_filter_spec.rb | 58 | ||||
-rw-r--r-- | spec/lib/gitlab/metrics/dashboard/url_spec.rb | 56 |
10 files changed, 424 insertions, 0 deletions
diff --git a/changelogs/unreleased/embedded-metrics-be-2.yml b/changelogs/unreleased/embedded-metrics-be-2.yml new file mode 100644 index 00000000000..2623b4a2e0c --- /dev/null +++ b/changelogs/unreleased/embedded-metrics-be-2.yml @@ -0,0 +1,5 @@ +--- +title: Expose placeholder element for metrics charts in GFM +merge_request: 29861 +author: +type: added diff --git a/lib/banzai/filter/inline_embeds_filter.rb b/lib/banzai/filter/inline_embeds_filter.rb new file mode 100644 index 00000000000..97394fd8f82 --- /dev/null +++ b/lib/banzai/filter/inline_embeds_filter.rb @@ -0,0 +1,67 @@ +# 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 diff --git a/lib/banzai/filter/inline_metrics_filter.rb b/lib/banzai/filter/inline_metrics_filter.rb new file mode 100644 index 00000000000..0120cc37d6f --- /dev/null +++ b/lib/banzai/filter/inline_metrics_filter.rb @@ -0,0 +1,43 @@ +# 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 diff --git a/lib/banzai/filter/inline_metrics_redactor_filter.rb b/lib/banzai/filter/inline_metrics_redactor_filter.rb new file mode 100644 index 00000000000..ff91be2cbb7 --- /dev/null +++ b/lib/banzai/filter/inline_metrics_redactor_filter.rb @@ -0,0 +1,98 @@ +# 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 diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb index d67f461be57..2c1006f708a 100644 --- a/lib/banzai/pipeline/gfm_pipeline.rb +++ b/lib/banzai/pipeline/gfm_pipeline.rb @@ -25,6 +25,7 @@ module Banzai Filter::VideoLinkFilter, Filter::ImageLazyLoadFilter, Filter::ImageLinkFilter, + Filter::InlineMetricsFilter, Filter::TableOfContentsFilter, Filter::AutolinkFilter, Filter::ExternalLinkFilter, diff --git a/lib/banzai/pipeline/post_process_pipeline.rb b/lib/banzai/pipeline/post_process_pipeline.rb index 7eaad6d7560..5c199453638 100644 --- a/lib/banzai/pipeline/post_process_pipeline.rb +++ b/lib/banzai/pipeline/post_process_pipeline.rb @@ -13,6 +13,7 @@ module Banzai def self.internal_link_filters [ Filter::RedactorFilter, + Filter::InlineMetricsRedactorFilter, Filter::RelativeLinkFilter, Filter::IssuableStateFilter, Filter::SuggestionFilter diff --git a/lib/gitlab/metrics/dashboard/url.rb b/lib/gitlab/metrics/dashboard/url.rb new file mode 100644 index 00000000000..b197e7ca86b --- /dev/null +++ b/lib/gitlab/metrics/dashboard/url.rb @@ -0,0 +1,40 @@ +# 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 diff --git a/spec/lib/banzai/filter/inline_metrics_filter_spec.rb b/spec/lib/banzai/filter/inline_metrics_filter_spec.rb new file mode 100644 index 00000000000..772c94e3180 --- /dev/null +++ b/spec/lib/banzai/filter/inline_metrics_filter_spec.rb @@ -0,0 +1,55 @@ +# 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 diff --git a/spec/lib/banzai/filter/inline_metrics_redactor_filter_spec.rb b/spec/lib/banzai/filter/inline_metrics_redactor_filter_spec.rb new file mode 100644 index 00000000000..fb2186e9d12 --- /dev/null +++ b/spec/lib/banzai/filter/inline_metrics_redactor_filter_spec.rb @@ -0,0 +1,58 @@ +# 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 diff --git a/spec/lib/gitlab/metrics/dashboard/url_spec.rb b/spec/lib/gitlab/metrics/dashboard/url_spec.rb new file mode 100644 index 00000000000..34bc6359414 --- /dev/null +++ b/spec/lib/gitlab/metrics/dashboard/url_spec.rb @@ -0,0 +1,56 @@ +# 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 |