diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb
index 3e15f9b535ee2b02113ab848f312e3db9d5f624f..8b83625f10b1cd2ab838b03f02da1042f18142c7 100644
--- a/app/finders/ci/runners_finder.rb
+++ b/app/finders/ci/runners_finder.rb
@@ -22,6 +22,7 @@ def execute
                 all_runners
               end
 
+      items = by_ids(items)
       items = search(items)
       items = by_active(items)
       items = by_status(items)
@@ -45,6 +46,12 @@ def sort_key
 
     attr_reader :group, :project
 
+    def by_ids(items)
+      return items unless @params[:id_in]
+
+      items.id_in(@params[:id_in])
+    end
+
     def runner_type
       @params[:type_type]&.to_sym
     end
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index ae8e09f2135b75bb974fb50e6c32f2255f009500..ef15569c16c7b42e1fe3730596083b5dcdb9c5ee 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -13,6 +13,11 @@ class BasePolicy < DeclarativePolicy::Base
     end
   end
 
+  desc "The current instance is a GitLab Dedicated instance"
+  condition :gitlab_dedicated do
+    Gitlab::CurrentSettings.gitlab_dedicated_instance?
+  end
+
   desc "User is blocked"
   with_options scope: :user, score: 0
   condition(:blocked) { @user&.blocked? }
@@ -92,6 +97,10 @@ class BasePolicy < DeclarativePolicy::Base
     enable :change_repository_storage
   end
 
+  rule { gitlab_dedicated & admin }.policy do
+    enable :read_dedicated_hosted_runner_usage
+  end
+
   rule { default }.enable :read_cross_project
 
   condition(:is_gitlab_com, score: 0, scope: :global) { ::Gitlab.com? }
diff --git a/db/migrate/20250225045649_add_billing_month_year_index.rb b/db/migrate/20250225045649_add_billing_month_year_index.rb
new file mode 100644
index 0000000000000000000000000000000000000000..210b4af153071a735e2f45eceaae480e2b9a5b88
--- /dev/null
+++ b/db/migrate/20250225045649_add_billing_month_year_index.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AddBillingMonthYearIndex < Gitlab::Database::Migration[2.2]
+  disable_ddl_transaction!
+  milestone '17.10'
+
+  INDEX_NAME = 'idx_gitlab_hosted_runner_monthly_usages_on_billing_month_year'
+
+  def up
+    add_concurrent_index :ci_gitlab_hosted_runner_monthly_usages, "EXTRACT(YEAR FROM billing_month)", name: INDEX_NAME,
+      using: :btree
+  end
+
+  def down
+    remove_concurrent_index_by_name :ci_gitlab_hosted_runner_monthly_usages, name: INDEX_NAME
+  end
+end
diff --git a/db/schema_migrations/20250225045649 b/db/schema_migrations/20250225045649
new file mode 100644
index 0000000000000000000000000000000000000000..a1edbda4d63fb35caca64c59f2f1dbd91ab74e66
--- /dev/null
+++ b/db/schema_migrations/20250225045649
@@ -0,0 +1 @@
+80718abeb6da1c012b1b339f5ad3e57746cffb0473185f95d37edb4b76fb494a
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 1d9cfad8d85e310fd98135a1ca8c45881385a4a9..c72b6c1645b95b77821134b05b4012b86ec6778d 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -31123,6 +31123,8 @@ CREATE UNIQUE INDEX idx_external_audit_event_destination_id_key_uniq ON audit_ev
 
 CREATE INDEX idx_external_status_checks_on_id_and_project_id ON external_status_checks USING btree (id, project_id);
 
+CREATE INDEX idx_gitlab_hosted_runner_monthly_usages_on_billing_month_year ON ci_gitlab_hosted_runner_monthly_usages USING btree (EXTRACT(year FROM billing_month));
+
 CREATE INDEX idx_gpg_keys_on_user_externally_verified ON gpg_keys USING btree (user_id) WHERE (externally_verified = true);
 
 CREATE INDEX idx_group_audit_events_on_author_id_created_at_id ON ONLY group_audit_events USING btree (author_id, created_at, id);
diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md
index b33088e712c160f8cd66f8e97e740b86b29f7fff..46bc35fe7e690b027123d6b8ce4410f353366940 100644
--- a/doc/api/graphql/reference/_index.md
+++ b/doc/api/graphql/reference/_index.md
@@ -409,6 +409,12 @@ Returns [`CiConfig`](#ciconfig).
 | <a id="queryciconfigsha"></a>`sha` | [`String`](#string) | Sha for the pipeline. |
 | <a id="queryciconfigskipverifyprojectsha"></a>`skipVerifyProjectSha` {{< icon name="warning-solid" >}} | [`Boolean`](#boolean) | **Introduced** in GitLab 16.5. **Status**: Experiment. If the provided `sha` is found in the project's repository but is not associated with a Git reference (a detached commit), the verification fails and a validation error is returned. Otherwise, verification passes, even if the `sha` is invalid. Set to `true` to skip this verification process. |
 
+### `Query.ciDedicatedHostedRunnerFilters`
+
+Returns available filters for GitLab Dedicated runner usage data.
+
+Returns [`CiDedicatedHostedRunnerFilters`](#cidedicatedhostedrunnerfilters).
+
 ### `Query.ciDedicatedHostedRunnerUsage`
 
 Compute usage data for runners across namespaces on GitLab Dedicated. Defaults to the current year if no year or billing month is specified. Ultimate only.
@@ -21722,6 +21728,17 @@ CI/CD config variables.
 | <a id="ciconfigvariablevalue"></a>`value` | [`String`](#string) | Value of the variable. |
 | <a id="ciconfigvariablevalueoptions"></a>`valueOptions` | [`[String!]`](#string) | Value options for the variable. |
 
+### `CiDedicatedHostedRunnerFilters`
+
+Filter options available for GitLab Dedicated runner usage data.
+
+#### Fields
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="cidedicatedhostedrunnerfiltersrunners"></a>`runners` | [`CiRunnerConnection`](#cirunnerconnection) | List of unique runners with usage data. (see [Connections](#connections)) |
+| <a id="cidedicatedhostedrunnerfiltersyears"></a>`years` | [`[Int!]`](#int) | List of years with available usage data. |
+
 ### `CiDedicatedHostedRunnerUsage`
 
 Compute usage data for hosted runners on GitLab Dedicated.
diff --git a/ee/app/graphql/ee/types/ci/minutes/dedicated_runner_filters_type.rb b/ee/app/graphql/ee/types/ci/minutes/dedicated_runner_filters_type.rb
new file mode 100644
index 0000000000000000000000000000000000000000..daec8e5dea7968ba1f7ee3180a88973084ccefb3
--- /dev/null
+++ b/ee/app/graphql/ee/types/ci/minutes/dedicated_runner_filters_type.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+# rubocop:disable Gitlab/EeOnlyClass -- This is only used in GitLab dedicated that comes under ultimate tier only.
+module EE
+  module Types
+    module Ci
+      module Minutes
+        class DedicatedRunnerFiltersType < ::Types::BaseObject
+          graphql_name 'CiDedicatedHostedRunnerFilters'
+          description 'Filter options available for GitLab Dedicated runner usage data.'
+
+          include ::Gitlab::Graphql::Authorize::AuthorizeResource
+
+          field :runners, ::Types::Ci::RunnerType.connection_type, null: true,
+            description: 'List of unique runners with usage data.'
+          field :years, [GraphQL::Types::Int], null: true,
+            description: 'List of years with available usage data.'
+
+          def runners
+            raise_resource_not_available_error! unless allowed?
+
+            runner_ids = ::Ci::Minutes::GitlabHostedRunnerMonthlyUsage.distinct_runner_ids
+
+            ::Ci::RunnersFinder
+              .new(current_user: context[:current_user], params: { id_in: runner_ids })
+              .execute
+          end
+
+          def years
+            raise_resource_not_available_error! unless allowed?
+
+            ::Ci::Minutes::GitlabHostedRunnerMonthlyUsage.distinct_years
+          end
+
+          def allowed?
+            current_user.can?(:read_dedicated_hosted_runner_usage)
+          end
+        end
+      end
+    end
+  end
+end
+# rubocop:enable Gitlab/EeOnlyClass
diff --git a/ee/app/graphql/ee/types/query_type.rb b/ee/app/graphql/ee/types/query_type.rb
index 9b496bb1a1b5744c13683b2549997eed5fe0383f..c28c6a58ca43d3069e30645a96605df902d14edb 100644
--- a/ee/app/graphql/ee/types/query_type.rb
+++ b/ee/app/graphql/ee/types/query_type.rb
@@ -33,6 +33,10 @@ module QueryType
             required: false,
             description: 'Date for which to retrieve the usage data, should be the first day of a month.'
         end
+        field :ci_dedicated_hosted_runner_filters, Types::Ci::Minutes::DedicatedRunnerFiltersType,
+          null: true,
+          fallback_value: {},
+          description: 'Returns available filters for GitLab Dedicated runner usage data.'
         field :ci_dedicated_hosted_runner_usage, Types::Ci::Minutes::DedicatedMonthlyUsageType.connection_type,
           null: true,
           resolver: EE::Resolvers::Ci::Minutes::DedicatedMonthlyUsageResolver,
diff --git a/ee/app/models/ci/minutes/gitlab_hosted_runner_monthly_usage.rb b/ee/app/models/ci/minutes/gitlab_hosted_runner_monthly_usage.rb
index 85f99347273211d50dc701baf47d942ea1c7cdd5..9749b715d93fde8a3b83feae6cfd7c19701caf9e 100644
--- a/ee/app/models/ci/minutes/gitlab_hosted_runner_monthly_usage.rb
+++ b/ee/app/models/ci/minutes/gitlab_hosted_runner_monthly_usage.rb
@@ -6,6 +6,7 @@ module Minutes
     # For gitlab dedicated hosted runners only
     class GitlabHostedRunnerMonthlyUsage < Ci::ApplicationRecord
       include Ci::NamespacedModelName
+      include LooseIndexScan
 
       belongs_to :project, inverse_of: :hosted_runner_monthly_usages
       belongs_to :root_namespace, class_name: 'Namespace', inverse_of: :hosted_runner_monthly_usages
@@ -56,6 +57,22 @@ class GitlabHostedRunnerMonthlyUsage < Ci::ApplicationRecord
         query
       end
 
+      # rubocop:disable Database/AvoidUsingPluckWithoutLimit -- This will be done on GitLab dedicated only. Result set would be small.
+      scope :distinct_runner_ids, -> do
+        loose_index_scan(column: :runner_id)
+          .joins(:runner)
+          .pluck(:runner_id)
+      end
+      # rubocop:enable Database/AvoidUsingPluckWithoutLimit
+
+      # rubocop:disable Database/AvoidUsingPluckWithoutLimit -- Result set is small.
+      scope :distinct_years, -> do
+        distinct
+          .pluck(Arel.sql('EXTRACT(YEAR FROM billing_month)::integer'))
+          .sort
+      end
+      # rubocop:enable Database/AvoidUsingPluckWithoutLimit
+
       private
 
       def self.billing_month_range(billing_month, year)
diff --git a/ee/spec/graphql/ee/types/ci/minutes/dedicated_runner_filters_type_spec.rb b/ee/spec/graphql/ee/types/ci/minutes/dedicated_runner_filters_type_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..e7f1ec0864ad9a0ed046b328c0f9a89f655c4b85
--- /dev/null
+++ b/ee/spec/graphql/ee/types/ci/minutes/dedicated_runner_filters_type_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['CiDedicatedHostedRunnerFilters'], feature_category: :hosted_runners do
+  include GraphqlHelpers
+
+  let_it_be(:admin) { create(:admin) }
+  let_it_be(:runner1) { create(:ci_runner) }
+  let_it_be(:runner2) { create(:ci_runner) }
+  let_it_be(:usage1) { create(:ci_hosted_runner_monthly_usage, runner: runner1, billing_month: Date.new(2023, 1, 1)) }
+  let_it_be(:usage2) { create(:ci_hosted_runner_monthly_usage, runner: runner2, billing_month: Date.new(2024, 1, 1)) }
+
+  let(:filters) { graphql_response.dig('data', 'ciDedicatedHostedRunnerFilters') }
+  let(:query) do
+    %(
+      query {
+        ciDedicatedHostedRunnerFilters {
+          #{query_field}
+        }
+      }
+    )
+  end
+
+  let_it_be(:fields) { %i[runners years] }
+
+  subject(:graphql_response) { GitlabSchema.execute(query, context: { current_user: admin }).as_json }
+
+  before do
+    stub_application_setting(gitlab_dedicated_instance: true)
+  end
+
+  it { expect(described_class).to have_graphql_fields(fields) }
+
+  describe 'fields' do
+    let(:type_fields) { described_class.fields }
+
+    it { expect(type_fields['runners'].type).to be_present }
+    it { expect(type_fields['years'].type).to be_present }
+    it { expect(type_fields['years'].type.to_type_signature).to eq('[Int!]') }
+  end
+
+  describe 'runners field' do
+    let(:query_field) { 'runners { nodes { id } }' }
+
+    context 'when user is not authorized' do
+      let(:unauthorized_user) { create(:user) }
+
+      subject(:graphql_response) { GitlabSchema.execute(query, context: { current_user: unauthorized_user }).as_json }
+
+      it 'returns nil' do
+        expect(filters['runners']).to be_nil
+      end
+    end
+
+    context 'when user is authorized', :enable_admin_mode do
+      it 'returns distinct runners' do
+        runner_ids = filters.dig('runners', 'nodes').pluck('id')
+        expect(runner_ids).to contain_exactly(
+          runner1.to_global_id.to_s,
+          runner2.to_global_id.to_s
+        )
+      end
+    end
+  end
+
+  describe 'years field' do
+    let(:query_field) { 'years' }
+
+    context 'when user is not authorized' do
+      let(:unauthorized_user) { create(:user) }
+
+      subject(:graphql_response) { GitlabSchema.execute(query, context: { current_user: unauthorized_user }).as_json }
+
+      it 'returns nil' do
+        expect(filters['years']).to be_nil
+      end
+    end
+
+    context 'when user is authorized', :enable_admin_mode do
+      it 'returns all distinct years' do
+        expect(filters['years']).to contain_exactly(2023, 2024)
+      end
+    end
+  end
+end
diff --git a/ee/spec/graphql/types/query_type_spec.rb b/ee/spec/graphql/types/query_type_spec.rb
index 3608cdccc519a0aae00d3d7786563e641b970968..aad41d0f30957afdbea0900ca167806911dc3584 100644
--- a/ee/spec/graphql/types/query_type_spec.rb
+++ b/ee/spec/graphql/types/query_type_spec.rb
@@ -15,6 +15,7 @@
       :ci_catalog_resources,
       :ci_catalog_resource,
       :ci_minutes_usage,
+      :ci_dedicated_hosted_runner_filters,
       :ci_dedicated_hosted_runner_usage,
       :ci_queueing_history,
       :current_license,
diff --git a/ee/spec/models/ci/minutes/gitlab_hosted_runner_monthly_usage_spec.rb b/ee/spec/models/ci/minutes/gitlab_hosted_runner_monthly_usage_spec.rb
index 743b3bcc5c93a1ae8865ec08041a2c434b1b0029..d28beb651bad5e5841c58d01bbf9817e04697a56 100644
--- a/ee/spec/models/ci/minutes/gitlab_hosted_runner_monthly_usage_spec.rb
+++ b/ee/spec/models/ci/minutes/gitlab_hosted_runner_monthly_usage_spec.rb
@@ -142,4 +142,35 @@
       end
     end
   end
+
+  describe '.distinct_runner_ids' do
+    let_it_be(:runner1) { create(:ci_runner) }
+    let_it_be(:runner2) { create(:ci_runner) }
+
+    before do
+      create(:ci_hosted_runner_monthly_usage, runner: runner1)
+      create(:ci_hosted_runner_monthly_usage, runner: runner1) # Duplicate usage for same runner
+      create(:ci_hosted_runner_monthly_usage, runner: runner2)
+    end
+
+    it 'returns distinct runner IDs' do
+      expect(described_class.distinct_runner_ids).to contain_exactly(runner1.id, runner2.id)
+    end
+  end
+
+  describe '.distinct_years' do
+    before do
+      create(:ci_hosted_runner_monthly_usage, billing_month: Date.new(2023, 1, 1))
+      create(:ci_hosted_runner_monthly_usage, billing_month: Date.new(2023, 2, 1)) # Same year
+      create(:ci_hosted_runner_monthly_usage, billing_month: Date.new(2024, 1, 1))
+    end
+
+    it 'returns distinct years' do
+      expect(described_class.distinct_years).to contain_exactly(2023, 2024)
+    end
+
+    it 'returns years in ascending order' do
+      expect(described_class.distinct_years).to eq([2023, 2024])
+    end
+  end
 end
diff --git a/spec/finders/ci/runners_finder_spec.rb b/spec/finders/ci/runners_finder_spec.rb
index a2ff9b06c0f8d4fbded501b50f24130dc199acb2..f15f49cc287a5c477714053b5e9532efba30dcde 100644
--- a/spec/finders/ci/runners_finder_spec.rb
+++ b/spec/finders/ci/runners_finder_spec.rb
@@ -55,6 +55,36 @@
       end
 
       context 'filtering' do
+        context 'by ids' do
+          let_it_be(:runner1) { create(:ci_runner) }
+          let_it_be(:runner2) { create(:ci_runner) }
+          let_it_be(:runner3) { create(:ci_runner) }
+
+          context 'when id_in param is provided' do
+            let(:params) { { id_in: [runner1.id, runner2.id] } }
+
+            it 'returns runners with matching ids' do
+              expect(execute).to contain_exactly(runner1, runner2)
+            end
+          end
+
+          context 'when id_in param is empty' do
+            let(:params) { { id_in: [] } }
+
+            it 'returns no runners' do
+              expect(execute).to be_empty
+            end
+          end
+
+          context 'when id_in param contains non-existing ids' do
+            let(:params) { { id_in: [non_existing_record_id] } }
+
+            it 'returns no runners' do
+              expect(execute).to be_empty
+            end
+          end
+        end
+
         context 'by search term' do
           let(:params) { { search: 'term' } }
 
@@ -523,6 +553,14 @@
           end
 
           context 'filtering' do
+            context 'by ids' do
+              let(:extra_params) { { id_in: [runner_group.id, runner_sub_group_1.id] } }
+
+              it 'returns correct runners' do
+                expect(subject).to contain_exactly(runner_group, runner_sub_group_1)
+              end
+            end
+
             context 'by search term' do
               let(:extra_params) { { search: 'runner_project_search' } }
 
@@ -725,6 +763,14 @@
         let_it_be(:runner_other_project_paused) { create(:ci_runner, :project, :paused, :online, projects: [other_project]) }
         let_it_be(:runner_manager) { create(:ci_runner_machine, runner: runner_instance_paused, version: '15.10.0') }
 
+        context 'by ids' do
+          let(:extra_params) { { id_in: [runner_project_active.id, runner_project_paused.id] } }
+
+          it 'returns correct runners' do
+            expect(subject).to contain_exactly(runner_project_active, runner_project_paused)
+          end
+        end
+
         context 'by search term' do
           let_it_be(:runner_project_1) { create(:ci_runner, :project, :online, description: 'runner_project_search', projects: [project]) }
           let_it_be(:runner_project_2) { create(:ci_runner, :project, :online, description: 'runner_project', projects: [project]) }
diff --git a/spec/policies/base_policy_spec.rb b/spec/policies/base_policy_spec.rb
index cb3de5c72a0cff365076cfabf9659586663d07f5..5512484d4c335f06c2bb7f6b933e559895e3e6ec 100644
--- a/spec/policies/base_policy_spec.rb
+++ b/spec/policies/base_policy_spec.rb
@@ -81,6 +81,60 @@ def policy
     end
   end
 
+  describe 'read_dedicated_hosted_runner_usage' do
+    let(:current_user) { build_stubbed(:user) }
+
+    subject { described_class.new(current_user, nil) }
+
+    context 'for a regular user' do
+      it { is_expected.not_to be_allowed(:read_dedicated_hosted_runner_usage) }
+    end
+
+    context 'with an admin' do
+      let(:current_user) { build_stubbed(:admin) }
+
+      before do
+        enable_admin_mode!(current_user)
+      end
+
+      context 'on a non-dedicated instance' do
+        before do
+          allow(Gitlab::CurrentSettings).to receive(:gitlab_dedicated_instance?).and_return(false)
+        end
+
+        it { is_expected.not_to be_allowed(:read_dedicated_hosted_runner_usage) }
+      end
+
+      context 'on a dedicated instance' do
+        before do
+          allow(Gitlab::CurrentSettings).to receive(:gitlab_dedicated_instance?).and_return(true)
+        end
+
+        it { is_expected.to be_allowed(:read_dedicated_hosted_runner_usage) }
+      end
+    end
+
+    context 'with an admin not in admin mode' do
+      let(:current_user) { build_stubbed(:admin) }
+
+      before do
+        allow(Gitlab::CurrentSettings).to receive(:gitlab_dedicated_instance?).and_return(true)
+      end
+
+      it { is_expected.not_to be_allowed(:read_dedicated_hosted_runner_usage) }
+    end
+
+    context 'with anonymous' do
+      let(:current_user) { nil }
+
+      before do
+        allow(Gitlab::CurrentSettings).to receive(:gitlab_dedicated_instance?).and_return(true)
+      end
+
+      it { is_expected.not_to be_allowed(:read_dedicated_hosted_runner_usage) }
+    end
+  end
+
   describe 'read cross project' do
     let(:current_user) { build_stubbed(:user) }
     let(:user) { build_stubbed(:user) }