diff --git a/app/assets/javascripts/runner/components/cells/link_cell.vue b/app/assets/javascripts/runner/components/cells/link_cell.vue
new file mode 100644
index 0000000000000000000000000000000000000000..2843ddbacaf6a4d114839751aaed25a983f245cc
--- /dev/null
+++ b/app/assets/javascripts/runner/components/cells/link_cell.vue
@@ -0,0 +1,27 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+
+export default {
+  props: {
+    href: {
+      type: String,
+      required: false,
+      default: null,
+    },
+  },
+  computed: {
+    component() {
+      if (this.href) {
+        return GlLink;
+      }
+      return 'span';
+    },
+  },
+};
+</script>
+
+<template>
+  <component :is="component" :href="href" v-bind="$attrs" v-on="$listeners">
+    <slot></slot>
+  </component>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_details.vue b/app/assets/javascripts/runner/components/runner_details.vue
index ab4b99d41864f61006b07089bf1ac0b2d3e435cf..b6a5ffc7a6420129a2c477ed8310b36374dc603b 100644
--- a/app/assets/javascripts/runner/components/runner_details.vue
+++ b/app/assets/javascripts/runner/components/runner_details.vue
@@ -1,22 +1,26 @@
 <script>
-import { GlTabs, GlTab, GlIntersperse } from '@gitlab/ui';
+import { GlBadge, GlTabs, GlTab, GlIntersperse } from '@gitlab/ui';
 import { s__ } from '~/locale';
 import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
 import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
 import { ACCESS_LEVEL_REF_PROTECTED, GROUP_TYPE, PROJECT_TYPE } from '../constants';
+import { formatJobCount } from '../utils';
 import RunnerDetail from './runner_detail.vue';
 import RunnerGroups from './runner_groups.vue';
 import RunnerProjects from './runner_projects.vue';
+import RunnerJobs from './runner_jobs.vue';
 import RunnerTags from './runner_tags.vue';
 
 export default {
   components: {
+    GlBadge,
     GlTabs,
     GlTab,
     GlIntersperse,
     RunnerDetail,
     RunnerGroups,
     RunnerProjects,
+    RunnerJobs,
     RunnerTags,
     TimeAgo,
   },
@@ -53,6 +57,9 @@ export default {
     isProjectRunner() {
       return this.runner?.runnerType === PROJECT_TYPE;
     },
+    jobCount() {
+      return formatJobCount(this.runner?.jobCount);
+    },
   },
   ACCESS_LEVEL_REF_PROTECTED,
 };
