diff --git a/app/assets/javascripts/filtered_search/project_artifacts_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/project_artifacts_filtered_search_token_keys.js
new file mode 100644
index 0000000000000000000000000000000000000000..f9c64182941e9c1bdcd4c05327e913d58cbcb5b7
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/project_artifacts_filtered_search_token_keys.js
@@ -0,0 +1,18 @@
+import FilteredSearchTokenKeys from './filtered_search_token_keys';
+
+const tokenKeys = [
+  {
+    key: 'deleted-branches',
+    type: 'string',
+    param: 'deleted-branches',
+    symbol: '',
+    icon: 'tag',
+    tag: 'Yes or No', // eslint-disable-line @gitlab/i18n/no-non-i18n-strings
+    lowercaseValueOnSubmit: true,
+    capitalizeTokenValue: true,
+  },
+];
+
+const ProjectArtifactsFilteredSearchTokenKeys = new FilteredSearchTokenKeys(tokenKeys);
+
+export default ProjectArtifactsFilteredSearchTokenKeys;
diff --git a/app/assets/javascripts/pages/constants.js b/app/assets/javascripts/pages/constants.js
index 5e119454ce177cbd5ff6ad7798a76f0d2ef697ab..000c06b917c2497753198aba1cbd2d4e8034ae79 100644
--- a/app/assets/javascripts/pages/constants.js
+++ b/app/assets/javascripts/pages/constants.js
@@ -3,5 +3,6 @@
 export const FILTERED_SEARCH = {
   MERGE_REQUESTS: 'merge_requests',
   ISSUES: 'issues',
+  ARTIFACTS: 'artifacts',
   ADMIN_RUNNERS: 'admin/runners',
 };
diff --git a/app/assets/javascripts/pages/projects/artifacts/index/index.js b/app/assets/javascripts/pages/projects/artifacts/index/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..f17128578d3f50e83c5b2b697850afedda821657
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/artifacts/index/index.js
@@ -0,0 +1,10 @@
+import initFilteredSearch from '~/pages/search/init_filtered_search';
+import ProjectArtifactsFilteredSearchTokenKeys from '~/filtered_search/project_artifacts_filtered_search_token_keys';
+import { FILTERED_SEARCH } from '~/pages/constants';
+
+document.addEventListener('DOMContentLoaded', () => {
+  initFilteredSearch({
+    page: FILTERED_SEARCH.ARTIFACTS,
+    filteredSearchTokenKeys: ProjectArtifactsFilteredSearchTokenKeys,
+  });
+});
diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index da8a371acaa36dae8bc77f524784a2c7a5430161..caddc0bcbd425dfe7f87b0384fe4546d2307c844 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -8,10 +8,24 @@ class Projects::ArtifactsController < Projects::ApplicationController
   layout 'project'
   before_action :authorize_read_build!
   before_action :authorize_update_build!, only: [:keep]
+  before_action :authorize_destroy_artifacts!, only: [:destroy]
   before_action :extract_ref_name_and_path
-  before_action :validate_artifacts!, except: [:download]
+  before_action :validate_artifacts!, except: [:index, :download, :destroy]
   before_action :entry, only: [:file]
 
+  def index
+    finder = JobsWithArtifactsFinder.new(project: @project, params: params)
+    @jobs_with_artifacts = finder.execute
+    @total_size = finder.total_size
+    @sort = finder.sort_key
+  end
+
+  def destroy
+    build.erase_erasable_artifacts!
+
+    redirect_to project_artifacts_path(@project), status: :found, notice: _('Artifacts were successfully deleted.')
+  end
+
   def download
     return render_404 unless artifacts_file
 
