diff --git a/src/api/app/assets/stylesheets/webui/workflow_runs.scss b/src/api/app/assets/stylesheets/webui/workflow_runs.scss index 38f61b11c3c..eb10944c3f0 100644 --- a/src/api/app/assets/stylesheets/webui/workflow_runs.scss +++ b/src/api/app/assets/stylesheets/webui/workflow_runs.scss @@ -1,3 +1,5 @@ +$workflow-runs-filter-box-height: 3.5rem; + #request-payload { max-height: 20rem; } @@ -7,3 +9,39 @@ max-height: 30rem; } } + +#workflow-runs-filter-desktop { + strong { cursor: pointer; } + .collapse { + &.show { border-top: 1px solid $gray-300;} + } +} + +@include media-breakpoint-up(md) { + #workflow-runs-filter-desktop { + .collapse { display: block !important; } + } +} + +@include media-breakpoint-between(xs, sm) { + #workflow-runs-filter-desktop { + &.show { border-top: 1px solid $gray-300; } + &.sticky-top { top: $top-navigation-height; } + height: $workflow-runs-filter-box-height; + // To not overlap with the notification action bar + z-index: calc(#{$zindex-sticky} + 1); + + #filters { + max-height: 100vw; overflow: auto; + -webkit-box-shadow: 2px 3px 5px rgba(0,0,0,.2); + -moz-box-shadow: 2px 3px 5px rgba(0,0,0,.2); + box-shadow: 2px 3px 5px rgba(0,0,0,.2); + } + } + + @media (orientation: landscape) { + #workflow-runs-filter-desktop { + #filters { max-height: 20vw; } + } + } +} diff --git a/src/api/app/components/workflow_run_filter_component.html.haml b/src/api/app/components/workflow_run_filter_component.html.haml new file mode 100644 index 00000000000..d550b88ac0b --- /dev/null +++ b/src/api/app/components/workflow_run_filter_component.html.haml @@ -0,0 +1,21 @@ +.row.list-group-flush + = render WorkflowRunFilterLinkComponent.new(token: @token, text: 'All', amount: @count['all'], + filter_item: {}, selected_filter: @selected_filter) +.row.list-group-flush.mt-5 + %h5.ml-3 Status + = render WorkflowRunFilterLinkComponent.new(token: @token, text: 'Succeeded', amount: @count['success'], + filter_item: { status: 'success' }, selected_filter: @selected_filter) + = render WorkflowRunFilterLinkComponent.new(token: @token, text: 'Running', amount: @count['running'], + filter_item: { status: 'running' }, selected_filter: @selected_filter) + = render WorkflowRunFilterLinkComponent.new(token: @token, text: 'Failed', amount: @count['fail'], + filter_item: { status: 'fail' }, selected_filter: @selected_filter) +.row.list-group-flush.mt-5 + %h5.ml-3 Event type + = render WorkflowRunFilterLinkComponent.new(token: @token, text: 'Pull Requests', amount: @count['pull_request'], + filter_item: { event_type: 'pull_request' }, selected_filter: @selected_filter) + = render WorkflowRunFilterLinkComponent.new(token: @token, text: 'Push', amount: @count['push'], + filter_item: { event_type: 'push' }, selected_filter: @selected_filter) + = render WorkflowRunFilterLinkComponent.new(token: @token, text: 'Merge Request Hook', amount: @count['Merge Request Hook'], + filter_item: { event_type: 'Merge Request Hook' }, selected_filter: @selected_filter) + = render WorkflowRunFilterLinkComponent.new(token: @token, text: 'Push Hook', amount: @count['Push Hook'], + filter_item: { event_type: 'Push Hook' }, selected_filter: @selected_filter) diff --git a/src/api/app/components/workflow_run_filter_component.rb b/src/api/app/components/workflow_run_filter_component.rb new file mode 100644 index 00000000000..b49de2e3791 --- /dev/null +++ b/src/api/app/components/workflow_run_filter_component.rb @@ -0,0 +1,17 @@ +class WorkflowRunFilterComponent < ApplicationComponent + def initialize(token:, selected_filter:, finder: WorkflowRunsFinder.new) + super + + @count = workflow_runs_count(finder) + @selected_filter = selected_filter + @token = token + end + + def workflow_runs_count(finder) + counted_workflow_runs = {} + counted_workflow_runs['success'] = finder.succeeded.count + counted_workflow_runs['running'] = finder.running.count + counted_workflow_runs['fail'] = finder.failed.count + counted_workflow_runs.merge(finder.group_by_event_type) + end +end diff --git a/src/api/app/components/workflow_run_filter_link_component.html.haml b/src/api/app/components/workflow_run_filter_link_component.html.haml new file mode 100644 index 00000000000..c35a106d6c3 --- /dev/null +++ b/src/api/app/components/workflow_run_filter_link_component.html.haml @@ -0,0 +1,4 @@ += link_to(token_workflow_runs_path(@token, @filter_item), class: "list-group-item list-group-item-action #{css_for_link}") do + = @text + - if @amount.positive? + %span.badge.align-text-top.ml-2{ class: css_for_badge_color }>= @amount diff --git a/src/api/app/components/workflow_run_filter_link_component.rb b/src/api/app/components/workflow_run_filter_link_component.rb new file mode 100644 index 00000000000..37b4ce52b3d --- /dev/null +++ b/src/api/app/components/workflow_run_filter_link_component.rb @@ -0,0 +1,31 @@ +class WorkflowRunFilterLinkComponent < ApplicationComponent + def initialize(text:, filter_item:, selected_filter:, token:, amount:) + super + + @text = text + @filter_item = filter_item + @selected_filter = selected_filter + @amount = amount || 0 + @token = token + end + + def css_for_link + workflow_run_filter_matches? ? 'active' : '' + end + + def css_for_badge_color + workflow_run_filter_matches? ? 'badge-light' : 'badge-primary' + end + + private + + def workflow_run_filter_matches? + if @selected_filter[:status].present? + @filter_item[:status] == @selected_filter[:status] + elsif @selected_filter[:event_type].present? + @filter_item[:event_type] == @selected_filter[:event_type] + elsif @selected_filter.empty? + @filter_item.empty? + end + end +end diff --git a/src/api/app/components/workflow_run_row_component.rb b/src/api/app/components/workflow_run_row_component.rb index 6d0cd92560a..53192733f92 100644 --- a/src/api/app/components/workflow_run_row_component.rb +++ b/src/api/app/components/workflow_run_row_component.rb @@ -13,13 +13,14 @@ class WorkflowRunRowComponent < ApplicationComponent 'Push Hook' => ['commits', 0, 'url'] }.freeze - attr_reader :workflow_run, :status + attr_reader :workflow_run, :status, :hook_event def initialize(workflow_run:) super @workflow_run = workflow_run @status = workflow_run.status + @hook_event = workflow_run.hook_event end def hook_action @@ -27,11 +28,6 @@ def hook_action return payload.dig('object_attributes', 'action') if merge_request_with_allowed_action end - def hook_event - parsed_request_headers['HTTP_X_GITHUB_EVENT'] || - parsed_request_headers['HTTP_X_GITLAB_EVENT'] - end - def repository_name payload.dig('repository', 'full_name') || # For GitHub payload.dig('repository', 'name') # For GitLab @@ -43,16 +39,17 @@ def repository_url end def event_source_name - path = SOURCE_NAME_PAYLOAD_MAPPING[hook_event] + path = SOURCE_NAME_PAYLOAD_MAPPING[@hook_event] payload.dig(*path) if path end def event_source_url - payload.dig(*SOURCE_URL_PAYLOAD_MAPPING[hook_event]) + mapped_source_url = SOURCE_URL_PAYLOAD_MAPPING[@hook_event] + payload.dig(*mapped_source_url) if mapped_source_url end def formatted_event_source_name - case hook_event + case @hook_event when 'pull_request', 'Merge Request Hook' "##{event_source_name}" else @@ -85,13 +82,6 @@ def status_icon private - def parsed_request_headers - workflow_run.request_headers.split("\n").each_with_object({}) do |h, headers| - k, v = h.split(':') - headers[k] = v.strip - end - end - def payload @payload ||= JSON.parse(workflow_run.request_payload) rescue JSON::ParserError @@ -99,12 +89,12 @@ def payload end def pull_request_with_allowed_action - hook_event == 'pull_request' && + @hook_event == 'pull_request' && ScmWebhookEventValidator::ALLOWED_PULL_REQUEST_ACTIONS.include?(payload['action']) end def merge_request_with_allowed_action - hook_event == 'Merge Request Hook' && + @hook_event == 'Merge Request Hook' && ScmWebhookEventValidator::ALLOWED_MERGE_REQUEST_ACTIONS.include?(payload.dig('object_attributes', 'action')) end end diff --git a/src/api/app/controllers/webui/workflow_runs_controller.rb b/src/api/app/controllers/webui/workflow_runs_controller.rb index 4f00fd60a5c..97e41290c0d 100644 --- a/src/api/app/controllers/webui/workflow_runs_controller.rb +++ b/src/api/app/controllers/webui/workflow_runs_controller.rb @@ -1,6 +1,19 @@ class Webui::WorkflowRunsController < Webui::WebuiController def index - @workflow_runs = WorkflowRunPolicy::Scope.new(User.session, WorkflowRun, { token_id: params[:token_id] }).resolve.page(params[:page]) + relation = WorkflowRunPolicy::Scope.new(User.session, WorkflowRun, { token_id: params[:token_id] }) + workflow_runs_finder = WorkflowRunsFinder.new(relation.resolve) + + @workflow_runs = if params[:status] + workflow_runs_finder.with_status(params[:status]) + elsif params[:event_type] + workflow_runs_finder.with_event_type(params[:event_type]) + else + workflow_runs_finder.all + end + + @workflow_runs = @workflow_runs.page(params[:page]) + + @selected_filter = selected_filter @token = Token::Workflow.find(params[:token_id]) end @@ -10,4 +23,8 @@ def show @token = @workflow_run.token end + + def selected_filter + { event_type: params[:event_type], status: params[:status] }.compact + end end diff --git a/src/api/app/models/workflow_run.rb b/src/api/app/models/workflow_run.rb index 7e2463f2831..b70fd8c02b2 100644 --- a/src/api/app/models/workflow_run.rb +++ b/src/api/app/models/workflow_run.rb @@ -16,6 +16,20 @@ class WorkflowRun < ApplicationRecord def update_to_fail(message) update(response_body: message, status: 'fail') end + + def hook_event + parsed_request_headers['HTTP_X_GITHUB_EVENT'] || + parsed_request_headers['HTTP_X_GITLAB_EVENT'] + end + + private + + def parsed_request_headers + request_headers.split("\n").each_with_object({}) do |h, headers| + k, v = h.split(':') + headers[k] = v.strip + end + end end # == Schema Information diff --git a/src/api/app/queries/workflow_runs_finder.rb b/src/api/app/queries/workflow_runs_finder.rb new file mode 100644 index 00000000000..b6cc43bd52f --- /dev/null +++ b/src/api/app/queries/workflow_runs_finder.rb @@ -0,0 +1,37 @@ +class WorkflowRunsFinder + def initialize(relation = WorkflowRun.all) + @relation = relation.order(created_at: :desc) + end + + def all + @relation.all + end + + def group_by_event_type + @relation.all.each_with_object(Hash.new(0)) do |workflow_run, grouped_workflows| + grouped_workflows[workflow_run.hook_event] += 1 + end + end + + def with_event_type(event_type) + allowed_events = ScmWebhookEventValidator::ALLOWED_GITHUB_EVENTS + ScmWebhookEventValidator::ALLOWED_GITLAB_EVENTS + filtered_event_type = '%' + ([event_type] & allowed_events).first + '%' + @relation.where('request_headers LIKE ?', filtered_event_type) + end + + def with_status(status) + @relation.where(status: status) + end + + def succeeded + with_status('success') + end + + def running + with_status('running') + end + + def failed + with_status('fail') + end +end diff --git a/src/api/app/views/webui/workflow_runs/index.html.haml b/src/api/app/views/webui/workflow_runs/index.html.haml index 500ce884d09..08f695d9ab9 100644 --- a/src/api/app/views/webui/workflow_runs/index.html.haml +++ b/src/api/app/views/webui/workflow_runs/index.html.haml @@ -1,17 +1,27 @@ - @pagetitle = 'Workflow Runs' -.card - .card-body - %h3= @pagetitle - - if @workflow_runs.blank? - %p There are no workflow runs for this token yet - - else - .text-center - %span.ml-3= page_entries_info(@workflow_runs) - .accordion.pt-3#workflow-runs-accordion - - @workflow_runs.each do |workflow_run| - .card-header{ id: "workflow-run-heading#{workflow_run.id}" } - .mb-0 - .row - = render(WorkflowRunRowComponent.new(workflow_run: workflow_run)) - = paginate @workflow_runs, views_prefix: 'webui' +.row + .col-md-4.col-lg-3.px-0.px-md-3.sticky-top#workflow-runs-filter-desktop + .card.mb-3 + %strong.d-block.d-md-none.p-3{ data: { toggle: 'collapse', target: '#filters' }, + aria: { expanded: true, controls: 'filters' } } + Filtered by: #{params[:status]&.humanize} + %i.float-right.mt-1.fa.fa-chevron-down#workflow-runs-dropdown-trigger + .card-body.collapse#filters + = render WorkflowRunFilterComponent.new(token: @token, selected_filter: @selected_filter) + .col-md-8.col-lg-9.px-0.px-md-3#workflow-run-list + .card + - if @workflow_runs.blank? + .card-body + %p There are no workflow runs for this token yet + - else + .card-body + .text-center + %span.ml-3= page_entries_info(@workflow_runs) + .accordion.pt-3#workflow-runs-accordion + - @workflow_runs.each do |workflow_run| + .card-header{ id: "workflow-run-heading#{workflow_run.id}" } + .mb-0 + .row + = render(WorkflowRunRowComponent.new(workflow_run: workflow_run)) + = paginate @workflow_runs, views_prefix: 'webui' diff --git a/src/api/spec/components/workflow_run_filter_component_spec.rb b/src/api/spec/components/workflow_run_filter_component_spec.rb new file mode 100644 index 00000000000..11c13bec3af --- /dev/null +++ b/src/api/spec/components/workflow_run_filter_component_spec.rb @@ -0,0 +1,47 @@ +require 'rails_helper' + +RSpec.describe WorkflowRunFilterComponent, type: :component do + let(:token) { create(:workflow_token) } + let(:stub_finder) do + instance_double('WorkflowRunsFinder', + succeeded: [:workflow_run], + running: [:workflow_run, :workflow_run], + failed: [:workflow_run, :workflow_run, :workflow_run], + group_by_event_type: { pull_request: [:workflow_run] }) + end + let(:selected_filter) { {} } + + before do + render_inline(described_class.new(token: token, selected_filter: selected_filter, finder: stub_finder)) + end + + it 'renders a link to receive all workflow runs' do + expect(rendered_component).to have_css('a.active', text: 'All') + end + + context 'status filter links' do + it 'renders the succeeded filter' do + expect(rendered_component).to have_css('a', text: 'Succeeded') + end + + it 'renders the failed filter' do + expect(rendered_component).to have_css('a', text: 'Failed') + end + + it 'renders the running filter' do + expect(rendered_component).to have_css('a', text: 'Running') + end + end + + context 'event type filter links' do + it 'renders the push event filters' do + expect(rendered_component).to have_css('a', text: 'Push') + expect(rendered_component).to have_css('a', text: 'Push Hook') + end + + it 'renders the pull request event filters' do + expect(rendered_component).to have_css('a', text: 'Pull Requests') + expect(rendered_component).to have_css('a', text: 'Merge Request Hook') + end + end +end diff --git a/src/api/spec/components/workflow_run_filter_link_component_spec.rb b/src/api/spec/components/workflow_run_filter_link_component_spec.rb new file mode 100644 index 00000000000..b65efb8b245 --- /dev/null +++ b/src/api/spec/components/workflow_run_filter_link_component_spec.rb @@ -0,0 +1,72 @@ +require 'rails_helper' + +RSpec.describe WorkflowRunFilterLinkComponent, type: :component do + let(:workflow_token) { build_stubbed(:workflow_token, id: 1) } + + describe 'status filter links' do + before do + render_inline(described_class.new(token: workflow_token, text: 'Succeeded', amount: 1, + filter_item: filter_item, selected_filter: selected_filter)) + end + + context 'when the selected filter matches the filter item' do + let(:filter_item) { { status: 'success' } } + let(:selected_filter) { { status: 'success' } } + let(:link_selector) { 'a.active[href="/my/tokens/1/workflow_runs?status=success"]' } + + it 'displays link with active class and a light badge' do + expect(rendered_component).to have_css("#{link_selector} span.badge.badge-light") + end + end + + context 'when the selected filter does not match the filter item' do + let(:filter_item) { { status: 'success' } } + let(:selected_filter) { { status: 'fail' } } + let(:link_selector) { 'a[href="/my/tokens/1/workflow_runs?status=success"]' } + + it 'displays link without active class and a primary badge' do + expect(rendered_component).to have_css("#{link_selector} span.badge.badge-primary") + end + end + end + + describe 'event type filter links' do + before do + render_inline(described_class.new(token: workflow_token, text: 'Pull Request', amount: 1, + filter_item: filter_item, selected_filter: selected_filter)) + end + + context 'when the selected filter matches the filter item' do + let(:filter_item) { { event_type: 'pull_request' } } + let(:selected_filter) { { event_type: 'pull_request' } } + let(:link_selector) { 'a.active[href="/my/tokens/1/workflow_runs?event_type=pull_request"]' } + + it 'displays link with active class and a light badge' do + expect(rendered_component).to have_css("#{link_selector} span.badge.badge-light") + end + end + + context 'when the selected filter does not match the filter item' do + let(:filter_item) { { event_type: 'pull_request' } } + let(:selected_filter) { { event_type: 'push' } } + let(:link_selector) { 'a[href="/my/tokens/1/workflow_runs?event_type=pull_request"]' } + + it 'displays link without active class and a primary badge' do + expect(rendered_component).to have_css("#{link_selector} span.badge.badge-primary") + end + end + end + + context 'when the amount of workflow runs for the filter is zero' do + let(:link_selector) { 'a.active[href="/my/tokens/1/workflow_runs?status=success"]' } + + before do + render_inline(described_class.new(token: workflow_token, text: 'Succeeded', amount: 0, + filter_item: { status: 'success' }, selected_filter: { status: 'success' })) + end + + it 'does not show a badge for the displayed filter link' do + expect(rendered_component).not_to have_css("#{link_selector} span.badge") + end + end +end diff --git a/src/api/spec/components/workflow_run_row_component_spec.rb b/src/api/spec/components/workflow_run_row_component_spec.rb index 282dd6df1ef..49d82b75ede 100644 --- a/src/api/spec/components/workflow_run_row_component_spec.rb +++ b/src/api/spec/components/workflow_run_row_component_spec.rb @@ -342,5 +342,25 @@ expect(rendered_component).to have_selector('i', class: 'fas fa-exclamation-triangle text-danger') end end + + context 'when the event is something unknown' do + let(:workflow_run) do + create(:workflow_run, + status: 'fail', + token: workflow_token, + request_headers: request_headers, + request_payload: request_payload) + end + let(:request_payload) { {} } + let(:request_headers) do + <<~END_OF_HEADERS + HTTP_X_GITHUB_EVENT: fake + END_OF_HEADERS + end + + it 'does not blow up' do + expect(rendered_component).to have_text('Fake event') + end + end end end diff --git a/src/api/spec/queries/workflow_runs_finder_spec.rb b/src/api/spec/queries/workflow_runs_finder_spec.rb new file mode 100644 index 00000000000..448b6f85f1e --- /dev/null +++ b/src/api/spec/queries/workflow_runs_finder_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +RSpec.describe WorkflowRunsFinder do + let(:workflow_token) { create(:workflow_token) } + + before do + ScmWebhookEventValidator::ALLOWED_GITHUB_EVENTS.each do |event| + create(:workflow_run, token: workflow_token, status: 'running', request_headers: "HTTP_X_GITHUB_EVENT: #{event}\n") + end + + ScmWebhookEventValidator::ALLOWED_GITLAB_EVENTS.each do |event| + create(:workflow_run, token: workflow_token, status: 'running', request_headers: "HTTP_X_GITLAB_EVENT: #{event}\n") + end + + ['success', 'running', 'fail'].each do |status| + create(:workflow_run, token: workflow_token, status: status, request_headers: "HTTP_X_GITHUB_EVENT: pull_request\n") + end + end + + subject { described_class.new } + + describe '#all' do + it 'returns all workflow runs' do + expect(subject.all.count).to eq(8) + end + end + + describe '#group_by_event_type' do + it 'returns a hash with the amount of workflow runs grouped by event' do + expect(subject.group_by_event_type).to include({ 'pull_request' => 4, 'push' => 1, 'Merge Request Hook' => 1, 'Push Hook' => 1, 'Tag Push Hook' => 1 }) + end + end + + describe '#succeeded' do + it 'returns all workflow runs with status success' do + expect(subject.succeeded.count).to eq(1) + end + end + + describe '#running' do + it 'returns all workflow runs with status running' do + expect(subject.running.count).to eq(6) + end + end + + describe '#failed' do + it 'returns all workflow runs with status fail' do + expect(subject.failed.count).to eq(1) + end + end +end