@@ -65,7 +72,7 @@ export default {
 
       <template v-if="runner">
         <div class="gl-pt-4">
-          <dl class="gl-mb-0">
+          <dl class="gl-mb-0" data-testid="runner-details-list">
             <runner-detail :label="s__('Runners|Description')" :value="runner.description" />
             <runner-detail
               :label="s__('Runners|Last contact')"
@@ -103,5 +110,15 @@ export default {
         <runner-projects v-if="isProjectRunner" :runner="runner" />
       </template>
     </gl-tab>
+    <gl-tab>
+      <template #title>
+        {{ s__('Runners|Jobs') }}
+        <gl-badge v-if="jobCount" data-testid="job-count-badge" class="gl-ml-1" size="sm">
+          {{ jobCount }}
+        </gl-badge>
+      </template>
+
+      <runner-jobs v-if="runner" :runner="runner" />
+    </gl-tab>
   </gl-tabs>
 </template>
diff --git a/app/assets/javascripts/runner/components/runner_jobs.vue b/app/assets/javascripts/runner/components/runner_jobs.vue
new file mode 100644
index 0000000000000000000000000000000000000000..c13e7e90168ec6aae28139907548c207d17823b4
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_jobs.vue
@@ -0,0 +1,82 @@
+<script>
+import { GlSkeletonLoading } from '@gitlab/ui';
+import { createAlert } from '~/flash';
+import getRunnerJobsQuery from '../graphql/get_runner_jobs.query.graphql';
+import { I18N_FETCH_ERROR, I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '../constants';
+import { captureException } from '../sentry_utils';
+import { getPaginationVariables } from '../utils';
+import RunnerJobsTable from './runner_jobs_table.vue';
+import RunnerPagination from './runner_pagination.vue';
+
+export default {
+  name: 'RunnerJobs',
+  components: {
+    GlSkeletonLoading,
+    RunnerJobsTable,
+    RunnerPagination,
+  },
+  props: {
+    runner: {
+      type: Object,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      jobs: {
+        items: [],
+        pageInfo: {},
+      },
+      pagination: {
+        page: 1,
+      },
+    };
+  },
+  apollo: {
+    jobs: {
+      query: getRunnerJobsQuery,
+      variables() {
+        return this.variables;
+      },
+      update({ runner }) {
+        return {
+          items: runner?.jobs?.nodes || [],
+          pageInfo: runner?.jobs?.pageInfo || {},
+        };
+      },
+      error(error) {
+        createAlert({ message: I18N_FETCH_ERROR });
+        this.reportToSentry(error);
+      },
+    },
+  },
+  computed: {
+    variables() {
+      const { id } = this.runner;
+      return {
+        id,
+        ...getPaginationVariables(this.pagination, RUNNER_DETAILS_JOBS_PAGE_SIZE),
+      };
+    },
+    loading() {
+      return this.$apollo.queries.jobs.loading;
+    },
+  },
+  methods: {
+    reportToSentry(error) {
+      captureException({ error, component: this.$options.name });
+    },
+  },
+  I18N_NO_JOBS_FOUND,
+};
+</script>
+
+<template>
+  <div class="gl-pt-3">
+    <gl-skeleton-loading v-if="loading" class="gl-py-5" />
+    <runner-jobs-table v-else-if="jobs.items.length" :jobs="jobs.items" />
+    <p v-else>{{ $options.I18N_NO_JOBS_FOUND }}</p>
+
+    <runner-pagination v-model="pagination" :disabled="loading" :page-info="jobs.pageInfo" />
+  </div>
+</template>
diff --git a/app/assets/javascripts/runner/components/runner_jobs_table.vue b/app/assets/javascripts/runner/components/runner_jobs_table.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7817577bab07f44cb01dc51900c797828b0c1925
--- /dev/null
+++ b/app/assets/javascripts/runner/components/runner_jobs_table.vue
@@ -0,0 +1,95 @@
+<script>
+import { GlTableLite } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
+import RunnerTags from '~/runner/components/runner_tags.vue';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import { tableField } from '../utils';
+import LinkCell from './cells/link_cell.vue';
+
+export default {
+  components: {
+    CiBadge,
+    GlTableLite,
+    LinkCell,
+    RunnerTags,
+    TimeAgo,
+  },
+  props: {
+    jobs: {
+      type: Array,
+      required: true,
+    },
+  },
+  methods: {
+    trAttr(job) {
+      if (job?.id) {
+        return { 'data-testid': `job-row-${getIdFromGraphQLId(job.id)}` };
+      }
+      return {};
+    },
+    jobId(job) {
+      return getIdFromGraphQLId(job.id);
+    },
+    jobPath(job) {
+      return job.detailedStatus?.detailsPath;
+    },
+    projectName(job) {
+      return job.pipeline?.project?.name;
+    },
+    projectWebUrl(job) {
+      return job.pipeline?.project?.webUrl;
+    },
+    commitShortSha(job) {
+      return job.shortSha;
+    },
+    commitPath(job) {
+      return job.commitPath;
+    },
+  },
+  fields: [
+    tableField({ key: 'status', label: s__('Job|Status') }),
+    tableField({ key: 'job', label: __('Job') }),
+    tableField({ key: 'project', label: __('Project') }),
+    tableField({ key: 'commit', label: __('Commit') }),
+    tableField({ key: 'finished_at', label: s__('Job|Finished at') }),
+    tableField({ key: 'tags', label: s__('Runners|Tags') }),
+  ],
+};
+</script>
+
+<template>
+  <gl-table-lite
+    :items="jobs"
+    :fields="$options.fields"
+    :tbody-tr-attr="trAttr"
+    primary-key="id"
+    stacked="md"
+    fixed
+  >
+    <template #cell(status)="{ item = {} }">
+      <ci-badge v-if="item.detailedStatus" :status="item.detailedStatus" />
+    </template>
+
+    <template #cell(job)="{ item = {} }">
+      <link-cell :href="jobPath(item)"> #{{ jobId(item) }} </link-cell>
+    </template>
+
+    <template #cell(project)="{ item = {} }">
+      <link-cell :href="projectWebUrl(item)">{{ projectName(item) }}</link-cell>
+    </template>
+
+    <template #cell(commit)="{ item = {} }">
+      <link-cell :href="commitPath(item)"> {{ commitShortSha(item) }}</link-cell>
+    </template>
+
+    <template #cell(tags)="{ item = {} }">
+      <runner-tags :tag-list="item.tags" />
+    </template>
+
+    <template #cell(finished_at)="{ item = {} }">
+      <time-ago v-if="item.finishedAt" :time="item.finishedAt" />
+    </template>
+  </gl-table-lite>
+</template>
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index 45e61768d1e7fc5e982877ab12263118b3d0e219..1544efaaae2f03163b635c87174d433e23aa3b5b 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -4,6 +4,7 @@ export const RUNNER_PAGE_SIZE = 20;
 export const RUNNER_JOB_COUNT_LIMIT = 1000;
 
 export const RUNNER_DETAILS_PROJECTS_PAGE_SIZE = 5;
+export const RUNNER_DETAILS_JOBS_PAGE_SIZE = 30;
 
 export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.');
 export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
@@ -45,6 +46,7 @@ export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run
 
 export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})');
 export const I18N_NONE = __('None');
+export const I18N_NO_JOBS_FOUND = s__('Runner|This runner has not run any jobs.');
 
 // Styles
 
diff --git a/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql b/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql
new file mode 100644
index 0000000000000000000000000000000000000000..2b1decd3dddbd3aea802f93e3d0fb53f374e11f8
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/get_runner_jobs.query.graphql
@@ -0,0 +1,36 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getRunnerJobs($id: CiRunnerID!, $first: Int, $last: Int, $before: String, $after: String) {
+  runner(id: $id) {
+    id
+    projectCount
+    jobs(before: $before, after: $after, first: $first, last: $last) {
+      nodes {
+        id
+        detailedStatus {
+          # fields for `<ci-badge>`
+          id
+          detailsPath
+          group
+          icon
+          text
+        }
+        pipeline {
+          id
+          project {
+            id
+            name
+            webUrl
+          }
+        }
+        shortSha
+        commitPath
+        tags
+        finishedAt
+      }
+      pageInfo {
+        ...PageInfo
+      }
+    }
+  }
+}
diff --git a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql
index ae29fa3a4dfb1241185abf36c18644ecd4c3222a..74760bbaa0754bbfcda90374ffbced989ffacb01 100644
--- a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql
@@ -8,6 +8,7 @@ fragment RunnerDetailsShared on CiRunner {
   ipAddress
   description
   maximumTimeout
+  jobCount
   tagList
   createdAt
   status(legacyMode: null)
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 76a86981cc8fb67e51d1e559fe6e7b5f4cac8ec3..e19b903fb18b2a3714ae7abac3f09c7262a4ebb5 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -20878,6 +20878,9 @@ msgstr ""
 msgid "Job|Erase job log and artifacts"
 msgstr ""
 
+msgid "Job|Finished at"
+msgstr ""
+
 msgid "Job|Job artifacts"
 msgstr ""
 
@@ -20902,6 +20905,9 @@ msgstr ""
 msgid "Job|Show complete raw"
 msgstr ""
 
+msgid "Job|Status"
+msgstr ""
+
 msgid "Job|The artifacts were removed"
 msgstr ""
 
@@ -31321,6 +31327,9 @@ msgstr ""
 msgid "Runners|Instance"
 msgstr ""
 
+msgid "Runners|Jobs"
+msgstr ""
+
 msgid "Runners|Last contact"
 msgstr ""
 
@@ -31561,6 +31570,9 @@ msgstr ""
 msgid "Runners|stale"
 msgstr ""
 
+msgid "Runner|This runner has not run any jobs."
+msgstr ""
+
 msgid "Running"
 msgstr ""
 
diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb
index befb4e23b22b9f5ffea27b8c7304e11067bee79d..cdb4c3fd8ba6183102ae7bc7d5d7978bb86168b1 100644
--- a/spec/frontend/fixtures/runner.rb
+++ b/spec/frontend/fixtures/runner.rb
@@ -17,6 +17,7 @@
   let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], active: false, version: '2.0.0', revision: '456', description: 'Group runner', ip_address: '127.0.0.1') }
   let_it_be(:group_runner_2) { create(:ci_runner, :group, groups: [group], active: false, version: '2.0.0', revision: '456', description: 'Group runner 2', ip_address: '127.0.0.1') }
   let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project, project_2], active: false, version: '2.0.0', revision: '456', description: 'Project runner', ip_address: '127.0.0.1') }
