From b69224b3cfb9016f93e2d1a89450d9fb1d15f512 Mon Sep 17 00:00:00 2001 From: Akshay Gupta Date: Tue, 31 Oct 2023 11:28:45 +0000 Subject: [PATCH] Devops Report: Grouped + Stacked bar chart (#572) - Simpler datastructures - Cache reports --- app/assets/images/cog.svg | 3 + app/assets/images/user.svg | 3 + app/components/chart_component.html.erb | 20 +- app/components/chart_component.rb | 112 +++++-- app/controllers/apps_controller.rb | 1 - app/controllers/trains_controller.rb | 1 + .../controllers/charts_controller.js | 287 ++++++------------ app/jobs/refresh_reports_job.rb | 12 + app/libs/charts/devops_report.rb | 156 +++++----- app/models/release.rb | 2 +- app/models/release_changelog.rb | 4 + app/models/train.rb | 8 + app/views/apps/show.html.erb | 20 -- app/views/trains/show.html.erb | 21 ++ config/locales/en.yml | 10 + 15 files changed, 338 insertions(+), 322 deletions(-) create mode 100644 app/assets/images/cog.svg create mode 100644 app/assets/images/user.svg create mode 100644 app/jobs/refresh_reports_job.rb diff --git a/app/assets/images/cog.svg b/app/assets/images/cog.svg new file mode 100644 index 000000000..0c6f7dfed --- /dev/null +++ b/app/assets/images/cog.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/user.svg b/app/assets/images/user.svg new file mode 100644 index 000000000..07a9c98a0 --- /dev/null +++ b/app/assets/images/user.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/components/chart_component.html.erb b/app/components/chart_component.html.erb index 155aa42e6..534b7e55c 100644 --- a/app/components/chart_component.html.erb +++ b/app/components/chart_component.html.erb @@ -3,24 +3,18 @@
<%= title %> - <%= inline_svg("question_mark.svg", classname: "w-4 inline-flex fill-current shrink-0 text-gray-400") %> + <%#= help_icon %>
-

<%= chart_scope %>

- -
- <%= inline_svg("thermometer.svg", classname: "w-6 fill-current shrink-0") %> -
+
<%= corner_icon %>
<% if area? %>
+ data-charts-series-value="<%= series %>">
<% end %> @@ -29,9 +23,7 @@
+ data-charts-series-value="<%= series %>">
<% end %> @@ -40,9 +32,7 @@
+ data-charts-series-value="<%= series %>">
<% end %> diff --git a/app/components/chart_component.rb b/app/components/chart_component.rb index a990fc833..814f97fb2 100644 --- a/app/components/chart_component.rb +++ b/app/components/chart_component.rb @@ -1,52 +1,122 @@ class ChartComponent < ViewComponent::Base include AssetsHelper + using RefinedHash CHART_TYPES = %w[area line donut stacked-bar] InvalidChartType = Class.new(StandardError) - def initialize(chart) + def initialize(chart, icon:) @chart = chart + @icon = icon raise InvalidChartType unless chart[:type].in?(CHART_TYPES) end attr_reader :chart - def area? - type == "area" + def type + @type ||= chart[:type] end - def line? - type == "line" + def value_format + chart[:value_format] end - def stacked_bar? - type == "stacked-bar" + def series + ungroup_series.to_json end - def type - @type ||= chart[:type] + def series_raw + chart[:data] end - def legends - chart[:legends].to_json + def title + I18n.t("charts.#{chart[:name]}.title") end - def series - chart[:series].to_json + def chart_scope + I18n.t("charts.#{chart[:name]}.scope") end - def categories - chart[:x_axis].to_json + def help_text + I18n.t("charts.#{chart[:name]}.help_text") end - def value_format - chart[:value_format] + def corner_icon + inline_svg(@icon, classname: "w-6 fill-current shrink-0") end - def title - I18n.t("charts.#{chart[:name]}.title") + def help_icon + inline_svg("question_mark.svg", classname: "w-4 inline-flex fill-current shrink-0 text-gray-400") end - def chart_scope - I18n.t("charts.#{chart[:name]}.scope") + def subgroup? = chart[:subgroup] + + def stacked? = chart[:stacked] + + def line? = chart[:type] == "line" + + def area? = chart[:type] == "area" + + def donut? = chart[:type] == "donut" + + def stacked_bar? = chart[:type] == "stacked-bar" + + # {"8.0.2"=>{"android"=>1, "ios"=>0}} + # Input: + # { + # "8.0.1": { + # "android": { + # "QA Android Review": 2, + # "Android Release": 4 + # }, + # "ios": { + # "QA iOS Review": 6, + # "iOS Release": 8 + # } + # }, + # "8.0.2": { + # "android": { + # "QA Android Review": 3, + # "Android Release": 5 + # }, + # "ios": { + # "QA iOS Review": 7, + # "iOS Release": 9 + # } + # } + # } + # + # Output: + # [{:name=>"QA Android Review", :group=>"android", :data=>{"8.0.1"=>2, "8.0.2"=>3}}, + # {:name=>"Android Release", :group=>"android", :data=>{"8.0.1"=>4, "8.0.2"=>5}}, + # {:name=>"QA iOS Review", :group=>"ios", :data=>{"8.0.1"=>6, "8.0.2"=>7}}, + # {:name=>"iOS Release", :group=>"ios", :data=>{"8.0.1"=>8, "8.0.2"=>9}}] + def ungroup_series(input = series_raw) + input.each_with_object([]) do |(category, grouped_maps), result| + grouped_maps.each do |group, inner_data| + if inner_data.is_a?(Hash) && stacked? + inner_data.each do |name, value| + item = result.find { |r| r[:name] == name && r[:group] == group } + item ||= {name: name, group: group, data: {}} + item[:data][category] = value + result.push(item) unless result.include?(item) + end + else + item = result.find { |r| r[:name] == group } + item ||= {name: group, data: {}} + item[:data][category] = inner_data + result.push(item) unless result.include?(item) + end + end + end.then { cartesian_series(_1) } + end + + def cartesian_series(input = series_raw) + input.map do |series| + series.update_key(:data) do |data| + data.map do |x, y| + {x: x, y: y} + end + end + end end end diff --git a/app/controllers/apps_controller.rb b/app/controllers/apps_controller.rb index ed7c0bbfd..c36c12f24 100644 --- a/app/controllers/apps_controller.rb +++ b/app/controllers/apps_controller.rb @@ -15,7 +15,6 @@ def show @app_setup_instructions = @app.app_setup_instructions @train_setup_instructions = @app.train_setup_instructions @train_in_creation = @app.train_in_creation - @devops_report = Charts::DevopsReport.all(@app.trains.last) if current_user.release_health? end def new diff --git a/app/controllers/trains_controller.rb b/app/controllers/trains_controller.rb index b5441c4a1..b31e6e44c 100644 --- a/app/controllers/trains_controller.rb +++ b/app/controllers/trains_controller.rb @@ -10,6 +10,7 @@ class TrainsController < SignedInApplicationController before_action :set_notification_channels, only: %i[new create edit update] def show + @devops_report = @train.devops_report if @train.devops_report?(current_user) end def new diff --git a/app/javascript/controllers/charts_controller.js b/app/javascript/controllers/charts_controller.js index 1c8182b37..ca041560a 100644 --- a/app/javascript/controllers/charts_controller.js +++ b/app/javascript/controllers/charts_controller.js @@ -3,7 +3,7 @@ import ApexCharts from "apexcharts" import humanizeDuration from "humanize-duration"; const formatTypes = ["number", "time"] -const chartTypes = ["area", "line", "stacked-bar", "donut"] +const chartTypes = ["area", "line", "stacked-bar"] const chartColors = [ "#1A56DB", "#9061F9", "#E74694", "#31C48D", "#FDBA8C", "#16BDCA", "#7E3BF2", "#1C64F2", "#F05252" @@ -15,18 +15,7 @@ export default class extends Controller { static values = { type: String, format: {type: String, default: "number"}, - areaNames: Array, - areaSeries: Array, - areaCategories: Array, - lineNames: Array, - lineSeries: Array, - lineCategories: Array, - stackedBarNames: Array, - stackedBarSeries: Array, - stackedBarCategories: Array, - donutNames: Array, - donutSeries: Array, - donutLabel: String, + series: Array, } initialize() { @@ -42,47 +31,23 @@ export default class extends Controller { return; } - let names = [] - let series = [] - let categories = [] - let label + let series = this.seriesValue let chartOptions if (chartType === "area") { - names = this.areaNamesValue - series = this.areaSeriesValue - categories = this.areaCategoriesValue - chartOptions = this.areaOptions(names, series, categories) + chartOptions = this.areaOptions(series) } else if (chartType === "line") { - names = this.lineNamesValue - series = this.lineSeriesValue - categories = this.lineCategoriesValue - chartOptions = this.lineOptions(names, series, categories) - } else if (chartType === "donut") { - label = this.donutLabelValue; - names = this.donutNamesValue; - series = this.donutSeriesValue; - chartOptions = this.donutOptions(names, series, label) + chartOptions = this.lineOptions(series) } else if (chartType === "stacked-bar") { - names = this.stackedBarNamesValue - series = this.stackedBarSeriesValue - categories = this.stackedBarCategoriesValue - chartOptions = this.stackedBarOptions(names, series, categories) - - if (!this.__validateStackBarData(names, series, categories)) { - return - } - } - - if (chartType !== "stacked-bar" && !this.__validateData(names, series, categories)) { - return; + chartOptions = this.stackedBarOptions(series) } + console.log(series); this.chart = new ApexCharts(this.chartTarget, chartOptions); this.chart.render(); } - areaOptions(names, series, categories) { + areaOptions(series) { let self = this; return { @@ -93,7 +58,12 @@ export default class extends Controller { type: "area", fontFamily: "Inter, sans-serif", dropShadow: { - enabled: false, + enabled: true, + enabledSeries: [0], + top: -2, + left: 2, + blur: 5, + opacity: 0.1 }, toolbar: { show: false, @@ -130,21 +100,22 @@ export default class extends Controller { width: 6, }, grid: { - show: false, + show: true, strokeDashArray: 4, padding: { - left: 2, - right: 2, - top: 0 + left: -5, + right: 5, }, }, - series: this.__genSeries(names, series), + series: series, xaxis: { - categories: categories, tickPlacement: 'between', labels: { show: true, }, + tooltip: { + enabled: false + }, axisBorder: { show: false, }, @@ -158,7 +129,7 @@ export default class extends Controller { } } - lineOptions(names, series, categories) { + lineOptions(series) { let self = this; return { @@ -201,7 +172,7 @@ export default class extends Controller { top: -20 }, }, - series: this.__genSeries(names, series), + series: series, legend: { show: false }, @@ -210,14 +181,16 @@ export default class extends Controller { curve: 'smooth' }, xaxis: { - categories: categories, tickPlacement: 'between', labels: { show: true, style: { fontFamily: "Inter, sans-serif", cssClass: 'text-xs font-normal fill-gray-500' - } + }, + tooltip: { + enabled: false + }, }, axisBorder: { show: true, @@ -232,85 +205,16 @@ export default class extends Controller { } } - donutOptions(names, series, label) { - return { - series: series, - colors: chartColors, - chart: { - height: 320, - width: "100%", - type: "donut", - }, - stroke: { - colors: ["transparent"], - lineCap: "", - }, - plotOptions: { - pie: { - donut: { - labels: { - show: true, - name: { - show: true, - fontFamily: "Inter, sans-serif", - offsetY: 20, - }, - total: { - showAlways: true, - show: true, - label: label, - fontFamily: "Inter, sans-serif", - formatter: function (w) { - const sum = w.globals.seriesTotals.reduce((a, b) => { - return a + b - }, 0) - return `${sum}` - }, - }, - value: { - show: true, - fontFamily: "Inter, sans-serif", - offsetY: -20, - }, - }, - size: "80%", - }, - }, - }, - grid: { - padding: { - top: -2, - }, - }, - labels: names, - dataLabels: { - enabled: false, - }, - legend: { - position: "bottom", - fontFamily: "Inter, sans-serif", - }, - xaxis: { - axisTicks: { - show: false, - }, - axisBorder: { - show: false, - }, - }, - } - } - - stackedBarOptions(names, series, categories) { + stackedBarOptions(series) { let self = this; return { - series: this._genBarSeries(names, series, categories), + series: series, chart: { type: "bar", stacked: true, stackType: "100%", - height: "100%", + height: "200", fontFamily: "Inter, sans-serif", toolbar: { show: false, @@ -319,14 +223,10 @@ export default class extends Controller { plotOptions: { bar: { horizontal: false, - columnWidth: "70%", - borderRadiusApplication: "end", - borderRadius: 8, + rangeBarGroupRows: true, }, }, tooltip: { - shared: true, - intersect: false, style: { fontFamily: "Inter, sans-serif", }, @@ -354,7 +254,7 @@ export default class extends Controller { colors: ["transparent"], }, grid: { - show: false, + show: true, strokeDashArray: 4, padding: { left: 2, @@ -363,14 +263,23 @@ export default class extends Controller { }, }, dataLabels: { - enabled: false, + enabled: true, + style: { + fontSize: '10px', + }, + formatter(val) { + if (self.__isTimeFormat()) { + return self.__formatSeconds(val, true) + } else { + return val + } + }, }, legend: { show: false, }, xaxis: { - categories: categories, - floating: true, + show: true, labels: { show: true, style: { @@ -394,70 +303,58 @@ export default class extends Controller { } } - __genSeries(names, data) { - let outputData = []; - - for (let i = 0; i < names.length; i++) { - const formattedData = data[i].map((item) => (item === null ? 0 : item)); - const entry = { - name: names[i], - data: formattedData, - color: this.__pickColor(i) - }; - - outputData.push(entry); - } - - return outputData; - } - - _genBarSeries(names, series, categories) { - return names.map((name, index) => ({ - name, - color: this.__pickColor(index), - data: categories.map((xLabel, i) => (series[i][index])), - })); - } - - __pickColor(i) { - const colorsLength = chartColors.length; - const colorIndex = ((i % colorsLength) + colorsLength) % colorsLength; - return chartColors[colorIndex]; - } - - __validateData(names, series, categories) { - if (names.length !== series.length) { - console.error('Names and Series must have the same number of top-level items.'); - return false; - } - - if (categories.length > 0 && series.some(dataset => dataset.length !== categories.length)) { - console.error('Categories and each dataset in the Series must be equal in size'); - return false; + // __pickColor(i) { + // const colorsLength = chartColors.length; + // const colorIndex = ((i % colorsLength) + colorsLength) % colorsLength; + // return chartColors[colorIndex]; + // } + // + // __validateData(names, series, categories) { + // if (names.length !== series.length) { + // console.error('Names and Series must have the same number of top-level items.'); + // return false; + // } + // + // if (categories.length > 0 && series.some(dataset => dataset.length !== categories.length)) { + // console.error('Categories and each dataset in the Series must be equal in size'); + // return false; + // } + // + // return true; + // } + // + // __validateStackBarData(names, series, categories) { + // if (categories.length !== series.length) { + // console.error('Categories and Series must have the same number of top-level items.'); + // return false; + // } + // + // if (names.length > 0 && series.some(dataset => dataset.length !== names.length)) { + // console.error('Names and each dataset in the Series must be equal in size'); + // return false; + // } + // + // return true; + // } + + __formatSeconds(seconds, isShort) { + const ms = seconds * 1000 + if (isShort) { + return humanizeDuration(ms, { + round: true, + largest: 1, + maxDecimalPoints: 1, + units: ["h", "m", "s"], + language: "shortEn", + languages: {shortEn: {h: () => "h", m: () => "m", s: () => "s"}} + }) + } else { + return humanizeDuration(ms, {round: true, largest: 2, maxDecimalPoints: 1}) } - - return true; - } - - __validateStackBarData(names, series, categories) { - if (categories.length !== series.length) { - console.error('Categories and Series must have the same number of top-level items.'); - return false; - } - - if (names.length > 0 && series.some(dataset => dataset.length !== names.length)) { - console.error('Names and each dataset in the Series must be equal in size'); - return false; - } - - return true; - } - - __formatSeconds(seconds) { - return humanizeDuration(seconds * 1000, { round: true, largest: 2, maxDecimalPoints: 1 }) } __isTimeFormat() { return this.formatValue === "time" } } + diff --git a/app/jobs/refresh_reports_job.rb b/app/jobs/refresh_reports_job.rb new file mode 100644 index 000000000..f0502f90a --- /dev/null +++ b/app/jobs/refresh_reports_job.rb @@ -0,0 +1,12 @@ +class RefreshReportsJob < ApplicationJob + include Loggable + queue_as :high + + def perform(release_id) + release = Release.find(release_id) + train = release.train + + Charts::DevopsReport.warm(train) + Queries::ReleaseSummary.warm(release) + end +end diff --git a/app/libs/charts/devops_report.rb b/app/libs/charts/devops_report.rb index cc4ddd785..8fd52c4fc 100644 --- a/app/libs/charts/devops_report.rb +++ b/app/libs/charts/devops_report.rb @@ -1,67 +1,67 @@ class Charts::DevopsReport include Memery - include Loggable using RefinedString - def self.all(train) - new(train).all - end + def self.warm(train) = new(train).warm - def initialize(train) - @train = train - end + def self.all(train) = new(train).all - attr_reader :train + def initialize(train) = @train = train + + def warm + cache.write(cache_key, report) + rescue => e + elog(e) + end def all + cache.fetch(cache_key) + end + + def report { mobile_devops: { duration: { - x_axis: duration.keys, - series: [duration.values], - legends: ["duration"], + data: duration, type: "area", value_format: "time", name: "devops.duration" }, frequency: { - x_axis: frequency.keys, - series: [frequency.values], - legends: ["releases"], + data: frequency, type: "area", value_format: "number", name: "devops.frequency" }, time_in_review: { - x_axis: time_in_review.keys, - series: [time_in_review.values], - legends: ["time"], + data: time_in_review, type: "area", value_format: "time", name: "devops.time_in_review" }, hotfixes: { - x_axis: hotfixes.keys, - series: [hotfixes.values.pluck("android"), hotfixes.values.pluck("ios")], - legends: %w[android ios], + data: hotfixes, type: "area", value_format: "number", name: "devops.hotfixes" }, time_in_phases: { - x_axis: time_in_phases.keys, - series: time_in_phases.values.map(&:values), - legends: time_in_phases.values.map(&:keys).first, + data: time_in_phases, + stacked: true, type: "stacked-bar", value_format: "time", name: "devops.time_in_phases" } }, operational_efficiency: { + stability_contributors: { + data: release_stability_contributors, + type: "line", + value_format: "number", + name: "operational_efficiency.stability_contributors" + }, contributors: { - x_axis: contributors.keys, - series: [contributors.values], - legends: ["contributors"], + data: contributors, type: "line", value_format: "number", name: "operational_efficiency.contributors" @@ -74,40 +74,36 @@ def all LAST_TIME_PERIOD = 6 memoize def duration(last: LAST_RELEASES) - train - .releases - .includes(:release_platform_runs) - .limit(last) - .finished + finished_releases(last) .group_by(&:release_version) .sort_by { |v, _| v.to_semverish }.to_h - .transform_values { _1.first.duration } + .transform_values { {duration: _1.first.duration.seconds} } end memoize def frequency(period = :month, format = "%b %y", last: LAST_TIME_PERIOD) - train - .releases - .limit(last) - .finished + finished_releases(last) .reorder("") .group_by_period(period, :completed_at, last: last, current: true, format:) - .size + .count + .transform_values { {releases: _1} } end - memoize def contributors(last: LAST_RELEASES) - train - .releases - .includes(:release_platform_runs, :all_commits) - .limit(last) - .finished + memoize def release_stability_contributors(last: LAST_RELEASES) + finished_releases(last) .group_by(&:release_version) .sort_by { |v, _| v.to_semverish }.to_h .transform_values { _1.flat_map(&:all_commits).flat_map(&:author_email) } - .transform_values { _1.uniq.size } + .transform_values { {contributors: _1.uniq.size} } + end + + memoize def contributors(last: LAST_RELEASES) + finished_releases(last) + .group_by(&:release_version) + .sort_by { |v, _| v.to_semverish }.to_h + .transform_values { {contributors: _1.flat_map(&:release_changelog).compact.flat_map(&:unique_authors).size} } end memoize def time_in_review - # group by release (no platform split req.) train .external_releases .includes(deployment_run: [:deployment, {step_run: {release_platform_run: [:release]}}]) @@ -116,41 +112,42 @@ def all .group_by(&:build_version) .sort_by { |v, _| v.to_semverish }.to_h .transform_values { _1.flat_map(&:review_time) } - .transform_values { _1.sum(&:seconds) / _1.size.to_f } + .transform_values { {time: _1.sum(&:seconds) / _1.size.to_f} } end memoize def hotfixes(last: LAST_RELEASES) - train - .releases - .limit(last) - .finished - .includes(step_runs: :deployment_runs) - .flat_map(&:step_runs) - .flat_map(&:deployment_runs) - .filter { _1.production_release_happened? } - .group_by(&:release_platform_run) - .to_h { |platform_run, druns| [platform_run.release.release_version, {platform_run.platform => druns.size - 1}] } - .sort_by { |v, _| v.to_semverish }.to_h + by_version = + finished_releases(last) + .flat_map(&:step_runs) + .flat_map(&:deployment_runs) + .filter { _1.production_release_happened? } + .group_by { _1.release.release_version } + .sort_by { |v, _| v.to_semverish }.to_h + + by_version.transform_values do |runs| + runs.group_by(&:platform).transform_values { |d| d.size.pred } + end end def recovery_time # group by rel # recovery time: time between last rollout and next hotfix (line graph, across platforms) + raise NotImplementedError end memoize def time_in_phases(last: LAST_RELEASES) - train - .releases - .limit(last) - .finished - .includes(:release_platform_runs, step_runs: :step) - .flat_map(&:step_runs) - .group_by(&:release_version) - .sort_by { |v, _| v.to_semverish }.to_h - .transform_values do |step_runs| - step_runs - .group_by(&:name) - .transform_values { _1.pluck(:updated_at).max - _1.pluck(:scheduled_at).min } + by_version = + finished_releases(last) + .flat_map(&:step_runs) + .group_by(&:release_version) + .sort_by { |v, _| v.to_semverish }.to_h + + by_version.transform_values do |runs| + runs.group_by(&:platform).transform_values do |by_platform| + by_platform.group_by(&:name).transform_values do + _1.pluck(:updated_at).max - _1.pluck(:scheduled_at).min + end + end end end @@ -162,4 +159,25 @@ def ci_workflow_time def automations_run raise NotImplementedError end + + private + + attr_reader :train + delegate :cache, to: Rails + + memoize def finished_releases(n) + train + .releases + .limit(n) + .finished + .includes(:release_changelog, :release_platform_runs, :all_commits, step_runs: [:deployment_runs, :step]) + end + + def cache_key + "train/#{train.id}/devops_report" + end + + def thaw + cache.delete(cache_key) + end end diff --git a/app/models/release.rb b/app/models/release.rb index 58b1e5912..2e6860ba8 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -387,7 +387,7 @@ def on_finish! update_train_version event_stamp!(reason: :finished, kind: :success, data: {version: release_version}) notify!("Release has finished!", :release_ended, notification_params.merge(finalize_phase_metadata)) - Queries::ReleaseSummary.warm(id) + RefreshReportsJob.perform_later(id) end def update_train_version diff --git a/app/models/release_changelog.rb b/app/models/release_changelog.rb index fb6e01b7f..64bc5e0c7 100644 --- a/app/models/release_changelog.rb +++ b/app/models/release_changelog.rb @@ -22,6 +22,10 @@ def commit_messages commits.pluck("message") end + def unique_authors + commits.pluck("author_name").uniq + end + private class NormalizedCommit diff --git a/app/models/train.rb b/app/models/train.rb index 7077432d1..5d5bb7bd6 100644 --- a/app/models/train.rb +++ b/app/models/train.rb @@ -371,6 +371,14 @@ def schedule_editable? draft? || !automatic? || !persisted? end + def devops_report?(user) + user.release_health? && releases.size > 1 + end + + def devops_report + Charts::DevopsReport.all(self) + end + private def train_link diff --git a/app/views/apps/show.html.erb b/app/views/apps/show.html.erb index cc00e2cd4..33b4bfc46 100644 --- a/app/views/apps/show.html.erb +++ b/app/views/apps/show.html.erb @@ -65,26 +65,6 @@ <%= render partial: "apps/setup_progress", locals: { setup_instructions: @app_setup_instructions } %> <% end %> - <% if current_user.release_health? %> -
-

Mobile DevOps

-
- <%= render ChartComponent.new(@devops_report[:mobile_devops][:frequency]) %> - <%= render ChartComponent.new(@devops_report[:mobile_devops][:duration]) %> - <%= render ChartComponent.new(@devops_report[:mobile_devops][:hotfixes]) %> - <%= render ChartComponent.new(@devops_report[:mobile_devops][:time_in_review]) %> - <%= render ChartComponent.new(@devops_report[:mobile_devops][:time_in_phases]) %> -
-
- -
-

Operational Efficiency

-
- <%= render ChartComponent.new(@devops_report[:operational_efficiency][:contributors]) %> -
-
- <% end %> - <% unless @app.guided_train_setup? %>
<% if @app.cross_platform? %> diff --git a/app/views/trains/show.html.erb b/app/views/trains/show.html.erb index 03c1c6b28..54619084e 100644 --- a/app/views/trains/show.html.erb +++ b/app/views/trains/show.html.erb @@ -53,6 +53,7 @@ <%= render partial: "shared/draft_mode_notice", locals: { app: @app } %> +
@@ -209,6 +210,26 @@
<% end %> + <% if @devops_report.present? %> +
+

Mobile DevOps

+
+ <%= render ChartComponent.new(@devops_report[:mobile_devops][:frequency], icon: "cog.svg") %> + <%= render ChartComponent.new(@devops_report[:mobile_devops][:duration], icon: "cog.svg") %> + <%= render ChartComponent.new(@devops_report[:mobile_devops][:hotfixes], icon: "cog.svg") %> + <%= render ChartComponent.new(@devops_report[:mobile_devops][:time_in_review], icon: "cog.svg") %> + <%= render ChartComponent.new(@devops_report[:mobile_devops][:time_in_phases], icon: "cog.svg") %> +
+
+ +
+

Operational Efficiency

+
+ <%= render ChartComponent.new(@devops_report[:operational_efficiency][:contributors], icon: "user.svg") %> + <%= render ChartComponent.new(@devops_report[:operational_efficiency][:stability_contributors], icon: "user.svg") %> +
+
+ <% end %> <% @train.release_platforms.each do |release_platform| %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 4a8141332..4b8799fe3 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -253,20 +253,30 @@ en: duration: title: "Release Duration" scope: "Last 5 releases" + help_text: "How long does the release take?" frequency: title: "Releases Frequency" scope: "Last 6 months" + help_text: "" time_in_review: title: "Time in Review" scope: "Last 5 releases" + help_text: "" hotfixes: title: "Fixes during release" scope: "Last 5 releases" + help_text: "" time_in_phases: title: "Duration across Steps" scope: "Last 5 releases" + help_text: "" operational_efficiency: contributors: title: "Contributors" scope: "Last 5 releases" + help_text: "" + stability_contributors: + title: "Stability Contributors" + scope: "Last 5 releases" + help_text: ""