diff --git a/app/assets/javascripts/jobs/components/log/collapsible_section.vue b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7c0deb08488a09543910becc4875d00bf599bf5b
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/log/collapsible_section.vue
@@ -0,0 +1,55 @@
+<script>
+import LogLine from './line.vue';
+import LogLineHeader from './line_header.vue';
+
+export default {
+  name: 'CollpasibleLogSection',
+  components: {
+    LogLine,
+    LogLineHeader,
+  },
+  props: {
+    section: {
+      type: Object,
+      required: true,
+    },
+    traceEndpoint: {
+      type: String,
+      required: true,
+    },
+  },
+  computed: {
+    badgeDuration() {
+      return this.section.line && this.section.line.section_duration;
+    },
+  },
+  methods: {
+    handleOnClickCollapsibleLine(section) {
+      this.$emit('onClickCollapsibleLine', section);
+    },
+  },
+};
+</script>
+<template>
+  <div>
+    <log-line-header
+      :line="section.line"
+      :duration="badgeDuration"
+      :path="traceEndpoint"
+      :is-closed="section.isClosed"
+      @toggleLine="handleOnClickCollapsibleLine(section)"
+    />
+    <template v-if="!section.isClosed">
+      <template v-for="line in section.lines">
+        <collpasible-log-section
+          v-if="line.isHeader"
+          :key="`collapsible-nested-${line.offset}`"
+          :section="line"
+          :trace-endpoint="traceEndpoint"
+          @toggleLine="handleOnClickCollapsibleLine"
+        />
+        <log-line v-else :key="line.offset" :line="line" :path="traceEndpoint" />
+      </template>
+    </template>
+  </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue
index 429796aeb4e5188cdf11cd32f9b536a1a56b4675..ef126166e8bd9c1520d4d1466425c0b5ef414b7b 100644
--- a/app/assets/javascripts/jobs/components/log/log.vue
+++ b/app/assets/javascripts/jobs/components/log/log.vue
@@ -1,12 +1,12 @@
 <script>
 import { mapState, mapActions } from 'vuex';
+import CollpasibleLogSection from './collapsible_section.vue';
 import LogLine from './line.vue';
