diff options
Diffstat (limited to 'app')
64 files changed, 772 insertions, 150 deletions
diff --git a/app/assets/javascripts/blame/streaming/index.js b/app/assets/javascripts/blame/streaming/index.js new file mode 100644 index 00000000000..a74e01b6423 --- /dev/null +++ b/app/assets/javascripts/blame/streaming/index.js @@ -0,0 +1,56 @@ +import { renderHtmlStreams } from '~/streaming/render_html_streams'; +import { handleStreamedAnchorLink } from '~/streaming/handle_streamed_anchor_link'; +import { createAlert } from '~/flash'; +import { __ } from '~/locale'; +import { rateLimitStreamRequests } from '~/streaming/rate_limit_stream_requests'; +import { toPolyfillReadable } from '~/streaming/polyfills'; + +export async function renderBlamePageStreams(firstStreamPromise) { + const element = document.querySelector('#blame-stream-container'); + + if (!element || !firstStreamPromise) return; + + const stopAnchorObserver = handleStreamedAnchorLink(element); + const { dataset } = document.querySelector('#blob-content-holder'); + const totalExtraPages = parseInt(dataset.totalExtraPages, 10); + const { pagesUrl } = dataset; + + const remainingStreams = rateLimitStreamRequests({ + factory: (index) => { + const url = new URL(pagesUrl); + // page numbers start with 1 + // the first page is already rendered in the document + // the second page is passed with the 'firstStreamPromise' + url.searchParams.set('page', index + 3); + return fetch(url).then((response) => toPolyfillReadable(response.body)); + }, + // we don't want to overload gitaly with concurrent requests + // https://gitlab.com/gitlab-org/gitlab/-/issues/391842#note_1281695095 + // using 5 as a good starting point + maxConcurrentRequests: 5, + total: totalExtraPages, + }); + + try { + await renderHtmlStreams( + [firstStreamPromise.then(toPolyfillReadable), ...remainingStreams], + element, + ); + } catch (error) { + createAlert({ + message: __('Blame could not be loaded as a single page.'), + primaryButton: { + text: __('View blame as separate pages'), + clickHandler() { + const newUrl = new URL(window.location); + newUrl.searchParams.delete('streaming'); + window.location.href = newUrl; + }, + }, + }); + throw error; + } finally { + stopAnchorObserver(); + document.querySelector('#blame-stream-loading').remove(); + } +} diff --git a/app/assets/javascripts/pages/projects/blame/show/index.js b/app/assets/javascripts/pages/projects/blame/show/index.js index 1e4b9de90f2..f0fdd18c828 100644 --- a/app/assets/javascripts/pages/projects/blame/show/index.js +++ b/app/assets/javascripts/pages/projects/blame/show/index.js @@ -1,5 +1,10 @@ import initBlob from '~/pages/projects/init_blob'; import redirectToCorrectPage from '~/blame/blame_redirect'; +import { renderBlamePageStreams } from '~/blame/streaming'; -redirectToCorrectPage(); +if (new URLSearchParams(window.location.search).get('streaming')) { + renderBlamePageStreams(window.blamePageStream); +} else { + redirectToCorrectPage(); +} initBlob(); diff --git a/app/assets/javascripts/streaming/chunk_writer.js b/app/assets/javascripts/streaming/chunk_writer.js new file mode 100644 index 00000000000..4bbd0a5f843 --- /dev/null +++ b/app/assets/javascripts/streaming/chunk_writer.js @@ -0,0 +1,144 @@ +import { throttle } from 'lodash'; +import { RenderBalancer } from '~/streaming/render_balancer'; +import { + BALANCE_RATE, + HIGH_FRAME_TIME, + LOW_FRAME_TIME, + MAX_CHUNK_SIZE, + MIN_CHUNK_SIZE, + TIMEOUT, +} from '~/streaming/constants'; + +const defaultConfig = { + balanceRate: BALANCE_RATE, + minChunkSize: MIN_CHUNK_SIZE, + maxChunkSize: MAX_CHUNK_SIZE, + lowFrameTime: LOW_FRAME_TIME, + highFrameTime: HIGH_FRAME_TIME, + timeout: TIMEOUT, +}; + +function concatUint8Arrays(a, b) { + const array = new Uint8Array(a.length + b.length); + array.set(a, 0); + array.set(b, a.length); + return array; +} + +// This class is used to write chunks with a balanced size +// to avoid blocking main thread for too long. +// +// A chunk can be: +// 1. Too small +// 2. Too large +// 3. Delayed in time +// +// This class resolves all these problems by +// 1. Splitting or concatenating chunks to met the size criteria +// 2. Rendering current chunk buffer immediately if enough time has passed +// +// The size of the chunk is determined by RenderBalancer, +// It measures execution time for each chunk write and adjusts next chunk size. +export class ChunkWriter { + buffer = null; + decoder = new TextDecoder('utf-8'); + timeout = null; + + constructor(htmlStream, config) { + this.htmlStream = htmlStream; + + const { balanceRate, minChunkSize, maxChunkSize, lowFrameTime, highFrameTime, timeout } = { + ...defaultConfig, + ...config, + }; + + // ensure we still render chunks over time if the size criteria is not met + this.scheduleAccumulatorFlush = throttle(this.flushAccumulator.bind(this), timeout); + + const averageSize = Math.round((maxChunkSize + minChunkSize) / 2); + this.size = Math.max(averageSize, minChunkSize); + + this.balancer = new RenderBalancer({ + lowFrameTime, + highFrameTime, + decrease: () => { + this.size = Math.round(Math.max(this.size / balanceRate, minChunkSize)); + }, + increase: () => { + this.size = Math.round(Math.min(this.size * balanceRate, maxChunkSize)); + }, + }); + } + + write(chunk) { + this.scheduleAccumulatorFlush.cancel(); + + if (this.buffer) { + this.buffer = concatUint8Arrays(this.buffer, chunk); + } else { + this.buffer = chunk; + } + + // accumulate chunks until the size is fulfilled + if (this.size > this.buffer.length) { + this.scheduleAccumulatorFlush(); + return Promise.resolve(); + } + + return this.balancedWrite(); + } + + balancedWrite() { + let cursor = 0; + + return this.balancer.render(() => { + const chunkPart = this.buffer.subarray(cursor, cursor + this.size); + // accumulate chunks until the size is fulfilled + // this is a hot path for the last chunkPart of the chunk + if (chunkPart.length < this.size) { + this.buffer = chunkPart; + this.scheduleAccumulatorFlush(); + return false; + } + + this.writeToDom(chunkPart); + + cursor += this.size; + if (cursor >= this.buffer.length) { + this.buffer = null; + return false; + } + // continue render + return true; + }); + } + + writeToDom(chunk, stream = true) { + // stream: true allows us to split chunks with multi-part words + const decoded = this.decoder.decode(chunk, { stream }); + this.htmlStream.write(decoded); + } + + flushAccumulator() { + if (this.buffer) { + this.writeToDom(this.buffer); + this.buffer = null; + } + } + + close() { + this.scheduleAccumulatorFlush.cancel(); + if (this.buffer) { + // last chunk should have stream: false to indicate the end of the stream + this.writeToDom(this.buffer, false); + this.buffer = null; + } + this.htmlStream.close(); + } + + abort() { + this.scheduleAccumulatorFlush.cancel(); + this.buffer = null; + this.htmlStream.abort(); + } +} diff --git a/app/assets/javascripts/streaming/constants.js b/app/assets/javascripts/streaming/constants.js new file mode 100644 index 00000000000..224d93a7ac1 --- /dev/null +++ b/app/assets/javascripts/streaming/constants.js @@ -0,0 +1,9 @@ +// Lower min chunk numbers can make the page loading take incredibly long +export const MIN_CHUNK_SIZE = 128 * 1024; +export const MAX_CHUNK_SIZE = 2048 * 1024; +export const LOW_FRAME_TIME = 32; +// Tasks that take more than 50ms are considered Long +// https://web.dev/optimize-long-tasks/ +export const HIGH_FRAME_TIME = 64; +export const BALANCE_RATE = 1.2; +export const TIMEOUT = 500; diff --git a/app/assets/javascripts/streaming/handle_streamed_anchor_link.js b/app/assets/javascripts/streaming/handle_streamed_anchor_link.js new file mode 100644 index 00000000000..315dc9bb0a0 --- /dev/null +++ b/app/assets/javascripts/streaming/handle_streamed_anchor_link.js @@ -0,0 +1,26 @@ +import { throttle } from 'lodash'; +import { scrollToElement } from '~/lib/utils/common_utils'; +import LineHighlighter from '~/blob/line_highlighter'; + +const noop = () => {}; + +export function handleStreamedAnchorLink(rootElement) { + // "#L100-200" → ['L100', 'L200'] + const [anchorStart, end] = window.location.hash.substring(1).split('-'); + const anchorEnd = end ? `L${end}` : anchorStart; + if (!anchorStart || document.getElementById(anchorEnd)) return noop; + + const handler = throttle((mutationList, instance) => { + if (!document.getElementById(anchorEnd)) return; + scrollToElement(document.getElementById(anchorStart)); + // eslint-disable-next-line no-new + new LineHighlighter(); + instance.disconnect(); + }, 300); + + const observer = new MutationObserver(handler); + + observer.observe(rootElement, { childList: true, subtree: true }); + + return () => observer.disconnect(); +} diff --git a/app/assets/javascripts/streaming/html_stream.js b/app/assets/javascripts/streaming/html_stream.js new file mode 100644 index 00000000000..8182f69a607 --- /dev/null +++ b/app/assets/javascripts/streaming/html_stream.js @@ -0,0 +1,33 @@ +import { ChunkWriter } from '~/streaming/chunk_writer'; + +export class HtmlStream { + constructor(element) { + const streamDocument = document.implementation.createHTMLDocument('stream'); + + streamDocument.open(); + streamDocument.write('<streaming-element>'); + + const virtualStreamingElement = streamDocument.querySelector('streaming-element'); + element.appendChild(document.adoptNode(virtualStreamingElement)); + + this.streamDocument = streamDocument; + } + + withChunkWriter(config) { + return new ChunkWriter(this, config); + } + + write(chunk) { + // eslint-disable-next-line no-unsanitized/method + this.streamDocument.write(chunk); + } + + close() { + this.streamDocument.write('</streaming-element>'); + this.streamDocument.close(); + } + + abort() { + this.streamDocument.close(); + } +} diff --git a/app/assets/javascripts/streaming/polyfills.js b/app/assets/javascripts/streaming/polyfills.js new file mode 100644 index 00000000000..a9a044a3e99 --- /dev/null +++ b/app/assets/javascripts/streaming/polyfills.js @@ -0,0 +1,5 @@ +import { createReadableStreamWrapper } from '@mattiasbuelens/web-streams-adapter'; +import { ReadableStream as PolyfillReadableStream } from 'web-streams-polyfill'; + +// TODO: remove this when our WebStreams API reaches 100% support +export const toPolyfillReadable = createReadableStreamWrapper(PolyfillReadableStream); diff --git a/app/assets/javascripts/streaming/rate_limit_stream_requests.js b/app/assets/javascripts/streaming/rate_limit_stream_requests.js new file mode 100644 index 00000000000..04a592baa16 --- /dev/null +++ b/app/assets/javascripts/streaming/rate_limit_stream_requests.js @@ -0,0 +1,87 @@ +const consumeReadableStream = (stream) => { + return new Promise((resolve, reject) => { + stream.pipeTo( + new WritableStream({ + close: resolve, + abort: reject, + }), + ); + }); +}; + +const wait = (timeout) => + new Promise((resolve) => { + setTimeout(resolve, timeout); + }); + +// this rate-limiting approach is specific to Web Streams +// because streams only resolve when they're fully consumed +// so we need to split each stream into two pieces: +// one for the rate-limiter (wait for all the bytes to be sent) +// another for the original consumer +export const rateLimitStreamRequests = ({ + factory, + total, + maxConcurrentRequests, + immediateCount = maxConcurrentRequests, + timeout = 0, +}) => { + if (total === 0) return []; + + const unsettled = []; + + const pushUnsettled = (promise) => { + let res; + let rej; + const consume = new Promise((resolve, reject) => { + res = resolve; + rej = reject; + }); + unsettled.push(consume); + return promise.then((stream) => { + const [first, second] = stream.tee(); + // eslint-disable-next-line promise/no-nesting + consumeReadableStream(first) + .then(() => { + unsettled.splice(unsettled.indexOf(consume), 1); + res(); + }) + .catch(rej); + return second; + }, rej); + }; + + const immediate = Array.from({ length: Math.min(immediateCount, total) }, (_, i) => + pushUnsettled(factory(i)), + ); + + const queue = []; + const flushQueue = () => { + const promises = + unsettled.length > maxConcurrentRequests ? unsettled : [...unsettled, wait(timeout)]; + // errors are handled by the caller + // eslint-disable-next-line promise/catch-or-return + Promise.race(promises).then(() => { + const cb = queue.shift(); + cb?.(); + if (queue.length !== 0) { + // wait for stream consumer promise to be removed from unsettled + queueMicrotask(flushQueue); + } + }); + }; + + const throttled = Array.from({ length: total - immediateCount }, (_, i) => { + return new Promise((resolve, reject) => { + queue.push(() => { + pushUnsettled(factory(i + immediateCount)) + .then(resolve) + .catch(reject); + }); + }); + }); + + flushQueue(); + + return [...immediate, ...throttled]; +}; diff --git a/app/assets/javascripts/streaming/render_balancer.js b/app/assets/javascripts/streaming/render_balancer.js new file mode 100644 index 00000000000..66929ff3a54 --- /dev/null +++ b/app/assets/javascripts/streaming/render_balancer.js @@ -0,0 +1,36 @@ +export class RenderBalancer { + previousTimestamp = undefined; + + constructor({ increase, decrease, highFrameTime, lowFrameTime }) { + this.increase = increase; + this.decrease = decrease; + this.highFrameTime = highFrameTime; + this.lowFrameTime = lowFrameTime; + } + + render(fn) { + return new Promise((resolve) => { + const callback = (timestamp) => { + this.throttle(timestamp); + if (fn()) requestAnimationFrame(callback); + else resolve(); + }; + requestAnimationFrame(callback); + }); + } + + throttle(timestamp) { + const { previousTimestamp } = this; + this.previousTimestamp = timestamp; + if (previousTimestamp === undefined) return; + + const duration = Math.round(timestamp - previousTimestamp); + if (!duration) return; + + if (duration >= this.highFrameTime) { + this.decrease(); + } else if (duration < this.lowFrameTime) { + this.increase(); + } + } +} diff --git a/app/assets/javascripts/streaming/render_html_streams.js b/app/assets/javascripts/streaming/render_html_streams.js new file mode 100644 index 00000000000..7201e541777 --- /dev/null +++ b/app/assets/javascripts/streaming/render_html_streams.js @@ -0,0 +1,40 @@ +import { HtmlStream } from '~/streaming/html_stream'; + +async function pipeStreams(domWriter, streamPromises) { + try { + for await (const stream of streamPromises.slice(0, -1)) { + await stream.pipeTo(domWriter, { preventClose: true }); + } + const stream = await streamPromises[streamPromises.length - 1]; + await stream.pipeTo(domWriter); + } catch (error) { + domWriter.abort(error); + } +} + +// this function (and the rest of the pipeline) expects polyfilled streams +// do not pass native streams here unless our browser support allows for it +// TODO: remove this notice when our WebStreams API support reaches 100% +export function renderHtmlStreams(streamPromises, element, config) { + if (streamPromises.length === 0) return Promise.resolve(); + + const chunkedHtmlStream = new HtmlStream(element).withChunkWriter(config); + + return new Promise((resolve, reject) => { + const domWriter = new WritableStream({ + write(chunk) { + return chunkedHtmlStream.write(chunk); + }, + close() { + chunkedHtmlStream.close(); + resolve(); + }, + abort(error) { + chunkedHtmlStream.abort(); + reject(error); + }, + }); + + pipeStreams(domWriter, streamPromises); + }); +} diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 9ea5a66b3bc..b292adf9eac 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -580,3 +580,31 @@ span.idiff { padding: 0; border-radius: 0 0 $border-radius-default $border-radius-default; } + +.blame-stream-container { + border-top: 1px solid $border-color; +} + +.blame-stream-loading { + $gradient-size: 16px; + position: sticky; + bottom: 0; + display: flex; + justify-content: center; + align-items: center; + margin-top: -$gradient-size; + height: $gl-spacing-scale-10; + border-top: $gradient-size solid transparent; + background-color: $white; + box-sizing: content-box; + background-clip: content-box; + + .gradient { + position: absolute; + left: 0; + right: 0; + top: -$gradient-size; + height: $gradient-size; + background: linear-gradient(to top, $white, transparentize($white, 1)); + } +} diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index 91fc1bf489d..53d302f60ee 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -6,7 +6,7 @@ class Admin::ApplicationsController < Admin::ApplicationController before_action :set_application, only: [:show, :edit, :update, :renew, :destroy] before_action :load_scopes, only: [:new, :create, :edit, :update] - feature_category :authentication_and_authorization + feature_category :system_access def index applications = ApplicationsFinder.new.execute diff --git a/app/controllers/admin/identities_controller.rb b/app/controllers/admin/identities_controller.rb index dcec50e882d..0745ba328c6 100644 --- a/app/controllers/admin/identities_controller.rb +++ b/app/controllers/admin/identities_controller.rb @@ -4,7 +4,7 @@ class Admin::IdentitiesController < Admin::ApplicationController before_action :user before_action :identity, except: [:index, :new, :create] - feature_category :authentication_and_authorization + feature_category :system_access def new @identity = Identity.new diff --git a/app/controllers/admin/impersonation_tokens_controller.rb b/app/controllers/admin/impersonation_tokens_controller.rb index ddc555add5c..dae3337d19b 100644 --- a/app/controllers/admin/impersonation_tokens_controller.rb +++ b/app/controllers/admin/impersonation_tokens_controller.rb @@ -4,7 +4,7 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController before_action :user before_action :verify_impersonation_enabled! - feature_category :authentication_and_authorization + feature_category :user_management def index set_index_vars diff --git a/app/controllers/admin/impersonations_controller.rb b/app/controllers/admin/impersonations_controller.rb index 6c45b03455e..c1a6cb350ec 100644 --- a/app/controllers/admin/impersonations_controller.rb +++ b/app/controllers/admin/impersonations_controller.rb @@ -4,7 +4,7 @@ class Admin::ImpersonationsController < Admin::ApplicationController skip_before_action :authenticate_admin! before_action :authenticate_impersonator! - feature_category :authentication_and_authorization + feature_category :user_management def destroy original_user = stop_impersonation diff --git a/app/controllers/admin/keys_controller.rb b/app/controllers/admin/keys_controller.rb index 03383604e30..e4a756ec12d 100644 --- a/app/controllers/admin/keys_controller.rb +++ b/app/controllers/admin/keys_controller.rb @@ -3,7 +3,7 @@ class Admin::KeysController < Admin::ApplicationController before_action :user, only: [:show, :destroy] - feature_category :authentication_and_authorization + feature_category :user_management def show @key = user.keys.find(params[:id]) diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb index 63579421573..bb275532170 100644 --- a/app/controllers/admin/sessions_controller.rb +++ b/app/controllers/admin/sessions_controller.rb @@ -7,7 +7,7 @@ class Admin::SessionsController < ApplicationController before_action :user_is_admin! - feature_category :authentication_and_authorization + feature_category :system_access def new if current_user_mode.admin_mode? diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb index a0ba5b9c8a4..4d1cbd8becc 100644 --- a/app/controllers/confirmations_controller.rb +++ b/app/controllers/confirmations_controller.rb @@ -10,7 +10,7 @@ class ConfirmationsController < Devise::ConfirmationsController prepend_before_action :check_recaptcha, only: :create before_action :load_recaptcha, only: :new - feature_category :authentication_and_authorization + feature_category :user_management def almost_there flash[:notice] = nil diff --git a/app/controllers/groups/settings/access_tokens_controller.rb b/app/controllers/groups/settings/access_tokens_controller.rb index d86ddcfe2d0..ff07e881bfa 100644 --- a/app/controllers/groups/settings/access_tokens_controller.rb +++ b/app/controllers/groups/settings/access_tokens_controller.rb @@ -7,7 +7,7 @@ module Groups include AccessTokensActions layout 'group_settings' - feature_category :authentication_and_authorization + feature_category :system_access alias_method :resource, :group diff --git a/app/controllers/groups/settings/applications_controller.rb b/app/controllers/groups/settings/applications_controller.rb index 6fb2b65feb8..b174ba9a6ad 100644 --- a/app/controllers/groups/settings/applications_controller.rb +++ b/app/controllers/groups/settings/applications_controller.rb @@ -9,7 +9,7 @@ module Groups before_action :set_application, only: [:show, :edit, :update, :renew, :destroy] before_action :load_scopes, only: [:index, :create, :edit, :update] - feature_category :authentication_and_authorization + feature_category :system_access def index set_index_vars diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index 0bee1faccf5..2729b11fcff 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -245,11 +245,7 @@ class Import::GithubController < Import::BaseController { before: params[:before].presence, after: params[:after].presence, - first: PAGE_LENGTH, - # TODO: remove after rollout FF github_client_fetch_repos_via_graphql - # https://gitlab.com/gitlab-org/gitlab/-/issues/385649 - page: [1, params[:page].to_i].max, - per_page: PAGE_LENGTH + first: PAGE_LENGTH } end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 2a7f2d42e2a..0a2c98af8ec 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -13,7 +13,7 @@ class InvitesController < ApplicationController respond_to :html - feature_category :authentication_and_authorization + feature_category :system_access def show accept if skip_invitation_prompt? diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index 7211eebdb4b..d299613f498 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -8,7 +8,7 @@ class JwtController < ApplicationController # Add this before other actions, since we want to have the user or project prepend_before_action :auth_user, :authenticate_project_or_user - feature_category :authentication_and_authorization + feature_category :system_access # https://gitlab.com/gitlab-org/gitlab/-/issues/357037 urgency :low diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 4046433f8ea..e450151fd82 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -12,7 +12,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController protect_from_forgery except: [:cas3, :failure] + AuthHelper.saml_providers, with: :exception, prepend: true - feature_category :authentication_and_authorization + feature_category :system_access def handle_omniauth omniauth_flow(Gitlab::Auth::OAuth) diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb index 38cdb16c350..38839497fb6 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -12,7 +12,7 @@ class PasswordsController < Devise::PasswordsController before_action :check_password_authentication_available, only: [:create] before_action :throttle_reset, only: [:create] - feature_category :authentication_and_authorization + feature_category :system_access # rubocop: disable CodeReuse/ActiveRecord def edit diff --git a/app/controllers/profiles/accounts_controller.rb b/app/controllers/profiles/accounts_controller.rb index cb8b2783000..eb64016379d 100644 --- a/app/controllers/profiles/accounts_controller.rb +++ b/app/controllers/profiles/accounts_controller.rb @@ -3,7 +3,7 @@ class Profiles::AccountsController < Profiles::ApplicationController include AuthHelper - feature_category :authentication_and_authorization + feature_category :system_access urgency :low, [:show] def show diff --git a/app/controllers/profiles/active_sessions_controller.rb b/app/controllers/profiles/active_sessions_controller.rb index 2607ba7d404..5a86179b89f 100644 --- a/app/controllers/profiles/active_sessions_controller.rb +++ b/app/controllers/profiles/active_sessions_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Profiles::ActiveSessionsController < Profiles::ApplicationController - feature_category :authentication_and_authorization + feature_category :system_access def index @sessions = ActiveSession.list(current_user).reject(&:is_impersonated) diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb index 738c41207d5..7a0dfbbba0d 100644 --- a/app/controllers/profiles/passwords_controller.rb +++ b/app/controllers/profiles/passwords_controller.rb @@ -11,7 +11,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController layout :determine_layout - feature_category :authentication_and_authorization + feature_category :system_access def new end diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 1663aa61f62..8d5c690fbfe 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -3,7 +3,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController include RenderAccessTokens - feature_category :authentication_and_authorization + feature_category :system_access before_action :check_personal_access_tokens_enabled diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb index aded295bfab..89151068696 100644 --- a/app/controllers/profiles/two_factor_auths_controller.rb +++ b/app/controllers/profiles/two_factor_auths_controller.rb @@ -12,7 +12,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController push_frontend_feature_flag(:webauthn) end - feature_category :authentication_and_authorization + feature_category :system_access def show setup_show_page diff --git a/app/controllers/profiles/u2f_registrations_controller.rb b/app/controllers/profiles/u2f_registrations_controller.rb index 32ca303e722..2ee0e9fe960 100644 --- a/app/controllers/profiles/u2f_registrations_controller.rb +++ b/app/controllers/profiles/u2f_registrations_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Profiles::U2fRegistrationsController < Profiles::ApplicationController - feature_category :authentication_and_authorization + feature_category :system_access def destroy u2f_registration = current_user.u2f_registrations.find(params[:id]) diff --git a/app/controllers/profiles/webauthn_registrations_controller.rb b/app/controllers/profiles/webauthn_registrations_controller.rb index a4a6d84f1ae..345d7bdbca8 100644 --- a/app/controllers/profiles/webauthn_registrations_controller.rb +++ b/app/controllers/profiles/webauthn_registrations_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Profiles::WebauthnRegistrationsController < Profiles::ApplicationController - feature_category :authentication_and_authorization + feature_category :system_access def destroy webauthn_registration = current_user.webauthn_registrations.find(params[:id]) diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 45b274fc920..70487915707 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -17,7 +17,7 @@ class ProfilesController < Profiles::ApplicationController feature_category :user_profile, [:show, :update, :reset_incoming_email_token, :reset_feed_token, :reset_static_object_token, :update_username] - feature_category :authentication_and_authorization, [:audit_log] + feature_category :system_access, [:audit_log] urgency :low, [:show, :update] def show diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb index cfff281604e..d41b347dc5a 100644 --- a/app/controllers/projects/blame_controller.rb +++ b/app/controllers/projects/blame_controller.rb @@ -23,13 +23,47 @@ class Projects::BlameController < Projects::ApplicationController environment_params[:find_latest] = true @environment = ::Environments::EnvironmentsByDeploymentsFinder.new(@project, current_user, environment_params).execute.last - blame_service = Projects::BlameService.new(@blob, @commit, params.permit(:page, :no_pagination)) + permitted_params = params.permit(:page, :no_pagination, :streaming) + blame_service = Projects::BlameService.new(@blob, @commit, permitted_params) @blame = Gitlab::View::Presenter::Factory.new(blame_service.blame, project: @project, path: @path, page: blame_service.page).fabricate! - @blame_pagination = blame_service.pagination + @entire_blame_path = full_blame_path(no_pagination: true) + @blame_pages_url = blame_pages_url(permitted_params) + if blame_service.streaming_possible + @entire_blame_path = full_blame_path(streaming: true) + end + + @streaming_enabled = blame_service.streaming_enabled + @blame_pagination = blame_service.pagination unless @streaming_enabled @blame_per_page = blame_service.per_page + + render locals: { total_extra_pages: blame_service.total_extra_pages } + end + + def page + @blob = @repository.blob_at(@commit.id, @path) + + environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } + environment_params[:find_latest] = true + @environment = ::Environments::EnvironmentsByDeploymentsFinder.new(@project, current_user, environment_params).execute.last + + blame_service = Projects::BlameService.new(@blob, @commit, params.permit(:page, :streaming)) + + @blame = Gitlab::View::Presenter::Factory.new(blame_service.blame, project: @project, path: @path, page: blame_service.page).fabricate! + + render partial: 'page' + end + + private + + def full_blame_path(params) + namespace_project_blame_path(namespace_id: @project.namespace, project_id: @project, id: @id, **params) + end + + def blame_pages_url(params) + namespace_project_blame_page_url(namespace_id: @project.namespace, project_id: @project, id: @id, **params) end end diff --git a/app/controllers/projects/settings/access_tokens_controller.rb b/app/controllers/projects/settings/access_tokens_controller.rb index 0884816ef62..af1527ba6a3 100644 --- a/app/controllers/projects/settings/access_tokens_controller.rb +++ b/app/controllers/projects/settings/access_tokens_controller.rb @@ -7,7 +7,7 @@ module Projects include AccessTokensActions layout 'project_settings' - feature_category :authentication_and_authorization + feature_category :system_access alias_method :resource, :project diff --git a/app/controllers/registrations/welcome_controller.rb b/app/controllers/registrations/welcome_controller.rb index cfb4e939b35..78c00a81a93 100644 --- a/app/controllers/registrations/welcome_controller.rb +++ b/app/controllers/registrations/welcome_controller.rb @@ -10,7 +10,7 @@ module Registrations skip_before_action :authenticate_user!, :required_signup_info, :check_two_factor_requirement, only: [:show, :update] before_action :require_current_user - feature_category :authentication_and_authorization + feature_category :user_management def show return redirect_to path_for_signed_in_user(current_user) if completed_welcome_step? diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 420ca6a2286..edc74dd71fc 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -27,7 +27,7 @@ class RegistrationsController < Devise::RegistrationsController push_frontend_feature_flag(:gitlab_gtm_datalayer, type: :ops) end - feature_category :authentication_and_authorization + feature_category :user_management def new @resource = build_resource diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index b6aba04c877..83034e3faa6 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -56,7 +56,7 @@ class SessionsController < Devise::SessionsController # token mismatch. protect_from_forgery with: :exception, prepend: true, except: :destroy - feature_category :authentication_and_authorization + feature_category :system_access urgency :low CAPTCHA_HEADER = 'X-GitLab-Show-Login-Captcha' diff --git a/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb new file mode 100644 index 00000000000..be17601e7a2 --- /dev/null +++ b/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Resolvers + module Analytics + module CycleAnalytics + class DeploymentCountResolver < BaseResolver + type Types::Analytics::CycleAnalytics::MetricType, null: true + + argument :from, Types::TimeType, + required: true, + description: 'Deployments finished after the date.' + + argument :to, Types::TimeType, + required: true, + description: 'Deployments finished before the date.' + + def resolve(**args) + value = count(args) + { + value: value, + title: n_('Deploy', 'Deploys', value.to_i), + identifier: 'deploys', + links: [] + } + end + + private + + def count(args) + finder = DeploymentsFinder.new({ + finished_after: args[:from], + finished_before: args[:to], + project: object.project, + status: :success, + order_by: :finished_at + }) + + finder.execute.count + end + + # :project level: no customization, returning the original resolver + # :group level: add the project_ids argument + def self.[](context = :project) + case context + when :project + self + when :group + Class.new(self) do + argument :project_ids, [GraphQL::Types::ID], + required: false, + description: 'Project IDs within the group hierarchy.' + end + + end + end + end + end + end +end + +mod = Resolvers::Analytics::CycleAnalytics::DeploymentCountResolver +mod.prepend_mod_with('Resolvers::Analytics::CycleAnalytics::DeploymentCountResolver') diff --git a/app/graphql/types/analytics/cycle_analytics/flow_metrics.rb b/app/graphql/types/analytics/cycle_analytics/flow_metrics.rb index d320cd6cfc6..2645a86a9f8 100644 --- a/app/graphql/types/analytics/cycle_analytics/flow_metrics.rb +++ b/app/graphql/types/analytics/cycle_analytics/flow_metrics.rb @@ -14,6 +14,11 @@ module Types null: true, description: 'Number of issues opened in the given period.', resolver: Resolvers::Analytics::CycleAnalytics::IssueCountResolver[context] + field :deployment_count, + Types::Analytics::CycleAnalytics::MetricType, + null: true, + description: 'Number of production deployments in the given period.', + resolver: Resolvers::Analytics::CycleAnalytics::DeploymentCountResolver[context] end end end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index a11e2c00cf8..ba6e760fd2b 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -85,7 +85,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy condition(:crm_enabled, score: 0, scope: :subject) { @subject.crm_enabled? } condition(:create_runner_workflow_enabled) do - Feature.enabled?(:create_runner_workflow_for_admin) + Feature.enabled?(:create_runner_workflow_for_namespace, group) end condition(:achievements_enabled, scope: :subject) do diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index cab3dff0400..fbe0f9bfeea 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -235,7 +235,7 @@ class ProjectPolicy < BasePolicy end condition(:create_runner_workflow_enabled) do - Feature.enabled?(:create_runner_workflow_for_admin) + Feature.enabled?(:create_runner_workflow_for_namespace, project.namespace) end # `:read_project` may be prevented in EE, but `:read_project_for_iids` should diff --git a/app/services/projects/blame_service.rb b/app/services/projects/blame_service.rb index 58e146e5a32..1ea16040655 100644 --- a/app/services/projects/blame_service.rb +++ b/app/services/projects/blame_service.rb @@ -5,15 +5,19 @@ module Projects class BlameService PER_PAGE = 1000 + STREAMING_FIRST_PAGE_SIZE = 200 + STREAMING_PER_PAGE = 2000 def initialize(blob, commit, params) @blob = blob @commit = commit - @page = extract_page(params) + @streaming_enabled = streaming_state(params) @pagination_enabled = pagination_state(params) + @page = extract_page(params) + @params = params end - attr_reader :page + attr_reader :page, :streaming_enabled def blame Gitlab::Blame.new(blob, commit, range: blame_range) @@ -28,7 +32,22 @@ module Projects end def per_page - PER_PAGE + streaming_enabled ? STREAMING_PER_PAGE : PER_PAGE + end + + def total_pages + total = (blob_lines_count.to_f / per_page).ceil + return total unless streaming_enabled + + ([blob_lines_count - STREAMING_FIRST_PAGE_SIZE, 0].max.to_f / per_page).ceil + 1 + end + + def total_extra_pages + [total_pages - 1, 0].max + end + + def streaming_possible + Feature.enabled?(:blame_page_streaming, commit.project) end private @@ -36,9 +55,16 @@ module Projects attr_reader :blob, :commit, :pagination_enabled def blame_range - return unless pagination_enabled + return unless pagination_enabled || streaming_enabled first_line = (page - 1) * per_page + 1 + + if streaming_enabled + return 1..STREAMING_FIRST_PAGE_SIZE if page == 1 + + first_line = STREAMING_FIRST_PAGE_SIZE + (page - 2) * per_page + 1 + end + last_line = (first_line + per_page).to_i - 1 first_line..last_line @@ -52,6 +78,12 @@ module Projects page end + def streaming_state(params) + return false unless streaming_possible + + Gitlab::Utils.to_boolean(params[:streaming], default: false) + end + def pagination_state(params) return false if Gitlab::Utils.to_boolean(params[:no_pagination], default: false) @@ -59,7 +91,7 @@ module Projects end def overlimit?(page) - page * per_page >= blob_lines_count + per_page + page > total_pages end def blob_lines_count diff --git a/app/services/protected_branches/base_service.rb b/app/services/protected_branches/base_service.rb index 951017b2d01..6906ab2b642 100644 --- a/app/services/protected_branches/base_service.rb +++ b/app/services/protected_branches/base_service.rb @@ -21,3 +21,5 @@ module ProtectedBranches end end end + +ProtectedBranches::BaseService.prepend_mod diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index dd441d0d155..f0c1b090140 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -10,6 +10,7 @@ %meta{ 'http-equiv' => 'X-UA-Compatible', content: 'IE=edge' } = render 'layouts/startup_js' + = yield :startup_js - if page_canonical_link %link{ rel: 'canonical', href: page_canonical_link } diff --git a/app/views/layouts/component_preview.html.haml b/app/views/layouts/component_preview.html.haml index a1b1304f994..8217ac13c52 100644 --- a/app/views/layouts/component_preview.html.haml +++ b/app/views/layouts/component_preview.html.haml @@ -1,12 +1,12 @@ %head - - if params[:lookbook][:display][:theme] == 'light' + - if params[:lookbook][:display][:theme] == "light" = stylesheet_link_tag "application" = stylesheet_link_tag "application_utilities" - else = stylesheet_link_tag "application_dark" = stylesheet_link_tag "application_utilities_dark" %body - .container.gl-mt-6 + .gl-mt-6{ class: (params[:lookbook][:display][:layout] == "fluid" ? "container-fluid" : "container") } - if params[:lookbook][:display][:bg_dark] .bg-dark.rounded.shadow.p-4 = yield diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index 74b85a93c8e..ee7ca9cd351 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -1,6 +1,15 @@ - page_title _("Blame"), @blob.path, @ref - add_page_specific_style 'page_bundles/tree' -- dataset = { testid: 'blob-content-holder', qa_selector: 'blame_file_content', per_page: @blame_per_page } +- if @streaming_enabled && total_extra_pages > 0 + - content_for :startup_js do + = javascript_tag do + :plain + window.blamePageStream = (() => { + const url = new URL("#{@blame_pages_url}"); + url.searchParams.set('page', 2); + return fetch(url).then(response => response.body); + })(); +- dataset = { testid: 'blob-content-holder', qa_selector: 'blame_file_content', per_page: @blame_per_page, total_extra_pages: total_extra_pages - 1, pages_url: @blame_pages_url } #blob-content-holder.tree-holder.js-per-page{ data: dataset } = render "projects/blob/breadcrumb", blob: @blob, blame: true @@ -26,11 +35,21 @@ .blame-table-wrapper = render partial: 'page' + - if @streaming_enabled + #blame-stream-container.blame-stream-container + - if @blame_pagination && @blame_pagination.total_pages > 1 .gl-display-flex.gl-justify-content-center.gl-flex-direction-column.gl-align-items-center.gl-p-3.gl-bg-gray-50.gl-border-t-solid.gl-border-t-1.gl-border-gray-100 - = _('For faster browsing, not all history is shown.') - = render Pajamas::ButtonComponent.new(href: namespace_project_blame_path(namespace_id: @project.namespace, project_id: @project, id: @id, no_pagination: true), size: :small, button_options: { class: 'gl-mt-3' }) do |c| - = _('View entire blame') + = render Pajamas::ButtonComponent.new(href: @entire_blame_path, size: :small, button_options: { class: 'gl-mt-3' }) do |c| + = _('Show full blame') + + - if @streaming_enabled + #blame-stream-loading.blame-stream-loading + .gradient + = gl_loading_icon(size: 'sm') + %span.gl-mx-2 + = _('Loading full blame...') - if @blame_pagination = paginate(@blame_pagination, theme: "gitlab") + diff --git a/app/views/users/_profile_basic_info.html.haml b/app/views/users/_profile_basic_info.html.haml index b62440fcbde..c916b6c3d45 100644 --- a/app/views/users/_profile_basic_info.html.haml +++ b/app/views/users/_profile_basic_info.html.haml @@ -1,9 +1,9 @@ -.gl-text-gray-900.gl-mt-4 - = render 'middle_dot_divider' do +.gl-text-gray-900 + = render 'middle_dot_divider', stacking: true do @#{@user.username} - if can?(current_user, :read_user_profile, @user) - = render 'middle_dot_divider' do + = render 'middle_dot_divider', stacking: true do = s_('UserProfile|User ID: %{id}') % { id: @user.id } = clipboard_button(title: s_('UserProfile|Copy user ID'), text: @user.id) - = render 'middle_dot_divider' do + = render 'middle_dot_divider', stacking: true do = s_('Member since %{date}') % { date: @user.created_at.to_date.to_s(:long) } diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index dd84b303655..29fd894cebf 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -43,80 +43,82 @@ = _('Follow') .profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] } - .avatar-holder - = link_to avatar_icon_for_user(@user, 400, current_user: current_user), target: '_blank', rel: 'noopener noreferrer' do - = render Pajamas::AvatarComponent.new(@user, alt: "", size: 96, avatar_options: { itemprop: "image" }) - - - if @user.blocked? || !@user.confirmed? - .user-info - %h1.cover-title - = user_display_name(@user) - = render "users/profile_basic_info" - - else - .user-info - %h1.cover-title{ itemprop: 'name' } - = @user.name - - if @user.pronouns.present? - %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle - = "(#{@user.pronouns})" - - if @user.status&.busy? - %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle= s_("UserProfile|(Busy)") - - - if @user.pronunciation.present? - .gl-align-items-center - %p.gl-mb-4.gl-text-gray-500.gl-max-w-80.gl-mx-auto= s_("UserProfile|Pronounced as: %{pronunciation}") % { pronunciation: @user.pronunciation } - - - if @user.status&.customized? - .cover-status.gl-display-inline-flex.gl-align-items-center - = emoji_icon(@user.status.emoji, class: 'gl-mr-2') - = markdown_field(@user.status, :message) + .gl-display-inline-block.gl-mx-8.gl-vertical-align-top + .avatar-holder + = link_to avatar_icon_for_user(@user, 400, current_user: current_user), target: '_blank', rel: 'noopener noreferrer' do + = render Pajamas::AvatarComponent.new(@user, alt: "", size: 96, avatar_options: { itemprop: "image" }) + .gl-display-inline-block.gl-vertical-align-top.gl-text-left + - if @user.blocked? || !@user.confirmed? + .user-info + %h1.cover-title.gl-my-0 + = user_display_name(@user) = render "users/profile_basic_info" - .gl-text-gray-900.mb-1.mb-sm-2 - - unless @user.location.blank? - = render 'middle_dot_divider', stacking: true, itemprop: 'address', itemscope: true, itemtype: 'https://schema.org/PostalAddress' do - = sprite_icon('location', css_class: 'fgray') - %span{ itemprop: 'addressLocality' } - = @user.location + - else + .user-info + %h1.cover-title.gl-my-0{ itemprop: 'name' } + = @user.name + - if @user.pronouns.present? + %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle + = "(#{@user.pronouns})" + - if @user.status&.busy? + %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle= s_("UserProfile|(Busy)") + + - if @user.pronunciation.present? + .gl-align-items-center + %p.gl-mb-4.gl-text-gray-500.gl-max-w-80.gl-mx-auto= s_("UserProfile|Pronounced as: %{pronunciation}") % { pronunciation: @user.pronunciation } + + - if @user.status&.customized? + .cover-status.gl-display-inline-flex.gl-align-items-center.gl-mb-3 + = emoji_icon(@user.status.emoji, class: 'gl-mr-2') + = markdown_field(@user.status, :message) + = render "users/profile_basic_info" - user_local_time = local_time(@user.timezone) - - unless user_local_time.nil? - = render 'middle_dot_divider', stacking: true, data: { testid: 'user-local-time' } do - = sprite_icon('clock', css_class: 'fgray') - %span - = user_local_time - - unless work_information(@user).blank? - = render 'middle_dot_divider', stacking: true do - = sprite_icon('work', css_class: 'fgray') - %span - = work_information(@user, with_schema_markup: true) - .gl-text-gray-900 - - unless @user.skype.blank? - = render 'middle_dot_divider' do - = link_to "skype:#{@user.skype}", class: 'gl-hover-text-decoration-none', title: "Skype" do - = sprite_icon('skype', css_class: 'skype-icon') - - unless @user.linkedin.blank? - = render 'middle_dot_divider' do - = link_to linkedin_url(@user), class: 'gl-hover-text-decoration-none', title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do - = sprite_icon('linkedin', css_class: 'linkedin-icon') - - unless @user.twitter.blank? - = render 'middle_dot_divider', breakpoint: 'sm' do - = link_to twitter_url(@user), class: 'gl-hover-text-decoration-none', title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do - = sprite_icon('twitter', css_class: 'twitter-icon') - - unless @user.discord.blank? - = render 'middle_dot_divider', breakpoint: 'sm' do - = link_to discord_url(@user), class: 'gl-hover-text-decoration-none', title: "Discord", target: '_blank', rel: 'noopener noreferrer nofollow' do - = sprite_icon('discord', css_class: 'discord-icon') - - unless @user.website_url.blank? - = render 'middle_dot_divider', stacking: true do - - if Feature.enabled?(:security_auto_fix) && @user.bot? - = sprite_icon('question', css_class: 'gl-text-blue-600') - = link_to @user.short_website_url, @user.full_website_url, target: '_blank', rel: 'me noopener noreferrer nofollow', itemprop: 'url' - - if display_public_email?(@user) - = render 'middle_dot_divider', stacking: true do - = link_to @user.public_email, "mailto:#{@user.public_email}", itemprop: 'email' - - if @user.bio.present? - .gl-text-gray-900 - .profile-user-bio - = @user.bio + - if @user.location.present? || user_local_time.present? || work_information(@user).present? + .gl-text-gray-900 + - if @user.location.present? + = render 'middle_dot_divider', stacking: true, itemprop: 'address', itemscope: true, itemtype: 'https://schema.org/PostalAddress' do + = sprite_icon('location', css_class: 'fgray') + %span{ itemprop: 'addressLocality' } + = @user.location + - if user_local_time.present? + = render 'middle_dot_divider', stacking: true, data: { testid: 'user-local-time' } do + = sprite_icon('clock', css_class: 'fgray') + %span + = user_local_time + - if work_information(@user).present? + = render 'middle_dot_divider', stacking: true do + = sprite_icon('work', css_class: 'fgray') + %span + = work_information(@user, with_schema_markup: true) + .gl-text-gray-900 + - if @user.skype.present? + = render 'middle_dot_divider' do + = link_to "skype:#{@user.skype}", class: 'gl-hover-text-decoration-none', title: "Skype" do + = sprite_icon('skype', css_class: 'skype-icon') + - if @user.linkedin.present? + = render 'middle_dot_divider' do + = link_to linkedin_url(@user), class: 'gl-hover-text-decoration-none', title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do + = sprite_icon('linkedin', css_class: 'linkedin-icon') + - if @user.twitter.present? + = render 'middle_dot_divider', breakpoint: 'sm' do + = link_to twitter_url(@user), class: 'gl-hover-text-decoration-none', title: "Twitter", target: '_blank', rel: 'noopener noreferrer nofollow' do + = sprite_icon('twitter', css_class: 'twitter-icon') + - if @user.discord.present? + = render 'middle_dot_divider', breakpoint: 'sm' do + = link_to discord_url(@user), class: 'gl-hover-text-decoration-none', title: "Discord", target: '_blank', rel: 'noopener noreferrer nofollow' do + = sprite_icon('discord', css_class: 'discord-icon') + - if @user.website_url.present? + = render 'middle_dot_divider', stacking: true do + - if Feature.enabled?(:security_auto_fix) && @user.bot? + = sprite_icon('question', css_class: 'gl-text-blue-600') + = link_to @user.short_website_url, @user.full_website_url, target: '_blank', rel: 'me noopener noreferrer nofollow', itemprop: 'url' + - if display_public_email?(@user) + = render 'middle_dot_divider', stacking: true do + = link_to @user.public_email, "mailto:#{@user.public_email}", itemprop: 'email' + - if @user.bio.present? && @user.confirmed? && !@user.blocked? + .gl-text-gray-900.gl-mx-5 + .profile-user-bio.gl-text-left + = @user.bio - if !profile_tabs.empty? && !Feature.enabled?(:profile_tabs_vue, current_user) .scrolling-tabs-container diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index fbb348811e0..16930548124 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -5,7 +5,7 @@ --- - :name: authorized_project_update:authorized_project_update_project_recalculate :worker_name: AuthorizedProjectUpdate::ProjectRecalculateWorker - :feature_category: :authentication_and_authorization + :feature_category: :system_access :has_external_dependencies: false :urgency: :high :resource_boundary: :unknown @@ -14,7 +14,7 @@ :tags: [] - :name: authorized_project_update:authorized_project_update_project_recalculate_per_user :worker_name: AuthorizedProjectUpdate::ProjectRecalculatePerUserWorker - :feature_category: :authentication_and_authorization + :feature_category: :system_access :has_external_dependencies: false :urgency: :high :resource_boundary: :unknown @@ -23,7 +23,7 @@ :tags: [] - :name: authorized_project_update:authorized_project_update_user_refresh_from_replica :worker_name: AuthorizedProjectUpdate::UserRefreshFromReplicaWorker - :feature_category: :authentication_and_authorization + :feature_category: :system_access :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -32,7 +32,7 @@ :tags: [] - :name: authorized_project_update:authorized_project_update_user_refresh_over_user_range :worker_name: AuthorizedProjectUpdate::UserRefreshOverUserRangeWorker - :feature_category: :authentication_and_authorization + :feature_category: :system_access :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -41,7 +41,7 @@ :tags: [] - :name: authorized_project_update:authorized_project_update_user_refresh_with_low_urgency :worker_name: AuthorizedProjectUpdate::UserRefreshWithLowUrgencyWorker - :feature_category: :authentication_and_authorization + :feature_category: :system_access :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -617,7 +617,7 @@ :tags: [] - :name: cronjob:personal_access_tokens_expired_notification :worker_name: PersonalAccessTokens::ExpiredNotificationWorker - :feature_category: :authentication_and_authorization + :feature_category: :system_access :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -626,7 +626,7 @@ :tags: [] - :name: cronjob:personal_access_tokens_expiring :worker_name: PersonalAccessTokens::ExpiringWorker - :feature_category: :authentication_and_authorization + :feature_category: :system_access :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -680,7 +680,7 @@ :tags: [] - :name: cronjob:remove_expired_group_links :worker_name: RemoveExpiredGroupLinksWorker - :feature_category: :authentication_and_authorization + :feature_category: :system_access :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -689,7 +689,7 @@ :tags: [] - :name: cronjob:remove_expired_members :worker_name: RemoveExpiredMembersWorker - :feature_category: :authentication_and_authorization + :feature_category: :system_access :has_external_dependencies: false :urgency: :low :resource_boundary: :cpu @@ -698,7 +698,7 @@ :tags: [] - :name: cronjob:remove_unaccepted_member_invites :worker_name: RemoveUnacceptedMemberInvitesWorker - :feature_category: :authentication_and_authorization + :feature_category: :system_access :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -2165,7 +2165,7 @@ :tags: [] - :name: unassign_issuables:members_destroyer_unassign_issuables :worker_name: MembersDestroyer::UnassignIssuablesWorker - :feature_category: :authentication_and_authorization + :feature_category: :user_management :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -2219,7 +2219,7 @@ :tags: [] - :name: authorized_projects :worker_name: AuthorizedProjectsWorker - :feature_category: :authentication_and_authorization + :feature_category: :system_access :has_external_dependencies: false :urgency: :high :resource_boundary: :unknown @@ -2417,7 +2417,7 @@ :tags: [] - :name: delete_user :worker_name: DeleteUserWorker - :feature_category: :authentication_and_authorization + :feature_category: :user_management :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown @@ -2642,7 +2642,7 @@ :tags: [] - :name: groups_update_two_factor_requirement_for_members :worker_name: Groups::UpdateTwoFactorRequirementForMembersWorker - :feature_category: :authentication_and_authorization + :feature_category: :system_access :has_external_dependencies: false :urgency: :low :resource_boundary: :unknown diff --git a/app/workers/authorized_project_update/project_recalculate_per_user_worker.rb b/app/workers/authorized_project_update/project_recalculate_per_user_worker.rb index 352c82e5021..96647cc671c 100644 --- a/app/workers/authorized_project_update/project_recalculate_per_user_worker.rb +++ b/app/workers/authorized_project_update/project_recalculate_per_user_worker.rb @@ -4,7 +4,7 @@ module AuthorizedProjectUpdate class ProjectRecalculatePerUserWorker < ProjectRecalculateWorker data_consistency :always - feature_category :authentication_and_authorization + feature_category :system_access urgency :high queue_namespace :authorized_project_update diff --git a/app/workers/authorized_project_update/project_recalculate_worker.rb b/app/workers/authorized_project_update/project_recalculate_worker.rb index 1b5faee0b6f..cbf068f0b85 100644 --- a/app/workers/authorized_project_update/project_recalculate_worker.rb +++ b/app/workers/authorized_project_update/project_recalculate_worker.rb @@ -9,7 +9,7 @@ module AuthorizedProjectUpdate prepend WaitableWorker - feature_category :authentication_and_authorization + feature_category :system_access urgency :high queue_namespace :authorized_project_update diff --git a/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb b/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb index daebb23baae..cdc0a097c92 100644 --- a/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb +++ b/app/workers/authorized_project_update/user_refresh_from_replica_worker.rb @@ -5,7 +5,7 @@ module AuthorizedProjectUpdate include ApplicationWorker sidekiq_options retry: 3 - feature_category :authentication_and_authorization + feature_category :system_access urgency :low data_consistency :always queue_namespace :authorized_project_update diff --git a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb index 8452f2a7821..ae243a94d3d 100644 --- a/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb +++ b/app/workers/authorized_project_update/user_refresh_over_user_range_worker.rb @@ -16,7 +16,7 @@ module AuthorizedProjectUpdate sidekiq_options retry: 3 - feature_category :authentication_and_authorization + feature_category :system_access urgency :low queue_namespace :authorized_project_update diff --git a/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb b/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb index 7ca59a72adf..d6b41ba949c 100644 --- a/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb +++ b/app/workers/authorized_project_update/user_refresh_with_low_urgency_worker.rb @@ -2,7 +2,7 @@ module AuthorizedProjectUpdate class UserRefreshWithLowUrgencyWorker < ::AuthorizedProjectsWorker - feature_category :authentication_and_authorization + feature_category :system_access urgency :low queue_namespace :authorized_project_update deduplicate :until_executing, including_scheduled: true diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb index 4312ba41367..b553a2cd14e 100644 --- a/app/workers/authorized_projects_worker.rb +++ b/app/workers/authorized_projects_worker.rb @@ -8,7 +8,7 @@ class AuthorizedProjectsWorker sidekiq_options retry: 3 prepend WaitableWorker - feature_category :authentication_and_authorization + feature_category :system_access urgency :high weight 2 idempotent! diff --git a/app/workers/delete_user_worker.rb b/app/workers/delete_user_worker.rb index 0af084caf86..bca156ff84c 100644 --- a/app/workers/delete_user_worker.rb +++ b/app/workers/delete_user_worker.rb @@ -7,7 +7,7 @@ class DeleteUserWorker # rubocop:disable Scalability/IdempotentWorker sidekiq_options retry: 3 - feature_category :authentication_and_authorization + feature_category :user_management loggable_arguments 2 def perform(current_user_id, delete_user_id, options = {}) diff --git a/app/workers/groups/update_two_factor_requirement_for_members_worker.rb b/app/workers/groups/update_two_factor_requirement_for_members_worker.rb index ac1d3589516..ca68a82ec66 100644 --- a/app/workers/groups/update_two_factor_requirement_for_members_worker.rb +++ b/app/workers/groups/update_two_factor_requirement_for_members_worker.rb @@ -9,7 +9,7 @@ module Groups idempotent! - feature_category :authentication_and_authorization + feature_category :system_access def perform(group_id) group = Group.find_by_id(group_id) diff --git a/app/workers/members_destroyer/unassign_issuables_worker.rb b/app/workers/members_destroyer/unassign_issuables_worker.rb index 915551d6e30..2e6ce0005fc 100644 --- a/app/workers/members_destroyer/unassign_issuables_worker.rb +++ b/app/workers/members_destroyer/unassign_issuables_worker.rb @@ -11,7 +11,7 @@ module MembersDestroyer ENTITY_TYPES = %w(Group Project).freeze queue_namespace :unassign_issuables - feature_category :authentication_and_authorization + feature_category :user_management idempotent! diff --git a/app/workers/personal_access_tokens/expired_notification_worker.rb b/app/workers/personal_access_tokens/expired_notification_worker.rb index b119957fa2c..f86bd604cdf 100644 --- a/app/workers/personal_access_tokens/expired_notification_worker.rb +++ b/app/workers/personal_access_tokens/expired_notification_worker.rb @@ -8,7 +8,7 @@ module PersonalAccessTokens include CronjobQueue - feature_category :authentication_and_authorization + feature_category :system_access MAX_TOKENS = 100 diff --git a/app/workers/personal_access_tokens/expiring_worker.rb b/app/workers/personal_access_tokens/expiring_worker.rb index f4afa9f8994..de0bda82573 100644 --- a/app/workers/personal_access_tokens/expiring_worker.rb +++ b/app/workers/personal_access_tokens/expiring_worker.rb @@ -8,7 +8,7 @@ module PersonalAccessTokens include CronjobQueue - feature_category :authentication_and_authorization + feature_category :system_access MAX_TOKENS = 100 diff --git a/app/workers/remove_expired_group_links_worker.rb b/app/workers/remove_expired_group_links_worker.rb index 37298c53a5c..f1da5f37945 100644 --- a/app/workers/remove_expired_group_links_worker.rb +++ b/app/workers/remove_expired_group_links_worker.rb @@ -7,7 +7,7 @@ class RemoveExpiredGroupLinksWorker # rubocop:disable Scalability/IdempotentWork include CronjobQueue # rubocop:disable Scalability/CronWorkerContext - feature_category :authentication_and_authorization + feature_category :system_access def perform ProjectGroupLink.expired.find_each do |link| diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb index c9eb715a522..b5031f4cda6 100644 --- a/app/workers/remove_expired_members_worker.rb +++ b/app/workers/remove_expired_members_worker.rb @@ -7,7 +7,7 @@ class RemoveExpiredMembersWorker # rubocop:disable Scalability/IdempotentWorker include CronjobQueue - feature_category :authentication_and_authorization + feature_category :system_access worker_resource_boundary :cpu # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/workers/remove_unaccepted_member_invites_worker.rb b/app/workers/remove_unaccepted_member_invites_worker.rb index 7fe45b26094..96f60b5fa12 100644 --- a/app/workers/remove_unaccepted_member_invites_worker.rb +++ b/app/workers/remove_unaccepted_member_invites_worker.rb @@ -7,7 +7,7 @@ class RemoveUnacceptedMemberInvitesWorker # rubocop:disable Scalability/Idempote include CronjobQueue # rubocop:disable Scalability/CronWorkerContext - feature_category :authentication_and_authorization + feature_category :system_access urgency :low idempotent! |