diff --git a/app/finders/jobs_with_artifacts_finder.rb b/app/finders/jobs_with_artifacts_finder.rb
new file mode 100644
index 0000000000000000000000000000000000000000..95c8c089e88da5a2f400881f5b14ce59b4d75710
--- /dev/null
+++ b/app/finders/jobs_with_artifacts_finder.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+# TODO: This is only temporary so we can get a review app running
+# rubocop:disable CodeReuse/ActiveRecord
+
+class JobsWithArtifactsFinder
+  NUMBER_OF_JOBS_PER_PAGE = 30
+
+  def initialize(project:, params:)
+    @project = project
+    @params = params
+  end
+
+  def execute
+    jobs = jobs_with_size
+    jobs = filter_by_name(jobs)
+    jobs = filter_by_deleted_branches(jobs)
+    jobs = sorted(jobs)
+    jobs = paginated(jobs)
+    jobs
+  end
+
+  def total_size
+    job_ids = @project.builds.select(:id)
+
+    @project.builds.where(id: job_ids).sum(:artifacts_size) +
+      @project.job_artifacts.where(job_id: job_ids).sum(:size)
+  end
+
+  def sort_key
+    @params[:sort].presence || 'created_asc'
+  end
+
+  private
+
+  def filter_by_name(jobs)
+    return jobs if @params[:search].blank?
+
+    jobs.search(@params[:search])
+  end
+
+  def filter_by_deleted_branches(jobs)
+    deleted_branches = @params[:'deleted_branches_deleted-branches']
+
+    return jobs if deleted_branches.blank?
+
+    deleted_branches = ActiveModel::Type::Boolean.new.cast(deleted_branches)
+
+    if deleted_branches
+      jobs.where.not(ref: @project.repository.ref_names)
+    else
+      jobs.where(ref: @project.repository.ref_names)
+    end
+  end
+
+  def sorted(jobs)
+    jobs.order_by(sort_key)
+  end
+
+  def paginated(jobs)
+    jobs.page(@params[:page]).per(NUMBER_OF_JOBS_PER_PAGE).without_count
+  end
+
+  def jobs_with_size
+    @project.builds.with_sum_artifacts_size
+  end
+end
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index a4eb76a23599018abe231aaa321e85d4dff670f4..537c654150b8f0136983d234db670dfe5b31efb8 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -26,7 +26,9 @@ def sort_options_hash
       sort_value_priority          => sort_title_priority,
       sort_value_upvotes           => sort_title_upvotes,
       sort_value_contacted_date    => sort_title_contacted_date,
-      sort_value_relative_position => sort_title_relative_position
+      sort_value_relative_position => sort_title_relative_position,
+      sort_value_size              => sort_title_size,
+      sort_value_expire_date       => sort_title_expire_date
     }
   end
 
@@ -404,6 +406,14 @@ def sort_title_relative_position
     s_('SortOptions|Manual')
   end
 
+  def sort_title_size
+    s_('SortOptions|Largest size')
+  end
+
+  def sort_title_expire_date
+    s_('SortOptions|Oldest expired')
+  end
+
   # Values.
   def sort_value_access_level_asc
     'access_level_asc'
@@ -556,4 +566,12 @@ def sort_value_recently_last_activity
   def sort_value_relative_position
     'relative_position'
   end
+
+  def sort_value_size
+    'size_desc'
+  end
+
+  def sort_value_expire_date
+    'expired_asc'
+  end
 end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 3c0efca31db1b211a77c12c108cef8179f7b157e..9f7db7cc720a2fbee4d32f7d4ac18c0a9ee7bf1d 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -15,6 +15,8 @@ class Build < CommitStatus
     include Gitlab::Utils::StrongMemoize
     include Deployable
     include HasRef
+    include Gitlab::SQL::Pattern
+    include Sortable
 
     BuildArchivedError = Class.new(StandardError)
 
@@ -114,6 +116,14 @@ def persisted_environment
 
     scope :with_artifacts_stored_locally, -> { with_existing_job_artifacts(Ci::JobArtifact.archive.with_files_stored_locally) }
     scope :with_archived_trace_stored_locally, -> { with_existing_job_artifacts(Ci::JobArtifact.trace.with_files_stored_locally) }
+
+    scope :with_sum_artifacts_size, ->() do
+      select('ci_builds.*, (SUM(COALESCE(ci_job_artifacts.size, 0.0)) + COALESCE(ci_builds.artifacts_size, 0.0)) AS sum_artifacts_size')
+          .joins('LEFT OUTER JOIN ci_job_artifacts ON ci_builds.id = ci_job_artifacts.job_id')
+          .having('(COUNT(ci_job_artifacts.id) > 0 OR COALESCE(ci_builds.artifacts_size, 0.0) > 0.0)')
+          .group('ci_builds.id')
+    end
+
     scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) }
     scope :with_expired_artifacts, ->() { with_artifacts_archive.where('artifacts_expire_at < ?', Time.now) }
     scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) }
@@ -172,6 +182,19 @@ def retry(build, current_user)
           .execute(build)
         # rubocop: enable CodeReuse/ServiceClass
       end
+
+      def search(query)
+        fuzzy_search(query, [:name])
+      end
+
+      def order_by(method)
+        case method.to_s
+        when 'size_desc' then with_sum_artifacts_size.reorder('sum_artifacts_size desc')
+        when 'expired_asc' then reorder(artifacts_expire_at: :asc)
+        else
+          super(method)
+        end
+      end
     end
 
     state_machine :status do
diff --git a/app/models/project.rb b/app/models/project.rb
index 10679fb1f85a88193a982e3b21efd2b3948c7896..2822d6153be7fef6a031cd99308775c8d07ac961 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -266,6 +266,7 @@ class Project < ApplicationRecord
   # bulk that doesn't involve loading the rows into memory. As a result we're
   # still using `dependent: :destroy` here.
   has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+  has_many :job_artifacts, class_name: 'Ci::JobArtifact'
   has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName'
   has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks
   has_many :runner_projects, class_name: 'Ci::RunnerProject', inverse_of: :project
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index b8dee1b07898f3a8b29c77f189e5233c7f755e1a..78d77766114b6a283eaa3651fe091c38b1707e64 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -163,6 +163,8 @@ class ProjectPolicy < BasePolicy
     enable :set_issue_updated_at
     enable :set_note_created_at
     enable :set_emails_disabled