+  let_it_be(:build) { create(:ci_build, runner: instance_runner) }
 
   query_path = 'runner/graphql/'
   fixtures_path = 'graphql/runner/'
@@ -104,6 +105,22 @@
         expect_graphql_errors_to_be_empty
       end
     end
+
+    describe GraphQL::Query, type: :request do
+      get_runner_jobs_query_name = 'get_runner_jobs.query.graphql'
+
+      let_it_be(:query) do
+        get_graphql_query_as_string("#{query_path}#{get_runner_jobs_query_name}")
+      end
+
+      it "#{fixtures_path}#{get_runner_jobs_query_name}.json" do
+        post_graphql(query, current_user: admin, variables: {
+          id: instance_runner.to_global_id.to_s
+        })
+
+        expect_graphql_errors_to_be_empty
+      end
+    end
   end
 
   describe do
diff --git a/spec/frontend/runner/components/cells/link_cell_spec.js b/spec/frontend/runner/components/cells/link_cell_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..a59a0eaa5d80b008c24ec113acd960041e6c6c75
--- /dev/null
+++ b/spec/frontend/runner/components/cells/link_cell_spec.js
@@ -0,0 +1,72 @@
+import { GlLink } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import LinkCell from '~/runner/components/cells/link_cell.vue';
+
+describe('LinkCell', () => {
+  let wrapper;
+
+  const findGlLink = () => wrapper.find(GlLink);
+  const findSpan = () => wrapper.find('span');
+
+  const createComponent = ({ props = {}, ...options } = {}) => {
+    wrapper = shallowMountExtended(LinkCell, {
+      propsData: {
+        ...props,
+      },
+      ...options,
+    });
+  };
+
+  it('when an href is provided, renders a link', () => {
+    createComponent({ props: { href: '/url' } });
+    expect(findGlLink().exists()).toBe(true);
+  });
+
+  it('when an href is not provided, renders no link', () => {
+    createComponent();
+    expect(findGlLink().exists()).toBe(false);
+  });
+
+  describe.each`
+    href      | findContent
+    ${null}   | ${findSpan}
+    ${'/url'} | ${findGlLink}
+  `('When href is $href', ({ href, findContent }) => {
+    const content = 'My Text';
+    const attrs = { foo: 'bar' };
+    const listeners = {
+      click: jest.fn(),
+    };
+
+    beforeEach(() => {
+      createComponent({
+        props: { href },
+        slots: {
+          default: content,
+        },
+        attrs,
+        listeners,
+      });
+    });
+
+    afterAll(() => {
+      listeners.click.mockReset();
+    });
+
+    it('Renders content', () => {
+      expect(findContent().text()).toBe(content);
+    });
+
+    it('Passes attributes', () => {
+      expect(findContent().attributes()).toMatchObject(attrs);
+    });
+
+    it('Passes event listeners', () => {
+      expect(listeners.click).toHaveBeenCalledTimes(0);
+
+      findContent().vm.$emit('click');
+
+      expect(listeners.click).toHaveBeenCalledTimes(1);
+    });
+  });
+});
diff --git a/spec/frontend/runner/components/runner_details_spec.js b/spec/frontend/runner/components/runner_details_spec.js
index dbc96a30750eb0ad9190eea8b1b80c32d9783b34..6bf4a52a799e8a9f8565d11d00ba8e797a38978e 100644
--- a/spec/frontend/runner/components/runner_details_spec.js
+++ b/spec/frontend/runner/components/runner_details_spec.js
@@ -1,4 +1,4 @@
-import { GlSprintf, GlIntersperse } from '@gitlab/ui';
+import { GlSprintf, GlIntersperse, GlTab } from '@gitlab/ui';
 import { createWrapper, ErrorWrapper } from '@vue/test-utils';
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
 import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -8,6 +8,7 @@ import { ACCESS_LEVEL_REF_PROTECTED, ACCESS_LEVEL_NOT_PROTECTED } from '~/runner
 import RunnerDetails from '~/runner/components/runner_details.vue';
 import RunnerDetail from '~/runner/components/runner_detail.vue';
 import RunnerGroups from '~/runner/components/runner_groups.vue';
