diff options
Diffstat (limited to 'app')
22 files changed, 326 insertions, 91 deletions
diff --git a/app/assets/javascripts/admin/statistics_panel/components/app.vue b/app/assets/javascripts/admin/statistics_panel/components/app.vue index f250bdae4f5..347d5f0229c 100644 --- a/app/assets/javascripts/admin/statistics_panel/components/app.vue +++ b/app/assets/javascripts/admin/statistics_panel/components/app.vue @@ -1,10 +1,11 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlCard, GlLoadingIcon } from '@gitlab/ui'; import { mapState, mapGetters, mapActions } from 'vuex'; import statisticsLabels from '../constants'; export default { components: { + GlCard, GlLoadingIcon, }, data() { @@ -26,20 +27,14 @@ export default { </script> <template> - <div class="gl-card"> - <div class="gl-card-body"> - <h4>{{ __('Statistics') }}</h4> - <gl-loading-icon v-if="isLoading" size="lg" class="my-3" /> - <template v-else> - <p - v-for="statistic in getStatistics(statisticsLabels)" - :key="statistic.key" - class="js-stats" - > - {{ statistic.label }} - <span class="light float-right">{{ statistic.value }}</span> - </p> - </template> - </div> - </div> + <gl-card> + <h4>{{ __('Statistics') }}</h4> + <gl-loading-icon v-if="isLoading" size="lg" class="my-3" /> + <template v-else> + <p v-for="statistic in getStatistics(statisticsLabels)" :key="statistic.key" class="js-stats"> + {{ statistic.label }} + <span class="light float-right">{{ statistic.value }}</span> + </p> + </template> + </gl-card> </template> diff --git a/app/assets/javascripts/groups/settings/components/access_dropdown.vue b/app/assets/javascripts/groups/settings/components/access_dropdown.vue index b3813636631..28f059fa23e 100644 --- a/app/assets/javascripts/groups/settings/components/access_dropdown.vue +++ b/app/assets/javascripts/groups/settings/components/access_dropdown.vue @@ -173,21 +173,23 @@ export default { <template #header> <gl-search-box-by-type ref="search" v-model.trim="query" :is-loading="loading" /> </template> - <template v-if="groups.length"> - <gl-dropdown-section-header>{{ - $options.i18n.groupsSectionHeader - }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="group in groups" - :key="`${group.id}${group.name}`" - data-testid="group-dropdown-item" - :avatar-url="group.avatar_url" - is-check-item - :is-checked="isSelected(group)" - @click.native.capture.stop="onItemClick(group)" - > - {{ group.name }} - </gl-dropdown-item> - </template> + <div> + <template v-if="groups.length"> + <gl-dropdown-section-header>{{ + $options.i18n.groupsSectionHeader + }}</gl-dropdown-section-header> + <gl-dropdown-item + v-for="group in groups" + :key="`${group.id}${group.name}`" + data-testid="group-dropdown-item" + :avatar-url="group.avatar_url" + is-check-item + :is-checked="isSelected(group)" + @click.native.capture.stop="onItemClick(group)" + > + {{ group.name }} + </gl-dropdown-item> + </template> + </div> </gl-dropdown> </template> diff --git a/app/assets/javascripts/pages/groups/runners/index.js b/app/assets/javascripts/pages/groups/runners/index/index.js index ca1a6bdab75..ca1a6bdab75 100644 --- a/app/assets/javascripts/pages/groups/runners/index.js +++ b/app/assets/javascripts/pages/groups/runners/index/index.js diff --git a/app/assets/javascripts/pages/groups/runners/show/index.js b/app/assets/javascripts/pages/groups/runners/show/index.js new file mode 100644 index 00000000000..c59e3b80dc1 --- /dev/null +++ b/app/assets/javascripts/pages/groups/runners/show/index.js @@ -0,0 +1,3 @@ +import { initGroupRunnerShow } from '~/runner/group_runner_show'; + +initGroupRunnerShow(); diff --git a/app/assets/javascripts/releases/components/release_block_footer.vue b/app/assets/javascripts/releases/components/release_block_footer.vue index e7eaf5a49bf..3881c83b5c2 100644 --- a/app/assets/javascripts/releases/components/release_block_footer.vue +++ b/app/assets/javascripts/releases/components/release_block_footer.vue @@ -66,8 +66,11 @@ export default { </script> <template> <div> - <div v-if="commit" class="float-left mr-3 d-flex align-items-center js-commit-info"> - <gl-icon ref="commitIcon" name="commit" class="mr-1" /> + <div + v-if="commit" + class="gl-float-left gl-mr-5 gl-display-flex gl-align-items-center js-commit-info" + > + <gl-icon ref="commitIcon" name="commit" class="gl-mr-2" /> <div v-gl-tooltip.bottom :title="commit.title"> <gl-link v-if="commitPath" :href="commitPath"> {{ commit.shortId }} @@ -76,8 +79,11 @@ export default { </div> </div> - <div v-if="tagName" class="float-left mr-3 d-flex align-items-center js-tag-info"> - <gl-icon name="tag" class="mr-1" /> + <div + v-if="tagName" + class="gl-float-left gl-mr-5 gl-display-flex gl-align-items-center js-tag-info" + > + <gl-icon name="tag" class="gl-mr-2" /> <div v-gl-tooltip.bottom :title="__('Tag')"> <gl-link v-if="tagPath" :href="tagPath"> {{ tagName }} @@ -88,23 +94,23 @@ export default { <div v-if="releasedAt || author" - class="float-left d-flex align-items-center js-author-date-info" + class="gl-float-left gl-display-flex gl-align-items-center js-author-date-info" > - <span class="text-secondary">{{ createdTime }} </span> + <span class="gl-text-secondary">{{ createdTime }} </span> <template v-if="releasedAt"> <span v-gl-tooltip.bottom :title="tooltipTitle(releasedAt)" - class="text-secondary flex-shrink-0" + class="gl-text-secondary gl-flex-shrink-0" > {{ releasedAtTimeAgo }} </span> </template> - <div v-if="author" class="d-flex"> - <span class="text-secondary">{{ __('by') }} </span> + <div v-if="author" class="gl-display-flex"> + <span class="gl-text-secondary">{{ __('by') }} </span> <user-avatar-link - class="gl-my-n1" + class="gl-my-n1 gl-display-flex" :link-href="author.webUrl" :img-src="author.avatarUrl" :img-alt="userImageAltDescription" diff --git a/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue b/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue index c336e091fdf..ce5033d23f3 100644 --- a/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue +++ b/app/assets/javascripts/runner/group_runner_show/group_runner_show_app.vue @@ -1,16 +1,13 @@ <script> -import { GlBadge, GlTab, GlTooltipDirective } from '@gitlab/ui'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; import { TYPE_CI_RUNNER } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { redirectTo } from '~/lib/utils/url_utility'; -import { formatJobCount } from '../utils'; import RunnerDeleteButton from '../components/runner_delete_button.vue'; import RunnerEditButton from '../components/runner_edit_button.vue'; import RunnerPauseButton from '../components/runner_pause_button.vue'; import RunnerHeader from '../components/runner_header.vue'; import RunnerDetails from '../components/runner_details.vue'; -import RunnerJobs from '../components/runner_jobs.vue'; import { I18N_FETCH_ERROR } from '../constants'; import runnerQuery from '../graphql/show/runner.query.graphql'; import { captureException } from '../sentry_utils'; @@ -19,17 +16,11 @@ import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_lo export default { name: 'GroupRunnerShowApp', components: { - GlBadge, - GlTab, RunnerDeleteButton, RunnerEditButton, RunnerPauseButton, RunnerHeader, RunnerDetails, - RunnerJobs, - }, - directives: { - GlTooltip: GlTooltipDirective, }, props: { runnerId: { @@ -40,6 +31,11 @@ export default { type: String, required: true, }, + editGroupRunnerPath: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -68,9 +64,6 @@ export default { canDelete() { return this.runner.userPermissions?.deleteRunner; }, - jobCount() { - return formatJobCount(this.runner?.jobCount); - }, }, errorCaptured(error) { this.reportToSentry(error); @@ -90,25 +83,12 @@ export default { <div> <runner-header v-if="runner" :runner="runner"> <template #actions> - <runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" /> + <runner-edit-button v-if="canUpdate && editGroupRunnerPath" :href="editGroupRunnerPath" /> <runner-pause-button v-if="canUpdate" :runner="runner" /> <runner-delete-button v-if="canDelete" :runner="runner" @deleted="onDeleted" /> </template> </runner-header> - <runner-details :runner="runner"> - <template #jobs-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> - </template> - </runner-details> + <runner-details :runner="runner" /> </div> </template> diff --git a/app/assets/javascripts/runner/group_runner_show/index.js b/app/assets/javascripts/runner/group_runner_show/index.js index d1b87c8e427..62a0dab9211 100644 --- a/app/assets/javascripts/runner/group_runner_show/index.js +++ b/app/assets/javascripts/runner/group_runner_show/index.js @@ -1,21 +1,18 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import { showAlertFromLocalStorage } from '../local_storage_alert/show_alert_from_local_storage'; import GroupRunnerShowApp from './group_runner_show_app.vue'; Vue.use(VueApollo); -export const initAdminRunnerShow = (selector = '#js-group-runner-show') => { - showAlertFromLocalStorage(); - +export const initGroupRunnerShow = (selector = '#js-group-runner-show') => { const el = document.querySelector(selector); if (!el) { return null; } - const { runnerId, runnersPath } = el.dataset; + const { runnerId, runnersPath, editGroupRunnerPath } = el.dataset; const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), @@ -29,6 +26,7 @@ export const initAdminRunnerShow = (selector = '#js-group-runner-show') => { props: { runnerId, runnersPath, + editGroupRunnerPath, }, }); }, diff --git a/app/models/awareness_session.rb b/app/models/awareness_session.rb new file mode 100644 index 00000000000..faadb7e0ba9 --- /dev/null +++ b/app/models/awareness_session.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +# A Redis backed session store for real-time collaboration. A session is defined +# by its documents and the users that join this session. An online user can have +# two states within the session: "active" and "away". +# +# By design, session must eventually be cleaned up. If this doesn't happen +# explicitly, all keys used within the session model must have an expiry +# timestamp set. +class AwarenessSession # rubocop:disable Gitlab/NamespacedClass + # An awareness session expires automatically after 1 hour of no activity + SESSION_LIFETIME = 1.hour + private_constant :SESSION_LIFETIME + + # Expire user awareness keys after some time of inactivity + USER_LIFETIME = 1.hour + private_constant :USER_LIFETIME + + PRESENCE_LIFETIME = 10.minutes + private_constant :PRESENCE_LIFETIME + + KEY_NAMESPACE = "gitlab:awareness" + private_constant :KEY_NAMESPACE + + class << self + def for(value = nil) + # Creates a unique value for situations where we have no unique value to + # create a session with. This could be when creating a new issue, a new + # merge request, etc. + value = SecureRandom.uuid unless value.present? + + # We use SHA-256 based session identifiers (similar to abbreviated git + # hashes). There is always a chance for Hash collisions (birthday + # problem), we therefore have to pick a good tradeoff between the amount + # of data stored and the probability of a collision. + # + # The approximate probability for a collision can be calculated: + # + # p ~= n^2 / 2m + # ~= (2^18)^2 / (2 * 16^15) + # ~= 2^36 / 2^61 + # + # n is the number of awareness sessions and m the number of possibilities + # for each item. For a hex number, this is 16^c, where c is the number of + # characters. With 260k (~2^18) sessions, the probability for a collision + # is ~2^-25. + # + # The number of 15 is selected carefully. The integer representation fits + # nicely into a signed 64 bit integer and eventually allows Redis to + # optimize its memory usage. 16 chars would exceed the space for + # this datatype. + id = Digest::SHA256.hexdigest(value.to_s)[0, 15] + + AwarenessSession.new(id) + end + end + + def initialize(id) + @id = id + end + + def join(user) + user_key = user_sessions_key(user.id) + + with_redis do |redis| + redis.pipelined do |pipeline| + pipeline.sadd(user_key, id_i) + pipeline.expire(user_key, USER_LIFETIME.to_i) + + pipeline.zadd(users_key, timestamp.to_f, user.id) + + # We also mark for expiry when a session key is created (first user joins), + # because some users might never actively leave a session and the key could + # therefore become stale, w/o us noticing. + reset_session_expiry(pipeline) + end + end + + nil + end + + def leave(user) + user_key = user_sessions_key(user.id) + + with_redis do |redis| + redis.pipelined do |pipeline| + pipeline.srem(user_key, id_i) + pipeline.zrem(users_key, user.id) + end + + # cleanup orphan sessions and users + # + # this needs to be a second pipeline due to the delete operations being + # dependent on the result of the cardinality checks + user_sessions_count, session_users_count = redis.pipelined do |pipeline| + pipeline.scard(user_key) + pipeline.zcard(users_key) + end + + redis.pipelined do |pipeline| + pipeline.del(user_key) unless user_sessions_count > 0 + + unless session_users_count > 0 + pipeline.del(users_key) + @id = nil + end + end + end + + nil + end + + def present?(user, threshold: PRESENCE_LIFETIME) + with_redis do |redis| + user_timestamp = redis.zscore(users_key, user.id) + break false unless user_timestamp.present? + + timestamp - user_timestamp < threshold + end + end + + def away?(user, threshold: PRESENCE_LIFETIME) + !present?(user, threshold: threshold) + end + + # Updates the last_activity timestamp for a user in this session + def touch!(user) + with_redis do |redis| + redis.pipelined do |pipeline| + pipeline.zadd(users_key, timestamp.to_f, user.id) + + # extend the session lifetime due to user activity + reset_session_expiry(pipeline) + end + end + + nil + end + + def size + with_redis do |redis| + redis.zcard(users_key) + end + end + + def users + User.where(id: user_ids) + end + + def users_with_last_activity + user_ids, last_activities = user_ids_with_last_activity.transpose + users = User.where(id: user_ids) + users.zip(last_activities) + end + + private + + attr_reader :id + + # converts session id from hex to integer representation + def id_i + Integer(id, 16) if id.present? + end + + def users_key + "#{KEY_NAMESPACE}:session:#{id}:users" + end + + def user_sessions_key(user_id) + "#{KEY_NAMESPACE}:user:#{user_id}:sessions" + end + + def with_redis + Gitlab::Redis::SharedState.with do |redis| + yield redis if block_given? + end + end + + def timestamp + Time.now.to_i + end + + def user_ids + with_redis do |redis| + redis.zrange(users_key, 0, -1) + end + end + + # Returns an array of tuples, where the first element in the tuple represents + # the user ID and the second part the last_activity timestamp. + def user_ids_with_last_activity + pairs = with_redis do |redis| + redis.zrange(users_key, 0, -1, with_scores: true) + end + + # map data type of score (float) to Time + pairs.map do |user_id, score| + [user_id, Time.zone.at(score.to_i)] + end + end + + # We want sessions to cleanup automatically after a certain period of + # inactivity. This sets the expiry timestamp for this session to + # [SESSION_LIFETIME]. + def reset_session_expiry(redis) + redis.expire(users_key, SESSION_LIFETIME) + + nil + end +end diff --git a/app/models/concerns/awareness.rb b/app/models/concerns/awareness.rb new file mode 100644 index 00000000000..da87d87e838 --- /dev/null +++ b/app/models/concerns/awareness.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Awareness + extend ActiveSupport::Concern + + KEY_NAMESPACE = "gitlab:awareness" + private_constant :KEY_NAMESPACE + + def join(session) + session.join(self) + + nil + end + + def leave(session) + session.leave(self) + + nil + end + + def session_ids + with_redis do |redis| + redis + .smembers(user_sessions_key) + # converts session ids from (internal) integer to hex presentation + .map { |key| key.to_i.to_s(16) } + end + end + + private + + def user_sessions_key + "#{KEY_NAMESPACE}:user:#{id}:sessions" + end + + def with_redis + Gitlab::Redis::SharedState.with do |redis| + yield redis if block_given? + end + end +end diff --git a/app/models/concerns/integrations/slack_mattermost_notifier.rb b/app/models/concerns/integrations/slack_mattermost_notifier.rb index 3bdaa852ddf..142e62bb501 100644 --- a/app/models/concerns/integrations/slack_mattermost_notifier.rb +++ b/app/models/concerns/integrations/slack_mattermost_notifier.rb @@ -34,7 +34,7 @@ module Integrations class HTTPClient def self.post(uri, params = {}) params.delete(:http_options) # these are internal to the client and we do not want them - Gitlab::HTTP.post(uri, body: params, use_read_total_timeout: true) + Gitlab::HTTP.post(uri, body: params) end end end diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb index 4e30c1ccc69..230dc6bb336 100644 --- a/app/models/integrations/bamboo.rb +++ b/app/models/integrations/bamboo.rb @@ -155,7 +155,6 @@ module Integrations query_params[:os_authType] = 'basic' params[:basic_auth] = basic_auth - params[:use_read_total_timeout] = true params end diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb index bffe87c21ee..fe4a2f43b13 100644 --- a/app/models/integrations/base_issue_tracker.rb +++ b/app/models/integrations/base_issue_tracker.rb @@ -94,7 +94,7 @@ module Integrations result = false begin - response = Gitlab::HTTP.head(self.project_url, verify: true, use_read_total_timeout: true) + response = Gitlab::HTTP.head(self.project_url, verify: true) if response message = "#{self.type} received response #{response.code} when attempting to connect to #{self.project_url}" diff --git a/app/models/integrations/drone_ci.rb b/app/models/integrations/drone_ci.rb index 35524503dea..b1f72b7144e 100644 --- a/app/models/integrations/drone_ci.rb +++ b/app/models/integrations/drone_ci.rb @@ -60,8 +60,7 @@ module Integrations response = Gitlab::HTTP.try_get( commit_status_path(sha, ref), verify: enable_ssl_verification, - extra_log_info: { project_id: project_id }, - use_read_total_timeout: true + extra_log_info: { project_id: project_id } ) status = diff --git a/app/models/integrations/external_wiki.rb b/app/models/integrations/external_wiki.rb index c16ae9926f1..bc2ea193a84 100644 --- a/app/models/integrations/external_wiki.rb +++ b/app/models/integrations/external_wiki.rb @@ -29,7 +29,7 @@ module Integrations end def execute(_data) - response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true, use_read_total_timeout: true) + response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) response.body if response.code == 200 rescue StandardError nil diff --git a/app/models/integrations/mock_ci.rb b/app/models/integrations/mock_ci.rb index 0b3a9bc5405..2d8e26d409f 100644 --- a/app/models/integrations/mock_ci.rb +++ b/app/models/integrations/mock_ci.rb @@ -49,7 +49,7 @@ module Integrations # # => 'running' # def commit_status(sha, ref) - response = Gitlab::HTTP.get(commit_status_path(sha), verify: enable_ssl_verification, use_read_total_timeout: true) + response = Gitlab::HTTP.get(commit_status_path(sha), verify: enable_ssl_verification) read_commit_status(response) rescue Errno::ECONNREFUSED :error diff --git a/app/models/integrations/shimo.rb b/app/models/integrations/shimo.rb index dd25a0bc558..350ee61ad11 100644 --- a/app/models/integrations/shimo.rb +++ b/app/models/integrations/shimo.rb @@ -25,7 +25,7 @@ module Integrations # support for `test` method def execute(_data) - response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true, use_read_total_timeout: true) + response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) response.body if response.code == 200 rescue StandardError nil diff --git a/app/models/integrations/teamcity.rb b/app/models/integrations/teamcity.rb index a23aa5f783d..e0299c9ac5f 100644 --- a/app/models/integrations/teamcity.rb +++ b/app/models/integrations/teamcity.rb @@ -156,7 +156,7 @@ module Integrations end def get_path(path) - Gitlab::HTTP.try_get(build_url(path), verify: enable_ssl_verification, basic_auth: basic_auth, extra_log_info: { project_id: project_id }, use_read_total_timeout: true) + Gitlab::HTTP.try_get(build_url(path), verify: enable_ssl_verification, basic_auth: basic_auth, extra_log_info: { project_id: project_id }) end def post_to_build_queue(data, branch) @@ -167,8 +167,7 @@ module Integrations '</build>', headers: { 'Content-type' => 'application/xml' }, verify: enable_ssl_verification, - basic_auth: basic_auth, - use_read_total_timeout: true + basic_auth: basic_auth ) end diff --git a/app/models/integrations/unify_circuit.rb b/app/models/integrations/unify_circuit.rb index 646c2e75b03..f10a75fac5d 100644 --- a/app/models/integrations/unify_circuit.rb +++ b/app/models/integrations/unify_circuit.rb @@ -46,8 +46,7 @@ module Integrations response = Gitlab::HTTP.post(webhook, body: { subject: message.project_name, text: message.summary, - markdown: true, - use_read_total_timeout: true + markdown: true }.to_json) response if response.success? diff --git a/app/models/integrations/webex_teams.rb b/app/models/integrations/webex_teams.rb index 54d6f51ee17..75be457dcf5 100644 --- a/app/models/integrations/webex_teams.rb +++ b/app/models/integrations/webex_teams.rb @@ -44,7 +44,7 @@ module Integrations def notify(message, opts) header = { 'Content-Type' => 'application/json' } - response = Gitlab::HTTP.post(webhook, headers: header, body: { markdown: message.summary }.to_json, use_read_total_timeout: true) + response = Gitlab::HTTP.post(webhook, headers: header, body: { markdown: message.summary }.to_json) response if response.success? end diff --git a/app/models/user.rb b/app/models/user.rb index 2afd64358ef..cd4cc3537bb 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -9,6 +9,7 @@ class User < ApplicationRecord include Gitlab::SQL::Pattern include AfterCommitQueue include Avatarable + include Awareness include Referable include Sortable include CaseSensitivity diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index f2f94563e56..b68269fcf72 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -48,7 +48,6 @@ class WebHookService @force = force @request_options = { timeout: Gitlab.config.gitlab.webhook_timeout, - use_read_total_timeout: true, allow_local_requests: hook.allow_local_requests? } end diff --git a/app/views/groups/runners/show.html.haml b/app/views/groups/runners/show.html.haml index 5a9d2ca858e..828e8278127 100644 --- a/app/views/groups/runners/show.html.haml +++ b/app/views/groups/runners/show.html.haml @@ -1,6 +1,10 @@ - add_to_breadcrumbs _('Runners'), group_runners_path(@group) - if Feature.enabled?(:group_runner_view_ui) - #js-group-runner-show{ data: {runner_id: @runner.id, runners_path: group_runners_path(@group)} } + - title = "##{@runner.id} (#{@runner.short_sha})" + - breadcrumb_title title + - page_title title + + #js-group-runner-show{ data: {runner_id: @runner.id, runners_path: group_runners_path(@group), edit_group_runner_path: edit_group_runner_path(@group, @runner)} } - else = render 'shared/runners/runner_details', runner: @runner |