+
+    enable :destroy_artifacts
   end
 
   rule { can?(:guest_access) }.policy do
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 02ecf816e90f8154752c8d5b89bf04e14c7475d7..b756c2885705bacffcdc9277e7715fa117119565 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -182,11 +182,17 @@
                     = _('Pipelines')
 
             - if project_nav_tab? :builds
-              = nav_link(controller: [:jobs, :artifacts]) do
+              = nav_link(controller: [:jobs]) do
                 = link_to project_jobs_path(@project), title: _('Jobs'), class: 'shortcuts-builds' do
                   %span
                     = _('Jobs')
 
+            - if project_nav_tab? :builds
+              = nav_link(controller: :artifacts, action: :index) do
+                = link_to project_artifacts_path(@project), title: 'Artifacts', class: 'shortcuts-builds' do
+                  %span
+                    = _('Artifacts')
+
             - if project_nav_tab? :pipelines
               = nav_link(controller: :pipeline_schedules) do
                 = link_to pipeline_schedules_path(@project), title: _('Schedules'), class: 'shortcuts-builds' do
diff --git a/app/views/projects/artifacts/_artifact.html.haml b/app/views/projects/artifacts/_artifact.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..589d2ef41c8693b9ffe8338d3095ea500afdd4f4
--- /dev/null
+++ b/app/views/projects/artifacts/_artifact.html.haml
@@ -0,0 +1,62 @@
+- project = local_assigns.fetch(:project)
+
+.gl-responsive-table-row{ id: dom_id(job) }
+  .table-section.section-25.section-wrap
+    .table-mobile-header{ role: 'rowheader' }= _('Job')
+    .table-mobile-content
+      .branch-commit
+        - if can?(current_user, :read_build, job)
+          = link_to project_job_path(project, job) do
+            %span.build-link= job.id
+        - else
+          %span.build-link= job.id
+
+        - if job.ref
+          .icon-container
+            = job.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite')
+          = link_to job.ref, project_ref_path(project, job.ref), class: 'ref-name'
+        - else
+          .light= none
+        .icon-container.commit-icon
+          = custom_icon('icon_commit')
+
+        = link_to job.short_sha, project_commit_path(project, job.sha), class: 'commit-sha'
+
+  .table-section.section-15.section-wrap
+    .table-mobile-header{ role: 'rowheader' }= _('Name')
+    .table-mobile-content
+      = job.name
+
+  .table-section.section-20
+    .table-mobile-header{ role: 'rowheader' }= _('Creation date')
+    .table-mobile-content
+      %p.finished-at
+        = icon("calendar")
+        %span= time_ago_with_tooltip(job.created_at)
+
+  .table-section.section-20
+    .table-mobile-header{ role: 'rowheader' }= _('Expiration date')
+    .table-mobile-content
+      - if job.artifacts_expire_at
+        %p.finished-at
+          = icon("calendar")
+          %span= time_ago_with_tooltip(job.artifacts_expire_at)
+
+  .table-section.section-10
+    .table-mobile-header{ role: 'rowheader' }= _('Size')
+    .table-mobile-content
+      = number_to_human_size(job.sum_artifacts_size, precision: 2)
+
+  .table-section.table-button-footer.section-10
+    .btn-group.table-action-buttons
+      .btn-group
+        - if can?(current_user, :read_build, job)
+          = link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Download artifacts') }, class: 'btn btn-build has-tooltip' do
+            = sprite_icon('download')
+
+          = link_to browse_project_job_artifacts_path(job.project, job), rel: 'nofollow', title: _('Browse artifacts'), data: { placement: 'top', container: 'body' }, ref: 'tooltip', aria: { label: _('Browse artifacts') }, class: 'btn btn-build has-tooltip' do
+            = sprite_icon('earth')
+
+        - if can?(current_user, :destroy_artifacts, job.project)
+          = link_to namespace_project_job_artifacts_path(job.project.namespace, job.project, job), data: { placement: 'top', container: 'body', confirm: _('Are you sure you want to delete these artifacts?') }, method: :delete, title: _('Delete artifacts'), ref: 'tooltip', aria: { label: _('Delete artifacts') }, class: 'btn btn-remove has-tooltip' do
+            = icon('remove')
diff --git a/app/views/projects/artifacts/_sort_dropdown.html.haml b/app/views/projects/artifacts/_sort_dropdown.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..e0fce73e1c40b63c12bac77404a873af55d8ab09
--- /dev/null
+++ b/app/views/projects/artifacts/_sort_dropdown.html.haml
@@ -0,0 +1,11 @@
+- sorted_by = sort_options_hash[@sort]
+
+.dropdown.inline.prepend-left-10
+  %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } }
+    = sorted_by
+    = icon('chevron-down')
+  %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
+    %li
+      = sortable_item(sort_title_size,           page_filter_path(sort: sort_value_size,           label: true), sorted_by)
+      = sortable_item(sort_title_expire_date,    page_filter_path(sort: sort_value_expire_date,    label: true), sorted_by)
+      = sortable_item(sort_title_oldest_created, page_filter_path(sort: sort_value_oldest_created, label: true), sorted_by)
diff --git a/app/views/projects/artifacts/index.html.haml b/app/views/projects/artifacts/index.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..eba65f63b1ed62691c53e4fdf89f90c2d21b49ea
--- /dev/null
+++ b/app/views/projects/artifacts/index.html.haml
@@ -0,0 +1,75 @@
+- @no_container = true
+- page_title _('Artifacts')
+- hint = '{{hint}}'
+- tag = '{{tag}}'
+
+%div{ class: container_class }
+  .row
+    .col-sm-9
+      = form_tag project_artifacts_path(@project), id: 'project-artifacts-search', method: :get, class: 'filter-form js-filter-form' do
+        .filtered-search-wrapper
+          .filtered-search-box
+            = dropdown_tag(custom_icon('icon_history'),
+              options: { wrapper_class: 'filtered-search-history-dropdown-wrapper',
+              toggle_class: 'filtered-search-history-dropdown-toggle-button',
+              dropdown_class: 'filtered-search-history-dropdown',
+              content_class: 'filtered-search-history-dropdown-content',
+              title: _('Recent searches') }) do
+              .js-filtered-search-history-dropdown{ data: { full_path: project_artifacts_path(@project) } }
+            .filtered-search-box-input-container.droplab-dropdown
+              .scroll-container
+                %ul.tokens-container.list-unstyled
+                  %li.input-token
+                    %input.form-control.filtered-search{ { id: 'filtered-project-artifacts', placeholder: _('Search or filter results...') } }
+              #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
+                %ul{ data: { dropdown: true } }
+                  %li.filter-dropdown-item{ data: { action: 'submit' } }
+                    = button_tag class: %w[btn btn-link] do
+                      = sprite_icon('search')
+                      %span
+                        = _('Press Enter or click to search')
+                %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+                  %li.filter-dropdown-item
+                    = button_tag class: %w[btn btn-link] do
+                      -# Encapsulate static class name `{{icon}}` inside #{} to bypass
+                      -# haml lint's ClassAttributeWithStaticValue
+                      %svg
+                        %use{ 'xlink:href': "#{'{{icon}}'}" }
+                      %span.js-filter-hint
+                        = hint
+                      %span.js-filter-tag.dropdown-light-content
+                        = tag
+
+              #js-dropdown-project-artifact-deleted-branches.filtered-search-input-dropdown-menu.dropdown-menu
+                %ul{ data: { dropdown: true } }
+                  %li.filter-dropdown-item{ data: { value: 'true' } }
+                    = button_tag class: %w[btn btn-link] do
+                      = _('Yes')
+                  %li.filter-dropdown-item{ data: { value: 'false' } }
+                    = button_tag class: %w[btn btn-link] do
+                      = _('No')
+
+            = button_tag class: %w[clear-search hidden] do
+              = icon('times')
+          .filter-dropdown-container
+            = render 'sort_dropdown'
+
+    .col-sm-3.text-right-lg
+      = _('Total artifacts size: %{total_size}') % { total_size: number_to_human_size(@total_size, precicion: 2) }
+
+  - if @jobs_with_artifacts.any?
+    .artifacts-content.content-list
+      .table-holder
+        .ci-table
+          .gl-responsive-table-row.table-row-header{ role: 'row' }
+            .table-section.section-25{ role: 'rowheader' }= _('Job')
+            .table-section.section-15{ role: 'rowheader' }= _('Name')
+            .table-section.section-20{ role: 'rowheader' }= _('Creation date')
+            .table-section.section-20{ role: 'rowheader' }= _('Expiration date')
+            .table-section.section-10{ role: 'rowheader' }= _('Size')
+            .table-section.section-10{ role: 'rowheader' }
+
+          = render partial: 'artifact', collection: @jobs_with_artifacts, as: :job, locals: { project: @project }
+      = paginate_collection @jobs_with_artifacts
+  - else
+    .nothing-here-block= _('No artifacts found')
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 9a453d101a1252e0f9f449e511c582196801b03d..ecc8ebe7ec2e72d739798ca9edef65e95a5009f8 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -31,6 +31,8 @@
       scope '-' do
         get 'archive/*id', constraints: { format: Gitlab::PathRegex.archive_formats_regex, id: /.+?/ }, to: 'repositories#archive', as: 'archive'
 