+import RunnersJobs from '~/runner/components/runner_jobs.vue';
 import RunnerTags from '~/runner/components/runner_tags.vue';
 import RunnerTag from '~/runner/components/runner_tag.vue';
 
@@ -38,6 +39,8 @@ describe('RunnerDetails', () => {
   };
 
   const findDetailGroups = () => wrapper.findComponent(RunnerGroups);
+  const findRunnersJobs = () => wrapper.findComponent(RunnersJobs);
+  const findJobCountBadge = () => wrapper.findByTestId('job-count-badge');
 
   const createComponent = ({ props = {}, mountFn = shallowMountExtended, stubs } = {}) => {
     wrapper = mountFn(RunnerDetails, {
@@ -146,4 +149,41 @@ describe('RunnerDetails', () => {
       });
     });
   });
+
+  describe('Jobs tab', () => {
+    const stubs = { GlTab };
+
+    it('without a runner, shows no jobs', () => {
+      createComponent({
+        props: { runner: null },
+        stubs,
+      });
+
+      expect(findJobCountBadge().exists()).toBe(false);
+      expect(findRunnersJobs().exists()).toBe(false);
+    });
+
+    it('without a job count, shows no jobs count', () => {
+      createComponent({
+        props: {
+          runner: { ...mockRunner, jobCount: undefined },
+        },
+        stubs,
+      });
+
+      expect(findJobCountBadge().exists()).toBe(false);
+    });
+
+    it('with a job count, shows jobs count', () => {
+      const runner = { ...mockRunner, jobCount: 3 };
+
+      createComponent({
+        props: { runner },
+        stubs,
+      });
+
+      expect(findJobCountBadge().text()).toBe('3');
+      expect(findRunnersJobs().props('runner')).toBe(runner);
+    });
+  });
 });