-import LogLineHeader from './line_header.vue';
 
 export default {
   components: {
+    CollpasibleLogSection,
     LogLine,
-    LogLineHeader,
   },
   computed: {
     ...mapState(['traceEndpoint', 'trace', 'isTraceComplete']),
@@ -22,24 +22,13 @@ export default {
 <template>
   <code class="job-log d-block">
     <template v-for="(section, index) in trace">
-      <template v-if="section.isHeader">
-        <log-line-header
-          :key="`collapsible-${index}`"
-          :line="section.line"
-          :duration="section.section_duration"
-          :path="traceEndpoint"
-          :is-closed="section.isClosed"
-          @toggleLine="handleOnClickCollapsibleLine(section)"
-        />
-        <template v-if="!section.isClosed">
-          <log-line
-            v-for="line in section.lines"
-            :key="line.offset"
-            :line="line"
-            :path="traceEndpoint"
-          />
-        </template>
-      </template>
+      <collpasible-log-section
+        v-if="section.isHeader"
+        :key="`collapsible-${index}`"
+        :section="section"
+        :trace-endpoint="traceEndpoint"
+        @onClickCollapsibleLine="handleOnClickCollapsibleLine"
+      />
       <log-line v-else :key="section.offset" :line="section" :path="traceEndpoint" />
     </template>
 
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 82b4ec750ff28d0eb1669e69c29c8f945e49ce87..c4a0ed63080a7eeb529ab240d92dc88837e2dd4b 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -28,6 +28,7 @@
 @import 'framework/issue_box';
 @import 'framework/lists';
 @import 'framework/logo';
+@import 'framework/job_log';
 @import 'framework/markdown_area';
 @import 'framework/media_object';
 @import 'framework/modal';
diff --git a/spec/frontend/jobs/components/log/collapsible_section_spec.js b/spec/frontend/jobs/components/log/collapsible_section_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..6c1ebf0a7c1f9df5fb50c3b73f529255a5ea556b
--- /dev/null
+++ b/spec/frontend/jobs/components/log/collapsible_section_spec.js
@@ -0,0 +1,60 @@
+import { mount } from '@vue/test-utils';
+import CollpasibleSection from '~/jobs/components/log/collapsible_section.vue';
+import { nestedSectionOpened, nestedSectionClosed } from './mock_data';
+
+describe('Job Log Collapsible Section', () => {
+  let wrapper;
+
+  const traceEndpoint = 'jobs/335';
+
+  const findCollapsibleLine = () => wrapper.find('.collapsible-line');
+
+  const createComponent = (props = {}) => {
+    wrapper = mount(CollpasibleSection, {
+      sync: true,
+      propsData: {
+        ...props,
+      },
+    });
+  };
+
+  afterEach(() => {
+    wrapper.destroy();
+  });
+
+  describe('with closed nested section', () => {
+    beforeEach(() => {
+      createComponent({
+        section: nestedSectionClosed,
+        traceEndpoint,
+      });
+    });
+
+    it('renders clickable header line', () => {
+      expect(findCollapsibleLine().attributes('role')).toBe('button');
+    });
+  });
+
+  describe('with opened nested section', () => {
+    beforeEach(() => {
+      createComponent({
+        section: nestedSectionOpened,
+        traceEndpoint,
+      });
+    });
+
+    it('renders all sections opened', () => {
+      expect(wrapper.findAll('.collapsible-line').length).toBe(2);
+    });
+  });
+
+  it('emits onClickCollapsibleLine on click', () => {
+    createComponent({
+      section: nestedSectionOpened,
+      traceEndpoint,
+    });
+
+    findCollapsibleLine().trigger('click');
+    expect(wrapper.emitted('onClickCollapsibleLine').length).toBe(1);
+  });
+});
diff --git a/spec/frontend/jobs/components/log/mock_data.js b/spec/frontend/jobs/components/log/mock_data.js
index db42644de77b0175e5feb3cf9352a6bba49b6c9a..0dae306dcc7f64e48323710a3588f91919736aa1 100644
--- a/spec/frontend/jobs/components/log/mock_data.js
+++ b/spec/frontend/jobs/components/log/mock_data.js
@@ -150,3 +150,73 @@ export const collapsibleTraceIncremental = [
     sections: ['section'],
   },
 ];
+
+export const nestedSectionClosed = {
+  offset: 5,
+  section_header: true,
+  isHeader: true,
+  isClosed: true,
+  line: {
+    content: [{ text: 'foo' }],
+    sections: ['prepare-script'],
+    lineNumber: 1,
+  },
+  section_duration: '00:03',
+  lines: [
+    {
+      section_header: true,
+      section_duration: '00:02',
+      isHeader: true,
+      isClosed: true,
+      line: {
+        offset: 52,
+        content: [{ text: 'bar' }],
+        sections: ['prepare-script', 'prepare-script-nested'],
+        lineNumber: 2,
+      },
+      lines: [
+        {
+          offset: 80,
+          content: [{ text: 'this is a collapsible nested section' }],
+          sections: ['prepare-script', 'prepare-script-nested'],
+          lineNumber: 3,
+        },
+      ],
+    },
+  ],
+};
+
+export const nestedSectionOpened = {
+  offset: 5,
+  section_header: true,
+  isHeader: true,
+  isClosed: false,
+  line: {
+    content: [{ text: 'foo' }],
+    sections: ['prepare-script'],
+    lineNumber: 1,
+  },
+  section_duration: '00:03',
+  lines: [
+    {
+      section_header: true,
+      section_duration: '00:02',
+      isHeader: true,
+      isClosed: false,
+      line: {
+        offset: 52,
+        content: [{ text: 'bar' }],
+        sections: ['prepare-script', 'prepare-script-nested'],
+        lineNumber: 2,
+      },
+      lines: [
+        {
+          offset: 80,
+          content: [{ text: 'this is a collapsible nested section' }],
+          sections: ['prepare-script', 'prepare-script-nested'],
+          lineNumber: 3,
+        },
+      ],
+    },
+  ],
+};