+        get 'artifacts', constraints: { format: Gitlab::PathRegex.archive_formats_regex, id: /.+?/ }, to: 'artifacts#index', as: 'artifacts'
+
         resources :jobs, only: [:index, :show], constraints: { id: /\d+/ } do
           collection do
             resources :artifacts, only: [] do
@@ -61,6 +63,7 @@
             get :file, path: 'file/*path', format: false
             get :raw, path: 'raw/*path', format: false
             post :keep
+            delete :destroy
           end
         end
 
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 1c1a3a519324e91348d1353eb79a8982ad1caea4..0159010ac056ec304022a223a766923347067b36 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1278,6 +1278,9 @@ msgstr ""
 msgid "Are you sure you want to cancel editing this comment?"
 msgstr ""
 
+msgid "Are you sure you want to delete these artifacts?"
+msgstr ""
+
 msgid "Are you sure you want to delete this %{typeOfComment}?"
 msgstr ""
 
@@ -1347,6 +1350,9 @@ msgstr ""
 msgid "Artifacts"
 msgstr ""
 
+msgid "Artifacts were successfully deleted."
+msgstr ""
+
 msgid "As U2F devices are only supported by a few browsers, we require that you set up a two-factor authentication app before a U2F device. That way you'll always be able to log in - even when you're using an unsupported browser."
 msgstr ""
 