diff --git a/spec/frontend/runner/components/runner_jobs_spec.js b/spec/frontend/runner/components/runner_jobs_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..97339056370937b8e4b3bd1ebecc4ab8e7dcdc38
--- /dev/null
+++ b/spec/frontend/runner/components/runner_jobs_spec.js
@@ -0,0 +1,156 @@
+import { GlSkeletonLoading } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { createAlert } from '~/flash';
+import RunnerJobs from '~/runner/components/runner_jobs.vue';
+import RunnerJobsTable from '~/runner/components/runner_jobs_table.vue';
+import RunnerPagination from '~/runner/components/runner_pagination.vue';
+import { captureException } from '~/runner/sentry_utils';
+import { I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '~/runner/constants';
+
+import getRunnerJobsQuery from '~/runner/graphql/get_runner_jobs.query.graphql';
+
+import { runnerData, runnerJobsData } from '../mock_data';
+
+jest.mock('~/flash');
+jest.mock('~/runner/sentry_utils');
+
+const mockRunner = runnerData.data.runner;
+const mockRunnerWithJobs = runnerJobsData.data.runner;
+const mockJobs = mockRunnerWithJobs.jobs.nodes;
+
+Vue.use(VueApollo);
+
+describe('RunnerJobs', () => {
+  let wrapper;
+  let mockRunnerJobsQuery;
+
+  const findGlSkeletonLoading = () => wrapper.findComponent(GlSkeletonLoading);
+  const findRunnerJobsTable = () => wrapper.findComponent(RunnerJobsTable);
+  const findRunnerPagination = () => wrapper.findComponent(RunnerPagination);
+
+  const createComponent = ({ mountFn = shallowMountExtended } = {}) => {
+    wrapper = mountFn(RunnerJobs, {
+      apolloProvider: createMockApollo([[getRunnerJobsQuery, mockRunnerJobsQuery]]),
+      propsData: {
+        runner: mockRunner,
+      },
+    });
+  };
+
+  beforeEach(() => {
+    mockRunnerJobsQuery = jest.fn();
+  });
+
+  afterEach(() => {
+    mockRunnerJobsQuery.mockReset();
+    wrapper.destroy();
+  });
+
+  it('Requests runner jobs', async () => {
+    createComponent();
+
+    await waitForPromises();
+
+    expect(mockRunnerJobsQuery).toHaveBeenCalledTimes(1);
+    expect(mockRunnerJobsQuery).toHaveBeenCalledWith({
+      id: mockRunner.id,
+      first: RUNNER_DETAILS_JOBS_PAGE_SIZE,
+    });
+  });
+
+  describe('When there are jobs assigned', () => {
+    beforeEach(async () => {
+      mockRunnerJobsQuery.mockResolvedValueOnce(runnerJobsData);
+
+      createComponent();
+      await waitForPromises();
+    });
+
+    it('Shows jobs', () => {
+      const jobs = findRunnerJobsTable().props('jobs');
+
+      expect(jobs).toHaveLength(mockJobs.length);
+      expect(jobs[0]).toMatchObject(mockJobs[0]);
+    });
+
+    describe('When "Next" page is clicked', () => {
+      beforeEach(async () => {
+        findRunnerPagination().vm.$emit('input', { page: 2, after: 'AFTER_CURSOR' });
+
+        await waitForPromises();
+      });
+
+      it('A new page is requested', () => {
+        expect(mockRunnerJobsQuery).toHaveBeenCalledTimes(2);
+        expect(mockRunnerJobsQuery).toHaveBeenLastCalledWith({
+          id: mockRunner.id,
+          first: RUNNER_DETAILS_JOBS_PAGE_SIZE,
+          after: 'AFTER_CURSOR',
+        });
+      });
+    });
+  });
+
+  describe('When loading', () => {
+    it('shows loading indicator and no other content', () => {
+      createComponent();
+
+      expect(findGlSkeletonLoading().exists()).toBe(true);
+      expect(findRunnerJobsTable().exists()).toBe(false);
+      expect(findRunnerPagination().attributes('disabled')).toBe('true');
+    });
+  });
+
+  describe('When there are no jobs', () => {
+    beforeEach(async () => {
+      mockRunnerJobsQuery.mockResolvedValueOnce({
+        data: {
+          runner: {
+            id: mockRunner.id,
+            projectCount: 0,
+            jobs: {
+              nodes: [],
+              pageInfo: {
+                hasNextPage: false,
+                hasPreviousPage: false,
+                startCursor: '',
+                endCursor: '',
+              },
+            },
+          },
+        },
+      });
+
+      createComponent();
+      await waitForPromises();
+    });
+
+    it('Shows a "None" label', () => {
+      expect(wrapper.text()).toBe(I18N_NO_JOBS_FOUND);
+    });
+  });
+
+  describe('When an error occurs', () => {
+    beforeEach(async () => {
+      mockRunnerJobsQuery.mockRejectedValue(new Error('Error!'));
+
+      createComponent();
+      await waitForPromises();
+    });
+
+    it('shows an error', () => {
+      expect(createAlert).toHaveBeenCalled();
+    });
+
+    it('reports an error', () => {
+      expect(captureException).toHaveBeenCalledWith({
+        component: 'RunnerJobs',
+        error: expect.any(Error),
+      });
+    });
+  });
+});
diff --git a/spec/frontend/runner/components/runner_jobs_table_spec.js b/spec/frontend/runner/components/runner_jobs_table_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..5f4905ad2a86ac291e5f95e0def9530e8e48509e
--- /dev/null
+++ b/spec/frontend/runner/components/runner_jobs_table_spec.js
@@ -0,0 +1,119 @@
+import { GlTableLite } from '@gitlab/ui';
+import {
+  extendedWrapper,
+  shallowMountExtended,
+  mountExtended,
+} from 'helpers/vue_test_utils_helper';
+import { __, s__ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import RunnerJobsTable from '~/runner/components/runner_jobs_table.vue';
+import { useFakeDate } from 'helpers/fake_date';
+import { runnerJobsData } from '../mock_data';
+
+const mockJobs = runnerJobsData.data.runner.jobs.nodes;
+
+describe('RunnerJobsTable', () => {
+  let wrapper;
+  const mockNow = '2021-01-15T12:00:00Z';
+  const mockOneHourAgo = '2021-01-15T11:00:00Z';
+
+  useFakeDate(mockNow);
+
+  const findTable = () => wrapper.findComponent(GlTableLite);
+  const findHeaders = () => wrapper.findAll('th');
+  const findRows = () => wrapper.findAll('[data-testid^="job-row-"]');
+  const findCell = ({ field }) =>
+    extendedWrapper(findRows().at(0).find(`[data-testid="td-${field}"]`));
+
+  const createComponent = ({ props = {} } = {}, mountFn = shallowMountExtended) => {
+    wrapper = mountFn(RunnerJobsTable, {
+      propsData: {
+        jobs: mockJobs,
+        ...props,
+      },
+      stubs: {
+        GlTableLite,
+      },
+    });
+  };
+
+  afterEach(() => {
+    wrapper.destroy();
+  });
+
+  it('Sets job id as a row key', () => {
+    createComponent();
+
+    expect(findTable().attributes('primarykey')).toBe('id');
+  });
+
+  describe('Table data', () => {
+    beforeEach(() => {
+      createComponent({}, mountExtended);
+    });
+
+    it('Displays headers', () => {
+      const headerLabels = findHeaders().wrappers.map((w) => w.text());
+
+      expect(headerLabels).toEqual([
+        s__('Job|Status'),
+        __('Job'),
+        __('Project'),
+        __('Commit'),
+        s__('Job|Finished at'),
+        s__('Runners|Tags'),
+      ]);
+    });
+
+    it('Displays a list of jobs', () => {
+      expect(findRows()).toHaveLength(1);
+    });
+
+    it('Displays details of a job', () => {
+      const { id, detailedStatus, pipeline, shortSha, commitPath } = mockJobs[0];
+
+      expect(findCell({ field: 'status' }).text()).toMatchInterpolatedText(detailedStatus.text);
+
+      expect(findCell({ field: 'job' }).text()).toContain(`#${getIdFromGraphQLId(id)}`);
+      expect(findCell({ field: 'job' }).find('a').attributes('href')).toBe(
+        detailedStatus.detailsPath,
+      );
+
+      expect(findCell({ field: 'project' }).text()).toBe(pipeline.project.name);
+      expect(findCell({ field: 'project' }).find('a').attributes('href')).toBe(
+        pipeline.project.webUrl,
+      );
+
+      expect(findCell({ field: 'commit' }).text()).toBe(shortSha);
+      expect(findCell({ field: 'commit' }).find('a').attributes('href')).toBe(commitPath);
+    });
+  });
+
+  describe('Table data formatting', () => {
+    let mockJobsCopy;
+
+    beforeEach(() => {
+      mockJobsCopy = [
+        {
+          ...mockJobs[0],
+        },
+      ];
+    });
+
+    it('Formats finishedAt time', () => {
+      mockJobsCopy[0].finishedAt = mockOneHourAgo;
+
+      createComponent({ props: { jobs: mockJobsCopy } }, mountExtended);
+
+      expect(findCell({ field: 'finished_at' }).text()).toBe('1 hour ago');
+    });
+
+    it('Formats tags', () => {
+      mockJobsCopy[0].tags = ['tag-1', 'tag-2'];
+
+      createComponent({ props: { jobs: mockJobsCopy } }, mountExtended);
+
+      expect(findCell({ field: 'tags' }).text()).toMatchInterpolatedText('tag-1 tag-2');
+    });
+  });
+});
diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js
index 36120a4c7ed9987941e34b580e56b81fee73fe67..8b76be396efa0d64c0d3de31ae54d6ba8ee94614 100644
--- a/spec/frontend/runner/components/runner_update_form_spec.js
+++ b/spec/frontend/runner/components/runner_update_form_spec.js
@@ -123,6 +123,7 @@ describe('RunnerUpdateForm', () => {
 
     // Some read-only fields are not submitted
     const {
+      __typename,
       ipAddress,
       runnerType,
       createdAt,
@@ -132,7 +133,7 @@ describe('RunnerUpdateForm', () => {
       userPermissions,
       version,
       groups,
-      __typename,
+      jobCount,
       ...submitted
     } = mockRunner;
 
diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js
index 7260f0fbc9ad3b533423a6da9a6308551e2a2735..d80caa477528fc43b59fa521cdb4a163670ecee4 100644
--- a/spec/frontend/runner/mock_data.js
+++ b/spec/frontend/runner/mock_data.js
@@ -7,6 +7,7 @@ import runnersDataPaginated from 'test_fixtures/graphql/runner/get_runners.query
 import runnerData from 'test_fixtures/graphql/runner/get_runner.query.graphql.json';
 import runnerWithGroupData from 'test_fixtures/graphql/runner/get_runner.query.graphql.with_group.json';
 import runnerProjectsData from 'test_fixtures/graphql/runner/get_runner_projects.query.graphql.json';
+import runnerJobsData from 'test_fixtures/graphql/runner/get_runner_jobs.query.graphql.json';
 
 // Group queries
 import groupRunnersData from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.json';
@@ -20,6 +21,7 @@ export {
   runnerData,
   runnerWithGroupData,
   runnerProjectsData,
+  runnerJobsData,
   groupRunnersData,
   groupRunnersCountData,
   groupRunnersDataPaginated,