From 87ada2d4f393c257e0e8266168db393d1e19bbbe Mon Sep 17 00:00:00 2001 From: nicolachr Date: Thu, 21 May 2026 09:32:40 +0300 Subject: [PATCH 01/11] [wip] add static BI index page for CE Co-Authored-By: Claude Sonnet 4.6 --- app/assets/javascripts/hera.js | 1 + app/assets/javascripts/hera/pages/bi_index.js | 7 + app/assets/stylesheets/hera/views.scss | 1 + .../stylesheets/hera/views/_static_pages.scss | 92 +++++++++++ app/controllers/static_pages_controller.rb | 49 ++++++ app/helpers/static_pages_helper.rb | 33 ++++ .../hera/navbar/main_nav/_tools_menu.html.erb | 1 + app/views/static_pages/bi_index.html.erb | 150 ++++++++++++++++++ config/routes.rb | 1 + 9 files changed, 335 insertions(+) create mode 100644 app/assets/javascripts/hera/pages/bi_index.js create mode 100644 app/assets/stylesheets/hera/views/_static_pages.scss create mode 100644 app/helpers/static_pages_helper.rb create mode 100644 app/views/static_pages/bi_index.html.erb diff --git a/app/assets/javascripts/hera.js b/app/assets/javascripts/hera.js index 7ff9cedc2f..67bb0081ef 100644 --- a/app/assets/javascripts/hera.js +++ b/app/assets/javascripts/hera.js @@ -62,6 +62,7 @@ //= require hera/modules/uploads //= require hera/pages/activities +//= require hera/pages/bi_index //= require hera/pages/boards //= require hera/pages/issues //= require hera/pages/nodes/new_form diff --git a/app/assets/javascripts/hera/pages/bi_index.js b/app/assets/javascripts/hera/pages/bi_index.js new file mode 100644 index 0000000000..cd31f9a9b9 --- /dev/null +++ b/app/assets/javascripts/hera/pages/bi_index.js @@ -0,0 +1,7 @@ +document.addEventListener('turbo:load', function () { + if ($('body.static_pages.bi_index').length) { + $('[data-behavior~=widget-filter]').on('change', function (e) { + e.target.closest('form').requestSubmit(); + }); + } +}); diff --git a/app/assets/stylesheets/hera/views.scss b/app/assets/stylesheets/hera/views.scss index 7bfede4e35..1d19099154 100644 --- a/app/assets/stylesheets/hera/views.scss +++ b/app/assets/stylesheets/hera/views.scss @@ -1,4 +1,5 @@ @import 'hera/views/activities'; +@import 'hera/views/static_pages'; @import 'hera/views/boards'; @import 'hera/views/issues/import'; @import 'hera/views/issues/issues'; diff --git a/app/assets/stylesheets/hera/views/_static_pages.scss b/app/assets/stylesheets/hera/views/_static_pages.scss new file mode 100644 index 0000000000..f2eb37d82a --- /dev/null +++ b/app/assets/stylesheets/hera/views/_static_pages.scss @@ -0,0 +1,92 @@ +@import 'hera/variables'; + +.content-container { + &.chart { + .progress-wrap { + margin-bottom: 0.25rem; + + .issue-title { + margin-bottom: 0; + width: 25rem; + } + + .progress { + background-color: transparent; + border-radius: 0; + + .progress-bar { + background-color: var(--brand-bg); + border-radius: 0; + } + } + } + + &.top-issues { + min-height: 26.5rem; + } + } +} + +body.static_pages.bi_index { + .content-container { + display: flex; + flex-direction: column; + justify-content: center; + min-height: 12.5rem; + + .bg-icon { + font-size: 2.5rem; + opacity: 0.08; + position: absolute; + right: 0; + top: 0; + + i { + color: var(--brand-bg); + } + } + + .delta { + align-items: center; + color: var(--text-muted); + display: flex; + font-size: 1.2rem; + margin-bottom: 0; + + &.negative { + color: var(--text-error); + } + + &.positive { + color: var(--text-success); + } + + &-icon { + font-size: 0.65rem; + margin: 0 0.25rem 0.1rem 0; + } + } + + .title { + line-height: 2.3rem; + } + + .form-container { + flex-grow: 1; + margin-left: 1rem; + max-width: 12rem; + } + + &.pro-teaser { + color: var(--text-default); + } + } +} + +a:has(.pro-teaser) { + text-decoration: none; + + &:hover .pro-teaser { + border-color: var(--brand-border); + } +} diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index 9666d7e8b7..98d9da3bbb 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -1,7 +1,10 @@ class StaticPagesController < AuthenticatedController + before_action :set_bi_stats, only: [:bi_index] before_action :set_entries, only: [:issuelib_index, :issuelib_import] before_action :set_tickets, only: [:remediationtracker_index] + def bi_index; end + def issuelib_index; end def issuelib_import @@ -17,6 +20,52 @@ def remediationtracker_index; end private + def set_bi_stats + current_year_start = Time.current.beginning_of_year + last_year_start = 1.year.ago.beginning_of_year + last_year_end = 1.year.ago + + issuelib_ids = Node.where(type_id: Node::Types::ISSUELIB).pluck(:id) + issues = Issue.where(node_id: issuelib_ids) + + @bi_tags = Tag.joins(:taggings).where(taggings: { taggable_type: Issue.base_class.name, taggable_id: issues.select(:id) }).distinct + @selected_tag = params[:tag].presence + @selected_tag = nil unless @bi_tags.exists?(name: @selected_tag) + + filtered_issues = @selected_tag ? issues.joins(:tags).where(tags: { name: @selected_tag }) : issues + + current_issues_count = filtered_issues.where(created_at: current_year_start..Time.current).count + last_issues_count = filtered_issues.where(created_at: last_year_start..last_year_end).count + + current_users_count = User.where(created_at: current_year_start..Time.current).count + last_users_count = User.where(created_at: last_year_start..last_year_end).count + + @bi_projects = { current_year_count: 1, last_year_count: 1, yoy_delta: 0 } + @bi_issues = { + current_year_count: current_issues_count, + last_year_count: last_issues_count, + yoy_delta: yoy_delta(current_issues_count, last_issues_count) + } + @bi_contributors = { + current_year_count: current_users_count, + last_year_count: last_users_count, + yoy_delta: yoy_delta(current_users_count, last_users_count) + } + @bi_top_issues = filtered_issues.where(created_at: current_year_start..Time.current) + .group_by(&:title) + .map { |title, group| { title: title, count: group.size } } + .sort_by { |stats| -stats[:count] } + .first(10) + end + + def yoy_delta(current, previous) + if previous.zero? + current > 0 ? 100 : 0 + else + ((current - previous).to_f / previous * 100).round + end + end + def set_entries @entries = issuelib_entries end diff --git a/app/helpers/static_pages_helper.rb b/app/helpers/static_pages_helper.rb new file mode 100644 index 0000000000..2882b08c34 --- /dev/null +++ b/app/helpers/static_pages_helper.rb @@ -0,0 +1,33 @@ +module StaticPagesHelper + def delta_icon(delta) + case + when delta > 0 then 'fa-arrow-up' + when delta < 0 then 'fa-arrow-down' + else 'fa-arrows-up-down' + end + end + + def tag_options(tags) + [ + [ + 'All', + nil, + { data: { + 'combobox-option-color': 'var(--text-default)', + 'combobox-option-icon': 'fa-solid fa-tags' + } } + ] + ] + tags.uniq(&:name).map do |tag| + [ + tag.display_name, + tag.name, + { + data: { + 'combobox-option-color': tag.color, + 'combobox-option-icon': 'fa-solid fa-tag' + } + } + ] + end + end +end diff --git a/app/views/layouts/hera/navbar/main_nav/_tools_menu.html.erb b/app/views/layouts/hera/navbar/main_nav/_tools_menu.html.erb index a7dec21ae1..775f57a424 100644 --- a/app/views/layouts/hera/navbar/main_nav/_tools_menu.html.erb +++ b/app/views/layouts/hera/navbar/main_nav/_tools_menu.html.erb @@ -8,6 +8,7 @@ <%= render_view_hooks('tools_menu') %> <% if !defined?(Dradis::Pro) %>
  • +
  • <%= link_to 'Business Intelligence', main_app.static_bi_path, class: 'dropdown-item' %>
  • <%= link_to 'Issue Library', main_app.static_issuelib_path, class: 'dropdown-item' %>
  • <%= link_to 'Remediation Tracker', main_app.static_remediationtracker_path, class: 'dropdown-item' %>
  • <% end %> diff --git a/app/views/static_pages/bi_index.html.erb b/app/views/static_pages/bi_index.html.erb new file mode 100644 index 0000000000..23197b50fd --- /dev/null +++ b/app/views/static_pages/bi_index.html.erb @@ -0,0 +1,150 @@ +<% content_for :title, 'Business Intelligence' %> + +<% content_for :breadcrumbs do %> + +<% end %> + +<% content_for :sidebar do %> +
    +
    +
    BI Dashboard Tips
    +
    + <%= link_to '#bi-page-tips', data: { bs_toggle: 'collapse', behavior: 'collapse-collection' } do %> + Toggle update state + <% end %> +
    +
    +
    +
    +
    +

    The BI Dashboard surfaces stats and analytics across your engagements so you can identify trends and draw conclusions from your security data.

    +

    Get an overview of your Projects and Issues from the current year, and surface your most common findings to prioritize your security investments.

    + +
    +
    +<% end %> + +

    Your security posture at a glance

    +

    Year-over-year insights into your risk reduction

    + +
    +
    +
    +
    +
    +
    + +
    +

    Projects

    +

    <%= @bi_projects[:current_year_count] %>

    +

    created this year

    +
    + <%= @bi_projects[:yoy_delta].abs %>% +
    +

    from <%= @bi_projects[:last_year_count] %> during Jan 1 - <%= Date.today.prev_year.strftime('%b %d %Y') %>

    +
    +
    +
    + + +
    +
    +
    +
    + +
    +

    Teams

    +
    + Pro +

    Track team performance and year-over-year growth across your organisation.

    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +

    Issues

    +
    + <%= form_with url: static_bi_path, method: :get do |form| %> +
    + <%= form.select :tag, options_for_select(tag_options(@bi_tags), @selected_tag), {}, data: { behavior: 'widget-filter' }, class: 'form-select' %> +
    + <% end %> +
    +
    +

    <%= @bi_issues[:current_year_count] %>

    +

    created this year

    +
    + <%= @bi_issues[:yoy_delta].abs %>% +
    +

    from <%= @bi_issues[:last_year_count] %> during Jan 1 - <%= Date.today.prev_year.strftime('%b %d %Y') %>

    +
    +
    +
    + +
    +
    +
    +
    + +
    +

    Contributors

    +

    <%= @bi_contributors[:current_year_count] %>

    +

    active this year

    +
    + <%= @bi_contributors[:yoy_delta].abs %>% +
    +

    from <%= @bi_contributors[:last_year_count] %> during Jan 1 - <%= Date.today.prev_year.strftime('%b %d %Y') %>

    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +

    Most Common Issues

    + created this year, across projects, by title +
    +
    +
    + <% if @bi_top_issues.any? %> + <% max_count = @bi_top_issues.first[:count].to_f %> + <% @bi_top_issues.each do |issue| %> +
    +

    <%= issue[:title] %>

    +
    + <% percent = (issue[:count].to_f / max_count) * 100 %> +
    +
    +
    +
    + <% end %> + <% else %> +

    No issues created this year yet.

    + <% end %> +
    +
    +
    +
    +
    diff --git a/config/routes.rb b/config/routes.rb index 6bbfe11a41..bb307ab829 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,6 +8,7 @@ get 'projects/1/addons/issuelib', to: 'static_pages#issuelib_index', as: :static_issuelib get 'projects/1/addons/issuelib/import', to: 'static_pages#issuelib_import', as: :static_issuelib_import get 'projects/1/addons/remediationtracker', to: 'static_pages#remediationtracker_index', as: :static_remediationtracker + get 'addons/bi', to: 'static_pages#bi_index', as: :static_bi end # ------------------------------------------------------------ Authentication From e8f7b7025aef3ea0630d32e2e98768f1221eb69a Mon Sep 17 00:00:00 2001 From: nicolachr Date: Wed, 10 Jun 2026 16:14:16 +0300 Subject: [PATCH 02/11] Make BI dashboard widgets update independently via Turbo Frames Each widget now has its own route and partial so tag filtering only refreshes the relevant widget, not the full page. Issue bar colors reflect the tag of each finding. --- app/assets/javascripts/hera/pages/bi_index.js | 8 +++- app/controllers/static_pages_controller.rb | 43 +++++++++++++------ app/helpers/static_pages_helper.rb | 4 ++ app/views/static_pages/bi_index.html.erb | 38 +++++----------- .../bi_index/_issue_stats.html.erb | 13 ++++++ .../bi_index/_top_issues.html.erb | 28 ++++++++++++ config/routes.rb | 2 + 7 files changed, 95 insertions(+), 41 deletions(-) create mode 100644 app/views/static_pages/bi_index/_issue_stats.html.erb create mode 100644 app/views/static_pages/bi_index/_top_issues.html.erb diff --git a/app/assets/javascripts/hera/pages/bi_index.js b/app/assets/javascripts/hera/pages/bi_index.js index cd31f9a9b9..d1bcd2a53b 100644 --- a/app/assets/javascripts/hera/pages/bi_index.js +++ b/app/assets/javascripts/hera/pages/bi_index.js @@ -1,7 +1,13 @@ document.addEventListener('turbo:load', function () { if ($('body.static_pages.bi_index').length) { $('[data-behavior~=widget-filter]').on('change', function (e) { - e.target.closest('form').requestSubmit(); + const form = e.target.closest('form'); + const frameId = form.dataset.turboFrame; + if (frameId) { + $(`#${frameId} [data-behavior~=fetch-loader]`).removeClass('d-none'); + $(`#${frameId} [data-behavior~=widget-content]`).addClass('d-none'); + } + form.requestSubmit(); }); } }); diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index 98d9da3bbb..31c0644001 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -1,10 +1,19 @@ class StaticPagesController < AuthenticatedController before_action :set_bi_stats, only: [:bi_index] + before_action :set_bi_issues_data, only: [:bi_insights_issues, :bi_insights_top_issues] before_action :set_entries, only: [:issuelib_index, :issuelib_import] before_action :set_tickets, only: [:remediationtracker_index] def bi_index; end + def bi_insights_issues + render partial: 'static_pages/bi_index/issue_stats' + end + + def bi_insights_top_issues + render partial: 'static_pages/bi_index/top_issues' + end + def issuelib_index; end def issuelib_import @@ -21,6 +30,24 @@ def remediationtracker_index; end private def set_bi_stats + set_bi_issues_data + + current_year_start = Time.current.beginning_of_year + last_year_start = 1.year.ago.beginning_of_year + last_year_end = 1.year.ago + + current_users_count = User.where(created_at: current_year_start..Time.current).count + last_users_count = User.where(created_at: last_year_start..last_year_end).count + + @bi_projects = { current_year_count: 1, last_year_count: 1, yoy_delta: 0 } + @bi_contributors = { + current_year_count: current_users_count, + last_year_count: last_users_count, + yoy_delta: yoy_delta(current_users_count, last_users_count) + } + end + + def set_bi_issues_data current_year_start = Time.current.beginning_of_year last_year_start = 1.year.ago.beginning_of_year last_year_end = 1.year.ago @@ -37,23 +64,15 @@ def set_bi_stats current_issues_count = filtered_issues.where(created_at: current_year_start..Time.current).count last_issues_count = filtered_issues.where(created_at: last_year_start..last_year_end).count - current_users_count = User.where(created_at: current_year_start..Time.current).count - last_users_count = User.where(created_at: last_year_start..last_year_end).count - - @bi_projects = { current_year_count: 1, last_year_count: 1, yoy_delta: 0 } - @bi_issues = { + @bi_issues = { current_year_count: current_issues_count, last_year_count: last_issues_count, yoy_delta: yoy_delta(current_issues_count, last_issues_count) } - @bi_contributors = { - current_year_count: current_users_count, - last_year_count: last_users_count, - yoy_delta: yoy_delta(current_users_count, last_users_count) - } - @bi_top_issues = filtered_issues.where(created_at: current_year_start..Time.current) + @bi_top_issues = filtered_issues.where(created_at: current_year_start..Time.current) + .includes(:tags) .group_by(&:title) - .map { |title, group| { title: title, count: group.size } } + .map { |title, group| { title: title, count: group.size, issue: group.first } } .sort_by { |stats| -stats[:count] } .first(10) end diff --git a/app/helpers/static_pages_helper.rb b/app/helpers/static_pages_helper.rb index 2882b08c34..7833bbd0ee 100644 --- a/app/helpers/static_pages_helper.rb +++ b/app/helpers/static_pages_helper.rb @@ -1,4 +1,8 @@ module StaticPagesHelper + def issue_bar_color(issue) + issue.tags.first&.color || 'var(--untagged-color)' + end + def delta_icon(delta) case when delta > 0 then 'fa-arrow-up' diff --git a/app/views/static_pages/bi_index.html.erb b/app/views/static_pages/bi_index.html.erb index 23197b50fd..ba3ed21b9d 100644 --- a/app/views/static_pages/bi_index.html.erb +++ b/app/views/static_pages/bi_index.html.erb @@ -77,19 +77,14 @@

    Issues

    - <%= form_with url: static_bi_path, method: :get do |form| %> + <%= form_with url: static_bi_insights_issues_path, method: :get, data: { turbo: true, turbo_frame: 'issue_stats' } do |form| %>
    <%= form.select :tag, options_for_select(tag_options(@bi_tags), @selected_tag), {}, data: { behavior: 'widget-filter' }, class: 'form-select' %>
    <% end %>
    -

    <%= @bi_issues[:current_year_count] %>

    -

    created this year

    -
    - <%= @bi_issues[:yoy_delta].abs %>% -
    -

    from <%= @bi_issues[:last_year_count] %> during Jan 1 - <%= Date.today.prev_year.strftime('%b %d %Y') %>

    + <%= render 'static_pages/bi_index/issue_stats' %> @@ -120,29 +115,16 @@

    Most Common Issues

    created this year, across projects, by title - -
    - <% if @bi_top_issues.any? %> - <% max_count = @bi_top_issues.first[:count].to_f %> - <% @bi_top_issues.each do |issue| %> -
    -

    <%= issue[:title] %>

    -
    - <% percent = (issue[:count].to_f / max_count) * 100 %> -
    -
    -
    +
    + <%= form_with url: static_bi_insights_top_issues_path, method: :get, data: { turbo: true, turbo_frame: 'top_issues' } do |form| %> +
    + <%= form.select :tag, options_for_select(tag_options(@bi_tags), @selected_tag), {}, data: { behavior: 'widget-filter' }, class: 'form-select' %>
    <% end %> - <% else %> -

    No issues created this year yet.

    - <% end %> +
    +
    +
    + <%= render 'static_pages/bi_index/top_issues' %>
    diff --git a/app/views/static_pages/bi_index/_issue_stats.html.erb b/app/views/static_pages/bi_index/_issue_stats.html.erb new file mode 100644 index 0000000000..4a50391d7f --- /dev/null +++ b/app/views/static_pages/bi_index/_issue_stats.html.erb @@ -0,0 +1,13 @@ +<%= turbo_frame_tag 'issue_stats' do %> +
    + <%= spinner_tag %> +
    +
    +

    <%= @bi_issues[:current_year_count] %>

    +

    created this year

    +
    + <%= @bi_issues[:yoy_delta].abs %>% +
    +

    from <%= @bi_issues[:last_year_count] %> during Jan 1 - <%= Date.today.prev_year.strftime('%b %d %Y') %>

    +
    +<% end %> diff --git a/app/views/static_pages/bi_index/_top_issues.html.erb b/app/views/static_pages/bi_index/_top_issues.html.erb new file mode 100644 index 0000000000..1069f13e0d --- /dev/null +++ b/app/views/static_pages/bi_index/_top_issues.html.erb @@ -0,0 +1,28 @@ +<%= turbo_frame_tag 'top_issues' do %> +
    + <%= spinner_tag %> +
    +
    + <% if @bi_top_issues.any? %> + <% max_count = @bi_top_issues.first[:count].to_f %> + <% @bi_top_issues.each do |issue| %> +
    +

    <%= issue[:title] %>

    +
    + <% percent = (issue[:count].to_f / max_count) * 100 %> +
    +
    +
    +
    + <% end %> + <% else %> +

    No issues created this year yet.

    + <% end %> +
    +<% end %> diff --git a/config/routes.rb b/config/routes.rb index bb307ab829..3ec13ab86f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,6 +9,8 @@ get 'projects/1/addons/issuelib/import', to: 'static_pages#issuelib_import', as: :static_issuelib_import get 'projects/1/addons/remediationtracker', to: 'static_pages#remediationtracker_index', as: :static_remediationtracker get 'addons/bi', to: 'static_pages#bi_index', as: :static_bi + get 'addons/bi/insights/issues', to: 'static_pages#bi_insights_issues', as: :static_bi_insights_issues + get 'addons/bi/insights/top-issues', to: 'static_pages#bi_insights_top_issues', as: :static_bi_insights_top_issues end # ------------------------------------------------------------ Authentication From 193f6e300f4d5ddb0741e4ec53ffab67fcf04945 Mon Sep 17 00:00:00 2001 From: nicolachr Date: Thu, 11 Jun 2026 09:53:17 +0300 Subject: [PATCH 03/11] move BI partials to flat structure and refactor controller Flatten the bi_index/ partial subdirectory into static_pages/ with bi_ prefix, matching the rest of the app. Refactor the controller to put private methods in alphabetical order, use Issue.all instead of a non-existent issuelib filter, short-circuit Tag.exists? before filtering, capture Time.current once, and move top project/team property data into dedicated private methods. Add Teams stat to set_bi_stats. Update CSS to scope all BI min-height overrides inside the body selector block to fix specificity. --- .../stylesheets/hera/views/_static_pages.scss | 28 ++- app/controllers/static_pages_controller.rb | 221 +++++++++++------- .../_bi_top_project_properties.html.erb | 37 +++ .../_bi_top_team_properties.html.erb | 37 +++ .../bi_index/_issue_stats.html.erb | 13 -- .../bi_index/_top_issues.html.erb | 28 --- config/routes.rb | 10 +- 7 files changed, 223 insertions(+), 151 deletions(-) create mode 100644 app/views/static_pages/_bi_top_project_properties.html.erb create mode 100644 app/views/static_pages/_bi_top_team_properties.html.erb delete mode 100644 app/views/static_pages/bi_index/_issue_stats.html.erb delete mode 100644 app/views/static_pages/bi_index/_top_issues.html.erb diff --git a/app/assets/stylesheets/hera/views/_static_pages.scss b/app/assets/stylesheets/hera/views/_static_pages.scss index f2eb37d82a..90017489da 100644 --- a/app/assets/stylesheets/hera/views/_static_pages.scss +++ b/app/assets/stylesheets/hera/views/_static_pages.scss @@ -21,13 +21,16 @@ } } - &.top-issues { - min-height: 26.5rem; - } } } body.static_pages.bi_index { + .form-container { + flex-grow: 1; + margin-left: 1rem; + max-width: 12rem; + } + .content-container { display: flex; flex-direction: column; @@ -71,22 +74,17 @@ body.static_pages.bi_index { line-height: 2.3rem; } - .form-container { - flex-grow: 1; - margin-left: 1rem; - max-width: 12rem; + &.chart.property-item { + min-height: 18rem; + } + + &.chart.top-issues { + min-height: 26.5rem; } &.pro-teaser { color: var(--text-default); + cursor: pointer; } } } - -a:has(.pro-teaser) { - text-decoration: none; - - &:hover .pro-teaser { - border-color: var(--brand-border); - } -} diff --git a/app/controllers/static_pages_controller.rb b/app/controllers/static_pages_controller.rb index 31c0644001..c1e76ac6cf 100644 --- a/app/controllers/static_pages_controller.rb +++ b/app/controllers/static_pages_controller.rb @@ -7,11 +7,11 @@ class StaticPagesController < AuthenticatedController def bi_index; end def bi_insights_issues - render partial: 'static_pages/bi_index/issue_stats' + render partial: 'static_pages/bi_issue_stats' end def bi_insights_top_issues - render partial: 'static_pages/bi_index/top_issues' + render partial: 'static_pages/bi_top_issues' end def issuelib_index; end @@ -29,47 +29,121 @@ def remediationtracker_index; end private - def set_bi_stats - set_bi_issues_data - - current_year_start = Time.current.beginning_of_year - last_year_start = 1.year.ago.beginning_of_year - last_year_end = 1.year.ago + def bi_top_project_properties + [ + { + name: 'Web Application', + current_year_count: 12, + last_year_count: 9, + yoy_delta: 33, + top_issues: [ + { title: 'Cross-Site Scripting (XSS)', count: 12, percent: 100, color: '#d62728' }, + { title: 'SQL Injection', count: 10, percent: 83, color: '#d62728' }, + { title: 'Cross-Site Request Forgery (CSRF)', count: 7, percent: 58, color: '#ff7f0e' } + ] + }, + { + name: 'Network Pentest', + current_year_count: 6, + last_year_count: 8, + yoy_delta: -25, + top_issues: [ + { title: 'Security Misconfiguration', count: 6, percent: 100, color: '#ff7f0e' }, + { title: 'Outdated SSL/TLS Configuration', count: 4, percent: 67, color: '#ff7f0e' }, + { title: 'Missing HTTP Security Headers', count: 2, percent: 33, color: '#6baed6' } + ] + } + ] + end - current_users_count = User.where(created_at: current_year_start..Time.current).count - last_users_count = User.where(created_at: last_year_start..last_year_end).count + def bi_top_team_properties + [ + { + name: 'Financial Services', + current_year_count: 8, + last_year_count: 5, + yoy_delta: 60, + top_issues: [ + { title: 'SQL Injection', count: 8, percent: 100, color: '#d62728' }, + { title: 'Cross-Site Scripting (XSS)', count: 6, percent: 75, color: '#d62728' }, + { title: 'Broken Authentication', count: 4, percent: 50, color: '#9467bd' } + ] + }, + { + name: 'Healthcare', + current_year_count: 5, + last_year_count: 3, + yoy_delta: 67, + top_issues: [ + { title: 'Sensitive Data Exposure', count: 5, percent: 100, color: '#9467bd' }, + { title: 'Insufficient Logging', count: 3, percent: 60, color: '#6baed6' }, + { title: 'SQL Injection', count: 2, percent: 40, color: '#d62728' } + ] + } + ] + end - @bi_projects = { current_year_count: 1, last_year_count: 1, yoy_delta: 0 } - @bi_contributors = { - current_year_count: current_users_count, - last_year_count: last_users_count, - yoy_delta: yoy_delta(current_users_count, last_users_count) - } + def issuelib_entries + [ + { + title: 'Auto-complete in password field', + state: 'published', + created_at: Date.today - 3.months, + updated_at: Date.today - 1.week + }, + { + title: 'DOM-based cross-site scripting (XSS)', + state: 'published', + created_at: Date.today - 6.weeks, + updated_at: Date.today - 3.days + }, + { + title: 'Insufficient cross-site request forgery (CSRF) protection', + state: 'published', + created_at: Date.today - 2.months, + updated_at: Date.today - 2.weeks + }, + { + title: 'Reflected cross-site scripting (XSS)', + state: 'published', + created_at: Date.today - 5.weeks, + updated_at: Date.today - 4.days + }, + { + title: 'Insecure Direct Object Reference (IDOR)', + state: 'ready_for_review', + created_at: Date.today - 2.weeks, + updated_at: Date.today - 1.day + }, + { + title: 'Server-Side Request Forgery (SSRF)', + state: 'draft', + created_at: Date.today - 3.days, + updated_at: Date.today + } + ] end def set_bi_issues_data current_year_start = Time.current.beginning_of_year - last_year_start = 1.year.ago.beginning_of_year - last_year_end = 1.year.ago - - issuelib_ids = Node.where(type_id: Node::Types::ISSUELIB).pluck(:id) - issues = Issue.where(node_id: issuelib_ids) + last_year_start = 1.year.ago.beginning_of_year + last_year_end = 1.year.ago - @bi_tags = Tag.joins(:taggings).where(taggings: { taggable_type: Issue.base_class.name, taggable_id: issues.select(:id) }).distinct @selected_tag = params[:tag].presence - @selected_tag = nil unless @bi_tags.exists?(name: @selected_tag) + @selected_tag = nil unless @selected_tag && Tag.exists?(name: @selected_tag) - filtered_issues = @selected_tag ? issues.joins(:tags).where(tags: { name: @selected_tag }) : issues + filtered_issues = @selected_tag ? Issue.joins(:tags).where(tags: { name: @selected_tag }) : Issue.all + now = Time.current - current_issues_count = filtered_issues.where(created_at: current_year_start..Time.current).count - last_issues_count = filtered_issues.where(created_at: last_year_start..last_year_end).count + current_issues_count = filtered_issues.where(created_at: current_year_start..now).count + last_issues_count = filtered_issues.where(created_at: last_year_start..last_year_end).count @bi_issues = { current_year_count: current_issues_count, - last_year_count: last_issues_count, - yoy_delta: yoy_delta(current_issues_count, last_issues_count) + last_year_count: last_issues_count, + yoy_delta: yoy_delta(current_issues_count, last_issues_count) } - @bi_top_issues = filtered_issues.where(created_at: current_year_start..Time.current) + @bi_top_issues = filtered_issues.where(created_at: current_year_start..now) .includes(:tags) .group_by(&:title) .map { |title, group| { title: title, count: group.size, issue: group.first } } @@ -77,12 +151,12 @@ def set_bi_issues_data .first(10) end - def yoy_delta(current, previous) - if previous.zero? - current > 0 ? 100 : 0 - else - ((current - previous).to_f / previous * 100).round - end + def set_bi_stats + @bi_contributors = { current_year_count: 4, last_year_count: 3, yoy_delta: 33 } + @bi_projects = { current_year_count: 1, last_year_count: 1, yoy_delta: 0 } + @bi_teams = { current_year_count: 14, last_year_count: 11, yoy_delta: 27 } + @bi_top_project_properties = bi_top_project_properties + @bi_top_team_properties = bi_top_team_properties end def set_entries @@ -96,78 +170,45 @@ def set_tickets def tickets [ { - title: 'SQL Injection in Login Form', + title: 'SQL Injection in Login Form', category: 'Security', - state: 'Open', + state: 'Open', assignee: 'James T. Kirk', - due_at: Date.today - 1.week, - overdue: true + due_at: Date.today - 1.week, + overdue: true }, { - title: 'Outdated SSL Certificate', + title: 'Outdated SSL Certificate', category: 'Infrastructure', - state: 'In Progress', + state: 'In Progress', assignee: 'Nyota Uhura', - due_at: Date.today, - overdue: true + due_at: Date.today, + overdue: true }, { - title: 'Cross-Site Scripting in Search Bar', + title: 'Cross-Site Scripting in Search Bar', category: 'Application', - state: 'On Hold', + state: 'On Hold', assignee: 'Montgomery Scott', - due_at: Date.today + 1.week, - overdue: false + due_at: Date.today + 1.week, + overdue: false }, { - title: 'Missing HTTP Security Headers', + title: 'Missing HTTP Security Headers', category: 'Compliance', - state: 'Closed', + state: 'Closed', assignee: nil, - due_at: Date.today + 1.month, - overdue: false + due_at: Date.today + 1.month, + overdue: false } ] end - def issuelib_entries - [ - { - title: 'Auto-complete in password field', - state: 'published', - created_at: Date.today - 3.months, - updated_at: Date.today - 1.week - }, - { - title: 'DOM-based cross-site scripting (XSS)', - state: 'published', - created_at: Date.today - 6.weeks, - updated_at: Date.today - 3.days - }, - { - title: 'Insufficient cross-site request forgery (CSRF) protection', - state: 'published', - created_at: Date.today - 2.months, - updated_at: Date.today - 2.weeks - }, - { - title: 'Reflected cross-site scripting (XSS)', - state: 'published', - created_at: Date.today - 5.weeks, - updated_at: Date.today - 4.days - }, - { - title: 'Insecure Direct Object Reference (IDOR)', - state: 'ready_for_review', - created_at: Date.today - 2.weeks, - updated_at: Date.today - 1.day - }, - { - title: 'Server-Side Request Forgery (SSRF)', - state: 'draft', - created_at: Date.today - 3.days, - updated_at: Date.today - } - ] + def yoy_delta(current, previous) + if previous.zero? + current > 0 ? 100 : 0 + else + ((current - previous).to_f / previous * 100).round + end end end diff --git a/app/views/static_pages/_bi_top_project_properties.html.erb b/app/views/static_pages/_bi_top_project_properties.html.erb new file mode 100644 index 0000000000..ab20e88ed4 --- /dev/null +++ b/app/views/static_pages/_bi_top_project_properties.html.erb @@ -0,0 +1,37 @@ +
    +

    Top Project Properties

    + +
    + +<% @bi_top_project_properties.each_with_index do |property, index| %> +
    +

    <%= property[:name] %>

    +
    +
    +

    <%= property[:current_year_count] %>

    + created this year +
    +
    +
    + <%= property[:yoy_delta].abs %>% +
    +

    from <%= property[:last_year_count] %> during Jan 1 - <%= Date.today.prev_year.strftime('%b %d %Y') %>

    +
    +
    +
    + Most common issues +
    + <% property[:top_issues].each do |issue| %> +
    +

    <%= issue[:title] %>

    +
    +
    +
    +
    + <% end %> +
    +<% end %> diff --git a/app/views/static_pages/_bi_top_team_properties.html.erb b/app/views/static_pages/_bi_top_team_properties.html.erb new file mode 100644 index 0000000000..977dec7d6f --- /dev/null +++ b/app/views/static_pages/_bi_top_team_properties.html.erb @@ -0,0 +1,37 @@ +
    +

    Top Team Properties

    + +
    + +<% @bi_top_team_properties.each_with_index do |property, index| %> +
    +

    <%= property[:name] %>

    +
    +
    +

    <%= property[:current_year_count] %>

    + created this year +
    +
    +
    + <%= property[:yoy_delta].abs %>% +
    +

    from <%= property[:last_year_count] %> during Jan 1 - <%= Date.today.prev_year.strftime('%b %d %Y') %>

    +
    +
    +
    + Most common issues +
    + <% property[:top_issues].each do |issue| %> +
    +

    <%= issue[:title] %>

    +
    +
    +
    +
    + <% end %> +
    +<% end %> diff --git a/app/views/static_pages/bi_index/_issue_stats.html.erb b/app/views/static_pages/bi_index/_issue_stats.html.erb deleted file mode 100644 index 4a50391d7f..0000000000 --- a/app/views/static_pages/bi_index/_issue_stats.html.erb +++ /dev/null @@ -1,13 +0,0 @@ -<%= turbo_frame_tag 'issue_stats' do %> -
    - <%= spinner_tag %> -
    -
    -

    <%= @bi_issues[:current_year_count] %>

    -

    created this year

    -
    - <%= @bi_issues[:yoy_delta].abs %>% -
    -

    from <%= @bi_issues[:last_year_count] %> during Jan 1 - <%= Date.today.prev_year.strftime('%b %d %Y') %>

    -
    -<% end %> diff --git a/app/views/static_pages/bi_index/_top_issues.html.erb b/app/views/static_pages/bi_index/_top_issues.html.erb deleted file mode 100644 index 1069f13e0d..0000000000 --- a/app/views/static_pages/bi_index/_top_issues.html.erb +++ /dev/null @@ -1,28 +0,0 @@ -<%= turbo_frame_tag 'top_issues' do %> -
    - <%= spinner_tag %> -
    -
    - <% if @bi_top_issues.any? %> - <% max_count = @bi_top_issues.first[:count].to_f %> - <% @bi_top_issues.each do |issue| %> -
    -

    <%= issue[:title] %>

    -
    - <% percent = (issue[:count].to_f / max_count) * 100 %> -
    -
    -
    -
    - <% end %> - <% else %> -

    No issues created this year yet.

    - <% end %> -
    -<% end %> diff --git a/config/routes.rb b/config/routes.rb index 3ec13ab86f..57b68eab02 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -15,7 +15,7 @@ # ------------------------------------------------------------ Authentication # Sign in / sign out - get '/login' => 'sessions#new' + get '/login' => 'sessions#new' get '/logout' => 'sessions#destroy' resource :session @@ -135,12 +135,12 @@ get 'trash' => 'revisions#trash' # ------------------------------------------------------- Export Manager - get '/export' => 'export#index', as: :export_manager + get '/export' => 'export#index', as: :export_manager # ------------------------------------------------------- Upload Manager - get '/upload' => 'upload#index', as: :upload_manager - post '/upload' => 'upload#create' - post '/upload/parse' => 'upload#parse' + get '/upload' => 'upload#index', as: :upload_manager + post '/upload' => 'upload#create' + post '/upload/parse' => 'upload#parse' end resources :console, only: [] do From 8fbcc0aab65bcaec83000020ecefe4a45f3342af Mon Sep 17 00:00:00 2001 From: nicolachr Date: Thu, 11 Jun 2026 09:53:44 +0300 Subject: [PATCH 04/11] fix widget interactions and content centering on filter Load the Issues and Top Issues widgets lazily via data-behavior=fetch, matching Pro's architecture. Rewrite bi_index.js to attach widget-filter change handlers on dradis:fetch and turbo:frame-render events so they bind after the fetch completes rather than at turbo:load. Add mb-auto to the top_issues turbo frame and move chart-wrap inside widget-content so justify-content-center on the fetch container no longer shifts content to the middle when filtering causes the frame to collapse. Make Teams and Contributors stat cards open the upsell modal via js-try-pro. --- app/assets/javascripts/hera/pages/bi_index.js | 27 ++++-- .../static_pages/_bi_issue_stats.html.erb | 28 ++++++ .../static_pages/_bi_top_issues.html.erb | 43 ++++++++++ app/views/static_pages/bi_index.html.erb | 85 ++++++++----------- 4 files changed, 126 insertions(+), 57 deletions(-) create mode 100644 app/views/static_pages/_bi_issue_stats.html.erb create mode 100644 app/views/static_pages/_bi_top_issues.html.erb diff --git a/app/assets/javascripts/hera/pages/bi_index.js b/app/assets/javascripts/hera/pages/bi_index.js index d1bcd2a53b..4e21c439bf 100644 --- a/app/assets/javascripts/hera/pages/bi_index.js +++ b/app/assets/javascripts/hera/pages/bi_index.js @@ -1,13 +1,26 @@ document.addEventListener('turbo:load', function () { if ($('body.static_pages.bi_index').length) { - $('[data-behavior~=widget-filter]').on('change', function (e) { - const form = e.target.closest('form'); - const frameId = form.dataset.turboFrame; - if (frameId) { - $(`#${frameId} [data-behavior~=fetch-loader]`).removeClass('d-none'); - $(`#${frameId} [data-behavior~=widget-content]`).addClass('d-none'); + $(document).on('dradis:fetch turbo:frame-render', function (event) { + const $widgetFilter = $( + event.target.closest('[data-behavior~=fetch]') + ).find('[data-behavior~=widget-filter]'); + + if ($widgetFilter.length) { + if (event.type == 'turbo:frame-render') { + window.initBehaviors( + event.target + .closest('[data-behavior~=fetch]') + .querySelector('[data-behavior~=widget-content]') + ); + } + + $widgetFilter.on('change', function (e) { + const $container = $(e.target).parents('[data-behavior~=fetch]'); + $container.find('[data-behavior~=fetch-loader]').removeClass('d-none'); + $container.find('[data-behavior~=widget-content]').addClass('d-none'); + e.target.closest('form').requestSubmit(); + }); } - form.requestSubmit(); }); } }); diff --git a/app/views/static_pages/_bi_issue_stats.html.erb b/app/views/static_pages/_bi_issue_stats.html.erb new file mode 100644 index 0000000000..e135708242 --- /dev/null +++ b/app/views/static_pages/_bi_issue_stats.html.erb @@ -0,0 +1,28 @@ +
    +
    + +
    +
    +

    Issues

    +
    + <%= form_with url: static_bi_insights_issues_path, method: :get, data: { turbo: true, turbo_frame: 'issue_stats' } do |form| %> +
    + <%= form.select :tag, options_for_select(tag_options(Tag.all), @selected_tag), {}, data: { behavior: 'widget-filter' }, class: 'form-select' %> +
    + <% end %> +
    +
    + <%= turbo_frame_tag 'issue_stats' do %> +
    + <%= spinner_tag %> +
    +
    +

    <%= @bi_issues[:current_year_count] %>

    +

    created this year

    +
    + <%= @bi_issues[:yoy_delta].abs %>% +
    +

    from <%= @bi_issues[:last_year_count] %> during Jan 1 - <%= Date.today.prev_year.strftime('%b %d %Y') %>

    +
    + <% end %> +
    diff --git a/app/views/static_pages/_bi_top_issues.html.erb b/app/views/static_pages/_bi_top_issues.html.erb new file mode 100644 index 0000000000..135c46d3f6 --- /dev/null +++ b/app/views/static_pages/_bi_top_issues.html.erb @@ -0,0 +1,43 @@ +
    +
    +

    Most Common Issues

    + created this year, across projects, by title +
    +
    + <%= form_with url: static_bi_insights_top_issues_path, method: :get, data: { turbo: true, turbo_frame: 'top_issues' } do |form| %> +
    + <%= form.select :tag, options_for_select(tag_options(Tag.all), @selected_tag), {}, data: { behavior: 'widget-filter' }, class: 'form-select' %> +
    + <% end %> +
    +
    +<%= turbo_frame_tag 'top_issues', class: 'mb-auto' do %> +
    + <%= spinner_tag %> +
    +
    +
    + <% if @bi_top_issues.any? %> + <% max_count = @bi_top_issues.first[:count].to_f %> + <% @bi_top_issues.each do |issue| %> +
    +

    <%= issue[:title] %>

    +
    + <% percent = (issue[:count].to_f / max_count) * 100 %> +
    +
    +
    +
    + <% end %> + <% else %> +

    No issues created this year yet.

    + <% end %> +
    +
    +<% end %> diff --git a/app/views/static_pages/bi_index.html.erb b/app/views/static_pages/bi_index.html.erb index ba3ed21b9d..99457ac126 100644 --- a/app/views/static_pages/bi_index.html.erb +++ b/app/views/static_pages/bi_index.html.erb @@ -32,7 +32,7 @@
    -
    +
    - -
    -
    +
    - +
    -
    -

    Issues

    -
    - <%= form_with url: static_bi_insights_issues_path, method: :get, data: { turbo: true, turbo_frame: 'issue_stats' } do |form| %> -
    - <%= form.select :tag, options_for_select(tag_options(@bi_tags), @selected_tag), {}, data: { behavior: 'widget-filter' }, class: 'form-select' %> -
    - <% end %> -
    -
    - <%= render 'static_pages/bi_index/issue_stats' %> +

    Teams

    +

    <%= @bi_teams[:current_year_count] %>

    +

    active this year

    +
    + <%= @bi_teams[:yoy_delta].abs %>% +
    +

    from <%= @bi_teams[:last_year_count] %> during Jan 1 - <%= Date.today.prev_year.strftime('%b %d %Y') %>

    +
    +
    +
    + <%= spinner_tag %> +
    +
    + +
    @@ -109,24 +96,22 @@
    -
    -
    -
    -

    Most Common Issues

    - created this year, across projects, by title -
    -
    - <%= form_with url: static_bi_insights_top_issues_path, method: :get, data: { turbo: true, turbo_frame: 'top_issues' } do |form| %> -
    - <%= form.select :tag, options_for_select(tag_options(@bi_tags), @selected_tag), {}, data: { behavior: 'widget-filter' }, class: 'form-select' %> -
    - <% end %> -
    -
    -
    - <%= render 'static_pages/bi_index/top_issues' %> -
    +
    + <%= spinner_tag %>
    + +
    +
    +
    + <%= render 'static_pages/bi_top_team_properties' %> +
    +
    +
    + <%= render 'static_pages/bi_top_project_properties' %> +
    +
    From fff40bf1464b9c8a0e9eeef03fab25ffdc8f7944 Mon Sep 17 00:00:00 2001 From: nicolachr Date: Thu, 11 Jun 2026 09:54:46 +0300 Subject: [PATCH 05/11] align BI routes with other static addon page routes Add the projects/1/ prefix to match issuelib and remediationtracker. --- config/routes.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/routes.rb b/config/routes.rb index 57b68eab02..6c6310f5e5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,9 +8,9 @@ get 'projects/1/addons/issuelib', to: 'static_pages#issuelib_index', as: :static_issuelib get 'projects/1/addons/issuelib/import', to: 'static_pages#issuelib_import', as: :static_issuelib_import get 'projects/1/addons/remediationtracker', to: 'static_pages#remediationtracker_index', as: :static_remediationtracker - get 'addons/bi', to: 'static_pages#bi_index', as: :static_bi - get 'addons/bi/insights/issues', to: 'static_pages#bi_insights_issues', as: :static_bi_insights_issues - get 'addons/bi/insights/top-issues', to: 'static_pages#bi_insights_top_issues', as: :static_bi_insights_top_issues + get 'projects/1/addons/bi', to: 'static_pages#bi_index', as: :static_bi + get 'projects/1/addons/bi/insights/issues', to: 'static_pages#bi_insights_issues', as: :static_bi_insights_issues + get 'projects/1/addons/bi/insights/top-issues', to: 'static_pages#bi_insights_top_issues', as: :static_bi_insights_top_issues end # ------------------------------------------------------------ Authentication From dff6a5af91c549934d3500b6882521490e75f030 Mon Sep 17 00:00:00 2001 From: nicolachr Date: Thu, 11 Jun 2026 09:57:05 +0300 Subject: [PATCH 06/11] add BI dashboard to CHANGELOG --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 03ea91d708..51a0a5dc59 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,4 +1,5 @@ [v#.#.#] ([month] [YYYY]) + - Show don't gate: Business Intelligence - [entity]: - [future tense verb] [feature] - Upgraded gems: From f772d1bc86bee003738b78ab40faa851b9589215 Mon Sep 17 00:00:00 2001 From: nicolachr Date: Thu, 11 Jun 2026 10:32:04 +0300 Subject: [PATCH 07/11] address peer review findings Prevent change handler accumulation by calling .off('change') before .on('change') in bi_index.js so each turbo:frame-render doesn't stack an additional listener on the persisted widget-filter element. Move the SCSS import to its correct alphabetical position. Rewrite the CHANGELOG entry to follow the Entity: future-tense-verb convention. --- CHANGELOG | 2 +- app/assets/javascripts/hera/pages/bi_index.js | 2 +- app/assets/stylesheets/hera/views.scss | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 51a0a5dc59..fb1323d057 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,5 @@ [v#.#.#] ([month] [YYYY]) - - Show don't gate: Business Intelligence + - Business Intelligence: add static BI dashboard with year-over-year insights - [entity]: - [future tense verb] [feature] - Upgraded gems: diff --git a/app/assets/javascripts/hera/pages/bi_index.js b/app/assets/javascripts/hera/pages/bi_index.js index 4e21c439bf..7ae58fe888 100644 --- a/app/assets/javascripts/hera/pages/bi_index.js +++ b/app/assets/javascripts/hera/pages/bi_index.js @@ -14,7 +14,7 @@ document.addEventListener('turbo:load', function () { ); } - $widgetFilter.on('change', function (e) { + $widgetFilter.off('change').on('change', function (e) { const $container = $(e.target).parents('[data-behavior~=fetch]'); $container.find('[data-behavior~=fetch-loader]').removeClass('d-none'); $container.find('[data-behavior~=widget-content]').addClass('d-none'); diff --git a/app/assets/stylesheets/hera/views.scss b/app/assets/stylesheets/hera/views.scss index 1d19099154..0c63253416 100644 --- a/app/assets/stylesheets/hera/views.scss +++ b/app/assets/stylesheets/hera/views.scss @@ -1,5 +1,4 @@ @import 'hera/views/activities'; -@import 'hera/views/static_pages'; @import 'hera/views/boards'; @import 'hera/views/issues/import'; @import 'hera/views/issues/issues'; @@ -8,6 +7,7 @@ @import 'hera/views/noscript'; @import 'hera/views/projects'; @import 'hera/views/search'; +@import 'hera/views/static_pages'; @import 'hera/views/styles'; @import 'hera/views/uploads'; @import 'hera/views/versions'; From c3223f1b49146010f4eaab08740bdd71003766d8 Mon Sep 17 00:00:00 2001 From: nicolachr Date: Thu, 11 Jun 2026 10:35:29 +0300 Subject: [PATCH 08/11] revert CHANGELOG entry to show don't gate structure --- CHANGELOG | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index fb1323d057..51a0a5dc59 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,5 @@ [v#.#.#] ([month] [YYYY]) - - Business Intelligence: add static BI dashboard with year-over-year insights + - Show don't gate: Business Intelligence - [entity]: - [future tense verb] [feature] - Upgraded gems: From becb9d6bf274a88d814aecb199f74bb775283034 Mon Sep 17 00:00:00 2001 From: nicolachr Date: Thu, 11 Jun 2026 10:40:42 +0300 Subject: [PATCH 09/11] add request specs for BI insights endpoints Cover current-year date scoping, tag filtering (valid and nonexistent), top-issues grouping and ordering, the 10-result limit, and yoy_delta edge cases (both zero, previous zero with current > 0, normal case). --- spec/requests/static_pages/bi_spec.rb | 114 ++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 spec/requests/static_pages/bi_spec.rb diff --git a/spec/requests/static_pages/bi_spec.rb b/spec/requests/static_pages/bi_spec.rb new file mode 100644 index 0000000000..e956b8462a --- /dev/null +++ b/spec/requests/static_pages/bi_spec.rb @@ -0,0 +1,114 @@ +require 'rails_helper' + +describe 'BI insights' do + before { login_to_project_as_user } + + let(:current_year_start) { Time.current.beginning_of_year } + let(:last_year_start) { 1.year.ago.beginning_of_year } + let(:last_year_end) { 1.year.ago } + + def issue_text(title) + "#[Title]#\n#{title}\n\n#[Description]#\nFoo" + end + + describe 'GET /projects/1/addons/bi/insights/issues' do + it 'returns successfully with no data' do + get static_bi_insights_issues_path + expect(response).to be_successful + end + + it 'counts issues created this year only' do + create(:issue, text: issue_text('Current'), created_at: current_year_start + 1.day) + create(:issue, text: issue_text('Current'), created_at: current_year_start + 2.days) + create(:issue, text: issue_text('Old'), created_at: 2.years.ago) + get static_bi_insights_issues_path + expect(response.body).to match(/

    2<\/h4>/) + end + + context 'when filtering by a valid tag' do + let(:tag) { create(:tag, name: '!ff0000_critical') } + let!(:tagged_issue) do + issue = create(:issue, text: issue_text('Tagged'), created_at: current_year_start + 1.day) + issue.tags << tag + issue + end + let!(:untagged_issue) { create(:issue, text: issue_text('Untagged'), created_at: current_year_start + 1.day) } + + it 'returns only issues with that tag' do + get static_bi_insights_issues_path, params: { tag: '!ff0000_critical' } + expect(response).to be_successful + expect(response.body).to match(/

    1<\/h4>/) + end + end + + context 'when filtering by a nonexistent tag' do + let!(:issue) { create(:issue, created_at: current_year_start + 1.day) } + + it 'ignores the filter and returns all issues' do + get static_bi_insights_issues_path, params: { tag: 'nonexistent' } + expect(response).to be_successful + expect(response.body).to match(/

    1<\/h4>/) + end + end + end + + describe 'GET /projects/1/addons/bi/insights/top-issues' do + it 'returns successfully with no data' do + get static_bi_insights_top_issues_path + expect(response).to be_successful + end + + it 'groups issues by title and orders by count descending' do + 3.times { create(:issue, text: issue_text('SQL Injection'), created_at: current_year_start + 1.day) } + 1.times { create(:issue, text: issue_text('XSS'), created_at: current_year_start + 1.day) } + get static_bi_insights_top_issues_path + expect(response.body).to match(/SQL Injection.*XSS/m) + end + + it 'excludes issues from previous years' do + create(:issue, text: issue_text('Old Issue'), created_at: 2.years.ago) + get static_bi_insights_top_issues_path + expect(response.body).not_to include('Old Issue') + end + + it 'limits results to 10' do + 11.times { |i| create(:issue, text: issue_text("Issue #{i}"), created_at: current_year_start + 1.day) } + get static_bi_insights_top_issues_path + expect(response.body.scan('issue-title').size).to eq(10) + end + + context 'when filtering by a nonexistent tag' do + it 'ignores the filter and returns successfully' do + get static_bi_insights_top_issues_path, params: { tag: 'nonexistent' } + expect(response).to be_successful + end + end + end + + describe 'yoy_delta calculation' do + context 'when there were no issues last year' do + it 'returns 100% when there are issues this year' do + create(:issue, created_at: current_year_start + 1.day) + get static_bi_insights_issues_path + expect(response.body).to include('100') + end + + it 'returns 0% when there are no issues this year either' do + get static_bi_insights_issues_path + expect(response.body).to match(/fa-arrows-up-down.*0%/m) + end + end + + context 'when there were issues last year' do + before do + 2.times { create(:issue, created_at: last_year_start + 1.day) } + 4.times { create(:issue, created_at: current_year_start + 1.day) } + end + + it 'calculates the percentage change correctly' do + get static_bi_insights_issues_path + expect(response.body).to include('100') + end + end + end +end From 76ed700273fe87573f37241229a8a634f6c742a8 Mon Sep 17 00:00:00 2001 From: nicolachr Date: Thu, 11 Jun 2026 10:43:10 +0300 Subject: [PATCH 10/11] fix rubocop spacing offenses in bi spec --- spec/requests/static_pages/bi_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/requests/static_pages/bi_spec.rb b/spec/requests/static_pages/bi_spec.rb index e956b8462a..25c489d499 100644 --- a/spec/requests/static_pages/bi_spec.rb +++ b/spec/requests/static_pages/bi_spec.rb @@ -4,8 +4,8 @@ before { login_to_project_as_user } let(:current_year_start) { Time.current.beginning_of_year } - let(:last_year_start) { 1.year.ago.beginning_of_year } - let(:last_year_end) { 1.year.ago } + let(:last_year_start) { 1.year.ago.beginning_of_year } + let(:last_year_end) { 1.year.ago } def issue_text(title) "#[Title]#\n#{title}\n\n#[Description]#\nFoo" @@ -20,7 +20,7 @@ def issue_text(title) it 'counts issues created this year only' do create(:issue, text: issue_text('Current'), created_at: current_year_start + 1.day) create(:issue, text: issue_text('Current'), created_at: current_year_start + 2.days) - create(:issue, text: issue_text('Old'), created_at: 2.years.ago) + create(:issue, text: issue_text('Old'), created_at: 2.years.ago) get static_bi_insights_issues_path expect(response.body).to match(/

    2<\/h4>/) end @@ -60,7 +60,7 @@ def issue_text(title) it 'groups issues by title and orders by count descending' do 3.times { create(:issue, text: issue_text('SQL Injection'), created_at: current_year_start + 1.day) } - 1.times { create(:issue, text: issue_text('XSS'), created_at: current_year_start + 1.day) } + 1.times { create(:issue, text: issue_text('XSS'), created_at: current_year_start + 1.day) } get static_bi_insights_top_issues_path expect(response.body).to match(/SQL Injection.*XSS/m) end From ebc08657afa7c1ad52421da8d91b7d95f2746dd1 Mon Sep 17 00:00:00 2001 From: nicolachr Date: Tue, 30 Jun 2026 12:19:38 +0300 Subject: [PATCH 11/11] skip BI insights specs when running in Pro Static BI routes are CE-only (gated by !defined?(Dradis::Pro)), so the path helpers don't exist in Pro and the specs would fail. --- spec/requests/static_pages/bi_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/requests/static_pages/bi_spec.rb b/spec/requests/static_pages/bi_spec.rb index 25c489d499..7b7c548422 100644 --- a/spec/requests/static_pages/bi_spec.rb +++ b/spec/requests/static_pages/bi_spec.rb @@ -1,6 +1,6 @@ require 'rails_helper' -describe 'BI insights' do +describe 'BI insights', skip: defined?(Dradis::Pro) do before { login_to_project_as_user } let(:current_year_start) { Time.current.beginning_of_year }