@@ -1867,6 +1873,9 @@ msgstr ""
 msgid "Browse Files"
 msgstr ""
 
+msgid "Browse artifacts"
+msgstr ""
+
 msgid "Browse files"
 msgstr ""
 
@@ -3545,6 +3554,9 @@ msgstr ""
 msgid "Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available."
 msgstr ""
 
+msgid "Creation date"
+msgstr ""
+
 msgid "Cron Timezone"
 msgstr ""
 
@@ -3713,6 +3725,9 @@ msgstr ""
 msgid "Delete Snippet"
 msgstr ""
 
+msgid "Delete artifacts"
+msgstr ""
+
 msgid "Delete board"
 msgstr ""
 
@@ -7376,6 +7391,9 @@ msgstr ""
 msgid "No activities found"
 msgstr ""
 
+msgid "No artifacts found"
+msgstr ""
+
 msgid "No available namespaces to fork the project."
 msgstr ""
 
@@ -10357,6 +10375,9 @@ msgstr ""
 msgid "Site ID"
 msgstr ""
 
+msgid "Size"
+msgstr ""
+
 msgid "Size and domain settings for static websites"
 msgstr ""
 
@@ -10519,6 +10540,9 @@ msgstr ""
 msgid "SortOptions|Largest repository"
 msgstr ""
 
+msgid "SortOptions|Largest size"
+msgstr ""
+
 msgid "SortOptions|Last Contact"
 msgstr ""
 
@@ -10564,6 +10588,9 @@ msgstr ""
 msgid "SortOptions|Oldest created"
 msgstr ""
 
+msgid "SortOptions|Oldest expired"
+msgstr ""
+
 msgid "SortOptions|Oldest joined"
 msgstr ""
 
@@ -12079,6 +12106,9 @@ msgstr ""
 msgid "Total Time"
 msgstr ""
 
+msgid "Total artifacts size: %{total_size}"
+msgstr ""
+
 msgid "Total test time for all commits/merges"
 msgstr ""
 
diff --git a/spec/features/projects/artifacts/index_spec.rb b/spec/features/projects/artifacts/index_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4d9ff06b0882f759ec5d0e580ad30c42ab0d10fd
--- /dev/null
+++ b/spec/features/projects/artifacts/index_spec.rb
@@ -0,0 +1,150 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Index artifacts', :js do
+  include SortingHelper
+  include FilteredSearchHelpers
+
+  let(:jobs_values) do
+    [
+      { created_at: 1.day.ago, artifacts_expire_at: '', name: 'b_position', artifacts_size: 2 * 10**9 },
+      { created_at: 2.days.ago, artifacts_expire_at: 3.days.ago, name: 'c_position', artifacts_size: 3 * 10**9 },
+      { created_at: 3.days.ago, artifacts_expire_at: 2.days.ago, name: 'a_position', artifacts_size: 1 * 10**9 }
+    ]
+  end
+
+  describe 'non destructive functionality' do
+    let(:project) { create(:project, :public, :repository) }
+    let(:pipeline) { create(:ci_empty_pipeline, project: project) }
+    let!(:jobs) do
+      jobs_values.map do |values|
+        create(:ci_build, :artifacts, pipeline: pipeline,
+                                      created_at: values[:created_at], artifacts_expire_at: values[:artifacts_expire_at], name: values[:name])
+      end
+    end
+
+    before do
+      visit project_artifacts_path(project)
+    end
+
+    context 'when sorting' do
+      using RSpec::Parameterized::TableSyntax
+
+      where(:sort_column, :first, :second, :third) do
+        'Oldest created' | 2 | 1 | 0
+        'Oldest expired' | 1 | 2 | 0
+        'Largest size'   | 1 | 0 | 2
+      end
+
+      with_them do
+        before do
+          jobs.each_with_index { |job, index| job.job_artifacts_archive.update!(size: jobs_values[index][:artifacts_size]) }
+        end
+
+        subject(:names) do
+          page
+            .all('.artifacts-content .gl-responsive-table-row:not(.table-row-header) .table-section:nth-child(2)')
+            .map { |job_row| job_row.text }
+        end
+
+        it 'sorts the result by the specified sort key' do
+          sorting_by sort_column
+
+          expect(names).to eq [jobs[first], jobs[second], jobs[third]].map { |job| job.name }
+        end
+      end
+    end
+
+    context 'when searching' do
+      using RSpec::Parameterized::TableSyntax
+
+      where(:query_name, :job_indexes) do
+        'b_po'     | [0]
+        'a_posi'   | [2]
+        'position' | [2, 1, 0]
+      end
+
+      with_them do
+        subject(:names) do
+          page
+            .all('.artifacts-content .gl-responsive-table-row:not(.table-row-header) .table-section:nth-child(2)')
+            .map { |job_row| job_row.text }
+        end
+
+        it 'filters jobs' do
+          input_filtered_search_keys(query_name)
+
+          expect(names).to eq job_indexes.map { |index| jobs[index].name }
+        end
+      end
+    end
+  end
+
+  describe 'destructive functionality' do
+    def let_there_be_users_and_projects
+      # FIXME: Because of an issue: https://github.com/tomykaira/rspec-parameterized/issues/8#issuecomment-381888428
+      # setup needs to be made here instead of using let syntax
+
+      if user_type
+        @user = case user_type
+                when :regular
+                  create(:user)
+                when :admin
+                  create(:user, :admin)
+                end
+      end
+
+      @project = if project_association == :owner
+                   create(:project, :private, :repository, namespace: @user.namespace)
+                 else
+                   create(:project, :private, :repository)
+                 end
+
+      @project.add_master(@user) if project_association == :master
+      @project.add_developer(@user) if project_association == :developer
+      @project.add_reporter(@user) if project_association == :reporter
+
+      pipeline = create(:ci_empty_pipeline, project: @project)
+      @jobs = jobs_values.map do |values|
+        create(:ci_build, :artifacts,
+               pipeline: pipeline, created_at: values[:created_at], artifacts_expire_at: values[:artifacts_expire_at],
+               name: values[:name])
+      end
+    end
+
+    context 'with user roles allowed to delete artifacts' do
+      using RSpec::Parameterized::TableSyntax
+
+      where(:user_type, :project_association) do
+        :regular | :master
+        :regular | :owner
+        :admin   | nil
+        :admin   | :developer
+        :admin   | :master
+        :admin   | :owner
+      end
+
+      with_them do
+        before do
+          let_there_be_users_and_projects
+          sign_in(@user)
+        end
+
+        it 'can delete artifacts of job' do
+          visit project_artifacts_path(@project)
+
+          accept_alert { click_on 'Delete artifacts', match: :first }
+
+          expect(page).to have_content('Artifacts were successfully deleted.')
+
+          rows = page
+            .all('.artifacts-content .gl-responsive-table-row:not(.table-row-header) .table-section:nth-child(2)')
+            .map { |job_row| job_row.text }
+
+          expect(rows).to eq %w(c_position b_position)
+        end
+      end
+    end
+  end
+end
diff --git a/spec/finders/jobs_with_artifacts_finder_spec.rb b/spec/finders/jobs_with_artifacts_finder_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..6242593f51d987fdf090a5ed639cc91f98aaed6d
--- /dev/null
+++ b/spec/finders/jobs_with_artifacts_finder_spec.rb
@@ -0,0 +1,162 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe JobsWithArtifactsFinder do
+  describe '#execute' do
+    context 'with empty params' do
+      it 'returns all jobs belonging to the project' do
+        project = create(:project)
+
+        pipeline1 = create(:ci_empty_pipeline, project: project)
+        job1 = create(:ci_build, pipeline: pipeline1)
+        create(:ci_job_artifact, job: job1)
+
+        pipeline2 = create(:ci_empty_pipeline, project: project)
+        job2 = create(:ci_build, pipeline: pipeline2)
+        create(:ci_job_artifact, job: job2)
+
+        # without artifacts
+        pipeline3 = create(:ci_empty_pipeline, project: project)
+        create(:ci_build, pipeline: pipeline3)
+
+        create(:ci_job_artifact)
+
+        jobs = described_class.new(project: project, params: {}).execute
+
+        expect(jobs).to match_array [job1, job2]
+      end
+    end
+
+    context 'filter by search term' do
+      it 'calls Ci::Runner.search' do
+        project = create(:project)
+
+        expect(Ci::Build).to receive(:search).with('term').and_call_original
+
+        described_class.new(project: project, params: { search: 'term' }).execute
+      end
+    end
+
+    context 'filter by deleted branch' do
+      before do
+        @project = create(:project)
+
+        pipeline1 = create(:ci_empty_pipeline, project: @project)
+        @job1 = create(:ci_build, pipeline: pipeline1, ref: 'deleted_branches')
+        create(:ci_job_artifact, job: @job1)
+
+        pipeline2 = create(:ci_empty_pipeline, project: @project)
+        @job2 = create(:ci_build, pipeline: pipeline2, ref: 'master')
+        create(:ci_job_artifact, job: @job2)
+
+        allow(project.repository).to receive(:ref_names).and_return(['master'])
+      end
+
+      let(:project) { @project }
+      let(:job1) { @job1 }
+      let(:job2) { @job2 }
+
+      context 'deleted is set to true' do
+        it 'returns the jobs that belong to a deleted branch' do
+          jobs = described_class.new(project: project, params: { 'deleted_branches_deleted-branches': 'true' }).execute
+
+          expect(jobs).to eq [job1]
+        end
+      end
+
+      context 'deleted is set to false' do
+        it 'returns the jobs that belong to an existing branch' do
+          jobs = described_class.new(project: project, params: { 'deleted_branches_deleted-branches': 'false' }).execute
+
+          expect(jobs).to eq [job2]
+        end
+      end
+    end
+
+    context 'sort' do
+      context 'without sort param' do
+        it 'sorts by created_at' do
+          project = create(:project)
+
+          pipeline1 = create(:ci_empty_pipeline, project: project)
+          job1 = create(:ci_build, pipeline: pipeline1, created_at: '2018-07-12 07:00')
+          create(:ci_job_artifact, job: job1)
+
+          pipeline2 = create(:ci_empty_pipeline, project: project)
+          job2 = create(:ci_build, pipeline: pipeline2, created_at: '2018-07-12 09:00')
+          create(:ci_job_artifact, job: job2)
+
+          pipeline3 = create(:ci_empty_pipeline, project: project)
+          job3 = create(:ci_build, pipeline: pipeline3, created_at: '2018-07-12 08:00')
+          create(:ci_job_artifact, job: job3)
+
+          jobs = described_class.new(project: project, params: {}).execute
+
+          expect(jobs).to eq [job1, job3, job2]
+        end
+      end
+
+      context 'with sort param' do
+        it 'sorts by size_desc' do
+          project = create(:project)
+
+          pipeline1 = create(:ci_empty_pipeline, project: project)
+          job1 = create(:ci_build, pipeline: pipeline1)
+          create(:ci_job_artifact, job: job1, size: 2 * 1024)
+
+          pipeline2 = create(:ci_empty_pipeline, project: project)
+          job2 = create(:ci_build, pipeline: pipeline2)
+          create(:ci_job_artifact, job: job2, size: 1024)
+
+          pipeline3 = create(:ci_empty_pipeline, project: project)
+          job3 = create(:ci_build, pipeline: pipeline3)
+          create(:ci_job_artifact, job: job3, size: 3 * 1024)
+
+          jobs = described_class.new(project: project, params: { sort: 'size_desc' }).execute
+
+          expect(jobs).to eq [job3, job1, job2]
+        end
+
+        it 'sorts by expire_date_asc' do
+          project = create(:project)
+
+          pipeline1 = create(:ci_empty_pipeline, project: project)
+          job1 = create(:ci_build, pipeline: pipeline1, artifacts_expire_at: '2018-07-12 07:00')
+          create(:ci_job_artifact, job: job1)
+
+          pipeline2 = create(:ci_empty_pipeline, project: project)
+          job2 = create(:ci_build, pipeline: pipeline2, artifacts_expire_at: '2018-07-12 09:00')
+          create(:ci_job_artifact, job: job2)
+
+          pipeline3 = create(:ci_empty_pipeline, project: project)
+          job3 = create(:ci_build, pipeline: pipeline3, artifacts_expire_at: '2018-07-12 08:00')
+          create(:ci_job_artifact, job: job3)
+
+          jobs = described_class.new(project: project, params: { sort: 'expired_asc' }).execute
+
+          expect(jobs).to eq [job1, job3, job2]
+        end
+      end
+    end
+
+    context 'paginate' do
+      it 'returns the runners for the specified page' do
+        stub_const('JobsWithArtifactsFinder::NUMBER_OF_JOBS_PER_PAGE', 1)
+
+        project = create(:project)
+
+        pipeline1 = create(:ci_empty_pipeline, project: project)
+        job1 = create(:ci_build, pipeline: pipeline1)
+        create(:ci_job_artifact, job: job1)
+
+        pipeline2 = create(:ci_empty_pipeline, project: project)
+        job2 = create(:ci_build, pipeline: pipeline2)
+        create(:ci_job_artifact, job: job2)
+
+        expect(described_class.new(project: project, params: { page: 1 }).execute).to eq [job1]
+        expect(described_class.new(project: project, params: { page: 2 }).execute).to eq [job2]
+      end
+    end
+  end
+end
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 3c6b17c10ec38ce28bd77146ce7e4322a4dcff7c..36e5fd5423730dd9e1929fa55d8a421e2195bb4a 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -339,6 +339,7 @@ project:
 - members_and_requesters
 - build_trace_section_names
 - build_trace_chunks
+- job_artifacts
 - root_of_fork_network
 - fork_network_member
 - fork_network
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 4aac4b640f4aab4a9b3e5735a81c85c9f937c430..56fb0bc328c5bd78c170aea4ecdbb834d2332f68 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -255,6 +255,42 @@
     end
   end
 
+  describe '.with_sum_artifacts_size' do
+    subject { described_class.with_sum_artifacts_size[0].sum_artifacts_size }
+
+    context 'when job does not have an archive' do
+      let!(:job) { create(:ci_build) }
+
+      subject(:result) { described_class.with_sum_artifacts_size }
+
+      it { expect(result).to be_empty }
+    end
+
+    context 'when job has an achive' do
+      let!(:job) { create(:ci_build, :artifacts) }
+
+      it { is_expected.to eq 106826.0 }
+    end
+
+    context 'when job has a job artifact archive' do
+      let!(:job) { create(:ci_build, :artifacts) }
+
+      it { is_expected.to eq 106826.0 }
+    end
+
+    context 'when job has a job artifact trace' do
+      let!(:job) { create(:ci_build, :trace_artifact) }
+
+      it { is_expected.to eq 192709.0 }
+    end
+
+    context 'when job has a job an artiact and an artifact trace' do
+      let!(:job) { create(:ci_build, :trace_artifact, :artifacts) }
+
+      it { is_expected.to eq 299535.0 }
+    end
+  end
+
   describe '#actionize' do
     context 'when build is a created' do
       before do
@@ -3867,4 +3903,20 @@ def run_job_without_exception
       end
     end
   end
+
+  describe '.search' do
+    it 'fuzzy matches the name' do
+      project = create(:project)
+
+      pipeline1 = create(:ci_empty_pipeline, project: project)
+      job1 = create(:ci_build, pipeline: pipeline1, name: 'job1')
+
+      pipeline2 = create(:ci_empty_pipeline, project: project)
+      create(:ci_build, pipeline: pipeline2, name: 'job2')
+
+      jobs = described_class.search('ob1')
+
+      expect(jobs).to match_array [job1]
+    end
+  end
 end
diff --git a/spec/views/projects/artifacts/index.html.haml_spec.rb b/spec/views/projects/artifacts/index.html.haml_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3678ddb247e7b85684ae39d4e115a48994d14305
--- /dev/null
+++ b/spec/views/projects/artifacts/index.html.haml_spec.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe "projects/artifacts/index.html.haml" do
+  let(:project) { build(:project) }
+
+  describe 'delete button' do
+    before do
+      pipeline = create(:ci_empty_pipeline, project: project)
+      build = create(:ci_build, pipeline: pipeline, name: 'job1')
+      create(:ci_job_artifact, job: build, size: 2 * 10**9)
+
+      allow(view).to receive(:current_user).and_return(user)
+      assign(:project, project)
+      assign(:jobs_with_artifacts, Ci::Build.with_sum_artifacts_size)
+      assign(:total_size, 0)
+      assign(:sort, 'created_asc')
+    end
+
+    context 'with admin' do
+      let(:user) { build(:admin) }
+
+      it 'has a delete button' do
+        render
+
+        expect(rendered).to have_link('Delete artifacts')
+      end
+    end
+
+    context 'with owner' do
+      let(:user) { create(:user) }
+      let(:project) { build(:project, namespace: user.namespace) }
+
+      it 'has a delete button' do
+        render
+
+        expect(rendered).to have_link('Delete artifacts')
+      end
+    end
+
+    context 'with master' do
+      let(:user) { create(:user) }
+
+      it 'has a delete button' do
+        allow_any_instance_of(ProjectTeam).to receive(:max_member_access).and_return(Gitlab::Access::MASTER)
+        render
+
+        expect(rendered).to have_link('Delete artifacts')
+      end
+    end
+
+    context 'with developer' do
+      let(:user) { build(:user) }
+
+      it 'has no delete button' do
+        project.add_developer(user)
+        render
+
+        expect(rendered).not_to have_link('Delete artifacts')
+      end
+    end
+
+    context 'with reporter' do
+      let(:user) { build(:user) }
+
+      it 'has no delete button' do
+        project.add_reporter(user)
+        render
+
+        expect(rendered).not_to have_link('Delete artifacts')
+      end
+    end
+  end
+end