Commit b77fb046 authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 75ee59f7
......@@ -89,10 +89,9 @@ export default {
methods: {
...mapActions('environmentLogs', [
'setInitData',
'setSearch',
'showPodLogs',
'showEnvironment',
'fetchEnvironments',
'fetchLogs',
'fetchMoreLogsPrepend',
'dismissRequestEnvironmentsError',
'dismissInvalidTimeRangeWarning',
......@@ -191,13 +190,13 @@ export default {
<log-advanced-filters
v-if="showAdvancedFilters"
ref="log-advanced-filters"
class="d-md-flex flex-grow-1"
class="d-md-flex flex-grow-1 min-width-0"
:disabled="environments.isLoading"
/>
<log-simple-filters
v-else
ref="log-simple-filters"
class="d-md-flex flex-grow-1"
class="d-md-flex flex-grow-1 min-width-0"
:disabled="environments.isLoading"
/>
......@@ -205,7 +204,7 @@ export default {
ref="scrollButtons"
class="flex-grow-0 pr-2 mb-2 controllers"
:scroll-down-button-disabled="scrollDownButtonDisabled"
@refresh="showPodLogs(pods.current)"
@refresh="fetchLogs()"
@scrollDown="scrollDown"
/>
</div>
......
<script>
import { s__ } from '~/locale';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import { mapActions, mapState } from 'vuex';
import {
GlIcon,
GlDropdown,
GlDropdownHeader,
GlDropdownDivider,
GlDropdownItem,
GlSearchBoxByClick,
} from '@gitlab/ui';
import { GlFilteredSearch } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import { timeRanges } from '~/vue_shared/constants';
import { TOKEN_TYPE_POD_NAME } from '../constants';
import TokenWithLoadingState from './tokens/token_with_loading_state.vue';
export default {
components: {
GlIcon,
GlDropdown,
GlDropdownHeader,
GlDropdownDivider,
GlDropdownItem,
GlSearchBoxByClick,
GlFilteredSearch,
DateTimePicker,
},
props: {
......@@ -32,11 +22,10 @@ export default {
data() {
return {
timeRanges,
searchQuery: '',
};
},
computed: {
...mapState('environmentLogs', ['timeRange', 'pods']),
...mapState('environmentLogs', ['timeRange', 'pods', 'logs']),
timeRangeModel: {
get() {
......@@ -46,75 +35,56 @@ export default {
this.setTimeRange(val);
},
},
/**
* Token options.
*
* Returns null when no pods are present, so suggestions are displayed in the token
*/
podOptions() {
if (this.pods.options.length) {
return this.pods.options.map(podName => ({ value: podName, title: podName }));
}
return null;
},
podDropdownText() {
return this.pods.current || s__('Environments|All pods');
tokens() {
return [
{
icon: 'pod',
type: TOKEN_TYPE_POD_NAME,
title: s__('Environments|Pod name'),
token: TokenWithLoadingState,
operators: [{ value: '=', description: __('is'), default: 'true' }],
unique: true,
options: this.podOptions,
loading: this.logs.isLoading,
noOptionsText: s__('Environments|No pods to display'),
},
];
},
},
methods: {
...mapActions('environmentLogs', ['setSearch', 'showPodLogs', 'setTimeRange']),
isCurrentPod(podName) {
return podName === this.pods.current;
...mapActions('environmentLogs', ['showFilteredLogs', 'setTimeRange']),
filteredSearchSubmit(filters) {
this.showFilteredLogs(filters);
},
},
};
</script>
<template>
<div>
<gl-dropdown
ref="podsDropdown"
:text="podDropdownText"
:disabled="disabled"
class="mb-2 gl-h-32 pr-2 d-flex d-md-block flex-grow-0 qa-pods-dropdown"
>
<gl-dropdown-header class="text-center">
{{ s__('Environments|Filter by pod') }}
</gl-dropdown-header>
<gl-dropdown-item v-if="!pods.options.length" disabled>
<span ref="noPodsMsg" class="text-muted">
{{ s__('Environments|No pods to display') }}
</span>
</gl-dropdown-item>
<template v-else>
<gl-dropdown-item ref="allPodsOption" key="all-pods" @click="showPodLogs(null)">
<div class="d-flex">
<gl-icon
:class="{ invisible: pods.current !== null }"
name="status_success_borderless"
/>
<div class="flex-grow-1">{{ s__('Environments|All pods') }}</div>
</div>
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-dropdown-item
v-for="podName in pods.options"
:key="podName"
class="text-nowrap"
@click="showPodLogs(podName)"
>
<div class="d-flex">
<gl-icon
:class="{ invisible: !isCurrentPod(podName) }"
name="status_success_borderless"
<div class="mb-2 pr-2 flex-grow-1 min-width-0">
<gl-filtered-search
:placeholder="__('Search')"
:clear-button-title="__('Clear')"
:close-button-title="__('Close')"
class="gl-h-32"
:disabled="disabled || logs.isLoading"
:available-tokens="tokens"
@submit="filteredSearchSubmit"
/>
<div class="flex-grow-1">{{ podName }}</div>
</div>
</gl-dropdown-item>
</template>
</gl-dropdown>
<gl-search-box-by-click
ref="searchBox"
v-model.trim="searchQuery"
:disabled="disabled"
:placeholder="s__('Environments|Search')"
class="mb-2 pr-2 flex-grow-1"
type="search"
autofocus
@submit="setSearch(searchQuery)"
/>
<date-time-picker
ref="dateTimePicker"
......
<script>
import { GlFilteredSearchToken, GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
GlFilteredSearchToken,
GlLoadingIcon,
},
inheritAttrs: false,
props: {
config: {
type: Object,
required: true,
},
},
};
</script>
<template>
<gl-filtered-search-token :config="config" v-bind="{ ...$attrs }" v-on="$listeners">
<template #suggestions>
<div class="m-1">
<gl-loading-icon v-if="config.loading" />
<div v-else class="py-1 px-2 text-muted">
{{ config.noOptionsText }}
</div>
</div>
</template>
</gl-filtered-search-token>
</template>
export const dateFormatMask = 'UTC:mmm dd HH:MM:ss.l"Z"';
export const TOKEN_TYPE_POD_NAME = 'TOKEN_TYPE_POD_NAME';
......@@ -2,6 +2,7 @@ import { backOff } from '~/lib/utils/common_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import { TOKEN_TYPE_POD_NAME } from '../constants';
import * as types from './mutation_types';
......@@ -49,19 +50,42 @@ const requestLogsUntilData = ({ commit, state }) => {
return requestUntilData(logs_api_path, params);
};
/**
* Converts filters emitted by the component, e.g. a filterered-search
* to parameters to be applied to the filters of the store
* @param {Array} filters - List of strings or objects to filter by.
* @returns {Object} - An object with `search` and `podName` keys.
*/
const filtersToParams = (filters = []) => {
// Strings become part of the `search`
const search = filters
.filter(f => typeof f === 'string')
.join(' ')
.trim();
// null podName to show all pods
const podName = filters.find(f => f?.type === TOKEN_TYPE_POD_NAME)?.value?.data ?? null;
return { search, podName };
};
export const setInitData = ({ commit }, { timeRange, environmentName, podName }) => {
commit(types.SET_TIME_RANGE, timeRange);
commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
commit(types.SET_CURRENT_POD_NAME, podName);
};
export const showPodLogs = ({ dispatch, commit }, podName) => {
export const showFilteredLogs = ({ dispatch, commit }, filters = []) => {
const { podName, search } = filtersToParams(filters);
commit(types.SET_CURRENT_POD_NAME, podName);
commit(types.SET_SEARCH, search);
dispatch('fetchLogs');
};
export const setSearch = ({ dispatch, commit }, searchQuery) => {
commit(types.SET_SEARCH, searchQuery);
export const showPodLogs = ({ dispatch, commit }, podName) => {
commit(types.SET_CURRENT_POD_NAME, podName);
dispatch('fetchLogs');
};
......
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import dateFormat from 'dateformat';
const dateFormatMask = 'UTC:mmm dd HH:MM:ss.l"Z"';
import { dateFormatMask } from './constants';
/**
* Returns a time range (`start`, `end`) where `start` is the
......
......@@ -36,7 +36,7 @@ export default {
<template>
<div>
<div>
<div class="border-bottom pb-4">
<h3>{{ s__('StaticSiteEditor|Success!') }}</h3>
<p>
{{
......@@ -45,35 +45,37 @@ export default {
)
}}
</p>
<div>
<div class="d-flex justify-content-end">
<gl-new-button ref="returnToSiteButton" :href="returnUrl">{{
s__('StaticSiteEditor|Return to site')
}}</gl-new-button>
<gl-new-button ref="mergeRequestButton" :href="mergeRequest.url" variant="info">{{
s__('StaticSiteEditor|View merge request')
}}</gl-new-button>
<gl-new-button
ref="mergeRequestButton"
class="ml-2"
:href="mergeRequest.url"
variant="success"
>{{ s__('StaticSiteEditor|View merge request') }}</gl-new-button
>
</div>
</div>
<hr />
<div>
<div class="pt-2">
<h4>{{ s__('StaticSiteEditor|Summary of changes') }}</h4>
<ul>
<li>
{{ s__('StaticSiteEditor|A new branch was created:') }}
{{ s__('StaticSiteEditor|You created a new branch:') }}
<gl-link ref="branchLink" :href="branch.url">{{ branch.label }}</gl-link>
</li>
<li>
{{ s__('StaticSiteEditor|Your changes were committed to it:') }}
<gl-link ref="commitLink" :href="commit.url">{{ commit.label }}</gl-link>
</li>
<li>
{{ s__('StaticSiteEditor|A merge request was created:') }}
{{ s__('StaticSiteEditor|You created a merge request:') }}
<gl-link ref="mergeRequestLink" :href="mergeRequest.url">{{
mergeRequest.label
}}</gl-link>
</li>
<li>
{{ s__('StaticSiteEditor|You added a commit:') }}
<gl-link ref="commitLink" :href="commit.url">{{ commit.label }}</gl-link>
</li>
</ul>
</div>
</div>
......
......@@ -96,8 +96,8 @@
}
.name {
background-color: $filter-name-resting-color;
color: $filter-name-text-color;
background-color: $white-normal;
color: $gl-text-color-secondary;
border-radius: 2px 0 0 2px;
margin-right: 1px;
text-transform: capitalize;
......@@ -105,7 +105,7 @@
.operator {
background-color: $white-normal;
color: $filter-value-text-color;
color: $gl-text-color;
margin-right: 1px;
}
......@@ -113,7 +113,7 @@
display: flex;
align-items: center;
background-color: $white-normal;
color: $filter-value-text-color;
color: $gl-text-color;
border-radius: 0 2px 2px 0;
margin-right: 5px;
padding-right: 8px;
......@@ -152,7 +152,7 @@
.filtered-search-token .selected,
.filtered-search-term .selected {
.name {
background-color: $filter-name-selected-color;
background-color: $gray-200;
}
.operator {
......
......@@ -86,13 +86,13 @@
line-height: 10px;
color: $gl-gray-700;
vertical-align: middle;
background-color: $kdb-bg;
background-color: $gray-50;
border-width: 1px;
border-style: solid;
border-color: $gl-gray-200 $gl-gray-200 $kdb-border-bottom;
border-color: $gray-200 $gray-200 $gray-400;
border-image: none;
border-radius: 3px;
box-shadow: 0 -1px 0 $kdb-shadow inset;
box-shadow: 0 -1px 0 $gray-400 inset;
}
h1 {
......
......@@ -485,7 +485,7 @@ $line-removed-dark: #fac5cd;
$line-number-old: #f9d7dc;
$line-number-new: #ddfbe6;
$line-number-select: #fbf2da;
$line-target-blue: #f6faff;
$line-target-blue: $blue-50;
$line-select-yellow: #fcf8e7;
$line-select-yellow-dark: #f0e2bd;
$dark-diff-match-bg: rgba(255, 255, 255, 0.3);
......@@ -698,7 +698,7 @@ $logs-p-color: #333;
*/
$input-height: 34px;
$input-danger-bg: #f2dede;
$input-group-addon-bg: #f7f8fa;
$input-group-addon-bg: $gray-50;
$gl-field-focus-shadow: rgba(0, 0, 0, 0.075);
$gl-field-focus-shadow-error: rgba($red-500, 0.6);
$input-short-width: 200px;
......@@ -774,9 +774,6 @@ $select2-drop-shadow2: rgba(31, 37, 50, 0.317647);
/*
* Typography
*/
$kdb-bg: #fcfcfc;
$kdb-border-bottom: #bbb;
$kdb-shadow: #bbb;
$body-text-shadow: rgba(255, 255, 255, 0.01);
/*
......@@ -800,20 +797,6 @@ CI variable lists
*/
$ci-variable-remove-button-width: calc(1em + #{2 * $gl-padding});
/*
Filtered Search
*/
$filter-name-resting-color: #f8f8f8;
$filter-name-text-color: rgba(0, 0, 0, 0.55);
$filter-value-text-color: rgba(0, 0, 0, 0.85);
$filter-name-selected-color: #ebebeb;
$filter-value-selected-color: #d7d7d7;
/*
Animation Functions
*/
$dropdown-animation-timing: cubic-bezier(0.23, 1, 0.32, 1);
/*
GitLab Plans
*/
......
......@@ -54,6 +54,11 @@
.mh-50vh { max-height: 50vh; }
.min-width-0 {
// By default flex items don't shrink below their minimum content size. To change this, set the item's min-width
min-width: 0;
}
.font-size-inherit { font-size: inherit; }
.gl-w-8 { width: px-to-rem($grid-size); }
.gl-w-16 { width: px-to-rem($grid-size * 2); }
......
......@@ -475,6 +475,16 @@ class Group < Namespace
false
end
def wiki_access_level
# TODO: Remove this method once we implement group-level features.
# https://gitlab.com/gitlab-org/gitlab/-/issues/208412
if Feature.enabled?(:group_wiki, self)
ProjectFeature::ENABLED
else
ProjectFeature::DISABLED
end
end
private
def update_two_factor_requirement
......
# frozen_string_literal: true
class ProjectPolicy
module ClassMethods
module CrudPolicyHelpers
extend ActiveSupport::Concern
class_methods do
def create_read_update_admin_destroy(name)
[
:"read_#{name}",
......
# frozen_string_literal: true
class GroupPolicy < BasePolicy
include CrudPolicyHelpers
include FindGroupProjects
desc "Group is public"
......@@ -42,15 +43,23 @@ class GroupPolicy < BasePolicy
@subject.subgroup_creation_level == ::Gitlab::Access::MAINTAINER_SUBGROUP_ACCESS
end
desc "Group has wiki disabled"
condition(:wiki_disabled, score: 32) { !feature_available?(:wiki) }
rule { public_group }.policy do
enable :read_group
enable :read_package
enable :read_wiki
end
rule { logged_in_viewable }.enable :read_group
rule { logged_in_viewable }.policy do
enable :read_group
enable :read_wiki
end
rule { guest }.policy do
enable :read_group
enable :read_wiki
enable :upload_file
end
......@@ -78,10 +87,12 @@ class GroupPolicy < BasePolicy
enable :create_metrics_dashboard_annotation
enable :delete_metrics_dashboard_annotation
enable :update_metrics_dashboard_annotation
enable :create_wiki
end
rule { reporter }.policy do
enable :read_container_image
enable :download_wiki_code
enable :admin_label
enable :admin_list
enable :admin_issue
......@@ -100,6 +111,7 @@ class GroupPolicy < BasePolicy
enable :destroy_deploy_token
enable :read_deploy_token
enable :create_deploy_token
enable :admin_wiki
end
rule { owner }.policy do
......@@ -145,6 +157,11 @@ class GroupPolicy < BasePolicy
rule { maintainer & can?(:create_projects) }.enable :transfer_projects
rule { wiki_disabled }.policy do
prevent(*create_read_update_admin_destroy(:wiki))
prevent(:download_wiki_code)
end
def access_level
return GroupMember::NO_ACCESS if @user.nil?
......@@ -154,6 +171,21 @@ class GroupPolicy < BasePolicy
def lookup_access_level!
@subject.max_member_access_for_user(@user)
end
# TODO: Extract this into a helper shared with ProjectPolicy, once we implement group-level features.
# https://gitlab.com/gitlab-org/gitlab/-/issues/208412
def feature_available?(feature)
return false unless feature == :wiki
case @subject.wiki_access_level
when ProjectFeature::DISABLED
false
when ProjectFeature::PRIVATE
admin? || access_level >= ProjectFeature.required_minimum_access_level(feature)
else
true
end
end
end
GroupPolicy.prepend_if_ee('EE::GroupPolicy')
......@@ -5,7 +5,7 @@ class IssuePolicy < IssuablePolicy
# Make sure to sync this class checks with issue.rb to avoid security problems.
# Check commit 002ad215818450d2cbbc5fa065850a953dc7ada8 for more information.
extend ProjectPolicy::ClassMethods
include CrudPolicyHelpers
desc "User can read confidential issues"
condition(:can_read_confidential) do
......
# frozen_string_literal: true
class ProjectPolicy < BasePolicy
extend ClassMethods
include CrudPolicyHelpers
READONLY_FEATURES_WHEN_ARCHIVED = %i[
issue
......
......@@ -16,6 +16,11 @@ module Prometheus
identifier: 'response_metrics_nginx_ingress_http_error_rate',
operator: 'gt',
threshold: 0.1
},
{
identifier: 'response_metrics_nginx_http_error_percentage',
operator: 'gt',
threshold: 0.1
}
].freeze
......
---
title: Add filtered search for elastic search in logs
merge_request: 27654
author:
type: added
......@@ -194,8 +194,6 @@ application server, or a Gitaly node.
- `PRAEFECT_HOST` with the IP address or hostname of the Praefect node
```ruby
# Make Praefect accept connections on all network interfaces.
# Use firewalls to restrict access to this address/port.
praefect['listen_addr'] = 'PRAEFECT_HOST:2305'
# Enable Prometheus metrics access to Praefect. You must use firewalls
......@@ -532,7 +530,7 @@ Particular attention should be shown to:
`/etc/gitlab/gitlab.rb`
```ruby
gitaly['listen_addr'] = 'tcp://GITLAB_HOST:8075'
gitaly['listen_addr'] = 'GITLAB_HOST:8075'
```
1. Configure the `gitlab_shell['secret_token']` so that callbacks from Gitaly
......
......@@ -40,9 +40,13 @@ needs.
| Object storage service | Recommended store for shared data objects | [Cloud Object Storage configuration](../high_availability/object_storage.md) |
| NFS | Shared disk storage service. Can be used as an alternative for Gitaly or Object Storage. Required for GitLab Pages | [NFS configuration](../high_availability/nfs.md) |
## Examples
## Reference architectures
- 1 - 1000 Users: A single-node [Omnibus](https://docs.gitlab.com/omnibus/) setup with frequent backups. Refer to the [Single-node Omnibus installation](#single-node-installation) section below.
- 1000 to 50000+ Users: A [Scaled-out Omnibus installation with multiple servers](#multi-node-installation-scaled-out-for-availability), it can be with or without high-availability components applied.
- To decide the level of Availability please refer to our [Availability](../availability/index.md) page.
### Single-node Omnibus installation
### Single-node installation
This solution is appropriate for many teams that have a single server at their disposal. With automatic backup of the GitLab repositories, configuration, and the database, this can be an optimal solution if you don't have strict availability requirements.
......@@ -55,7 +59,7 @@ References:
- [Installation Docs](../../install/README.md)
- [Backup/Restore Docs](https://docs.gitlab.com/omnibus/settings/backups.html#backup-and-restore-omnibus-gitlab-configuration)
### Omnibus installation with multiple application servers
### Multi-node installation (scaled out for availability)
This solution is appropriate for teams that are starting to scale out when
scaling up is no longer meeting their needs. In this configuration, additional application nodes will handle frontend traffic, with a load balancer in front to distribute traffic across those nodes. Meanwhile, each application node connects to a shared file server and PostgreSQL and Redis services on the back end.
......@@ -72,14 +76,6 @@ References:
- [Configure packaged PostgreSQL server to listen on TCP/IP](https://docs.gitlab.com/omnibus/settings/database.html#configure-packaged-postgresql-server-to-listen-on-tcpip)
- [Setting up a Redis-only server](https://docs.gitlab.com/omnibus/settings/redis.html#setting-up-a-redis-only-server)
## Recommended setups based on number of users
- 1 - 1000 Users: A single-node [Omnibus](https://docs.gitlab.com/omnibus/) setup with frequent backups. Refer to the [requirements page](../../install/requirements.md) for further details of the specs you will require.
- 1000 - 10000 Users: A scaled environment based on one of our [Reference Architectures](#reference-architectures), without the HA components applied. This can be a reasonable step towards a fully HA environment.
- 2000 - 50000+ Users: A scaled HA environment based on one of our [Reference Architectures](#reference-architectures) below.
## Reference architectures
In this section we'll detail the Reference Architectures that can support large numbers
of users. These were built, tested and verified by our Quality and Support teams.
......@@ -99,7 +95,7 @@ how much automation you use, mirroring, and repo/change size. Additionally the
shown memory values are given directly by [GCP machine types](https://cloud.google.com/compute/docs/machine-types).
On different cloud vendors a best effort like for like can be used.
### 2,000 user configuration
#### 2,000 user configuration
- **Supported users (approximate):** 2,000
- **Test RPS rates:** API: 40 RPS, Web: 4 RPS, Git: 4 RPS
......@@ -120,7 +116,7 @@ On different cloud vendors a best effort like for like can be used.
| External load balancing node[^6] | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large |
| Internal load balancing node[^6] | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large |
### 5,000 user configuration
#### 5,000 user configuration
- **Supported users (approximate):** 5,000
- **Test RPS rates:** API: 100 RPS, Web: 10 RPS, Git: 10 RPS
......@@ -141,7 +137,7 @@ On different cloud vendors a best effort like for like can be used.
| External load balancing node[^6] | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large |
| Internal load balancing node[^6] | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large |
### 10,000 user configuration
#### 10,000 user configuration
- **Supported users (approximate):** 10,000
- **Test RPS rates:** API: 200 RPS, Web: 20 RPS, Git: 20 RPS
......@@ -165,7 +161,7 @@ On different cloud vendors a best effort like for like can be used.
| External load balancing node[^6] | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large |
| Internal load balancing node[^6] | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large |
### 25,000 user configuration
#### 25,000 user configuration
- **Supported users (approximate):** 25,000
- **Test RPS rates:** API: 500 RPS, Web: 50 RPS, Git: 50 RPS
......@@ -189,7 +185,7 @@ On different cloud vendors a best effort like for like can be used.
| External load balancing node[^6] | 1 | 2 vCPU, 1.8GB Memory | n1-highcpu-2 | c5.large |
| Internal load balancing node[^6] | 1 | 4 vCPU, 3.6GB Memory | n1-highcpu-4 | c5.xlarge |
### 50,000 user configuration
#### 50,000 user configuration
- **Supported users (approximate):** 50,000
- **Test RPS rates:** API: 1000 RPS, Web: 100 RPS, Git: 100 RPS
......
......@@ -14,7 +14,7 @@ Everything you need to build, test, deploy, and run your app at scale.
[Kubernetes](https://kubernetes.io) logs can be viewed directly within GitLab.
![Pod logs](img/kubernetes_pod_logs_v12_9.png)
![Pod logs](img/kubernetes_pod_logs_v12_10.png)
## Requirements
......@@ -32,7 +32,7 @@ You can access them in two ways.
Go to **{cloud-gear}** **Operations > Logs** on the sidebar menu.
![Sidebar menu](img/sidebar_menu_pod_logs_v12_5.png)
![Sidebar menu](img/sidebar_menu_pod_logs_v12_10.png)
### From Deploy Boards
......
......@@ -7869,9 +7869,6 @@ msgstr ""
msgid "EnvironmentsDashboard|This dashboard displays a maximum of 7 projects and 3 environments per project. %{readMoreLink}"
msgstr ""
msgid "Environments|All pods"
msgstr ""
msgid "Environments|An error occurred while canceling the auto stop, please try again"
msgstr ""
......@@ -7938,9 +7935,6 @@ msgstr ""
msgid "Environments|Environments are places where code gets deployed, such as staging or production."
msgstr ""
msgid "Environments|Filter by pod"
msgstr ""
msgid "Environments|Install Elastic Stack on your cluster to enable advanced querying capabilities such as full text search."
msgstr ""
......@@ -7980,6 +7974,9 @@ msgstr ""
msgid "Environments|Open live environment"
msgstr ""
msgid "Environments|Pod name"
msgstr ""
msgid "Environments|Re-deploy"
msgstr ""
......@@ -8007,9 +8004,6 @@ msgstr ""
msgid "Environments|Rollback environment %{name}?"
msgstr ""
msgid "Environments|Search"
msgstr ""
msgid "Environments|Select environment"
msgstr ""
......@@ -19365,12 +19359,6 @@ msgstr ""
msgid "Static Application Security Testing (SAST)"
msgstr ""
msgid "StaticSiteEditor|A merge request was created:"
msgstr ""
msgid "StaticSiteEditor|A new branch was created:"
msgstr ""
msgid "StaticSiteEditor|Return to site"
msgstr ""
......@@ -19383,10 +19371,16 @@ msgstr ""
msgid "StaticSiteEditor|View merge request"
msgstr ""
msgid "StaticSiteEditor|Your changes have been submitted and a merge request has been created. The changes won’t be visible on the site until the merge request has been accepted."
msgid "StaticSiteEditor|You added a commit:"
msgstr ""
msgid "StaticSiteEditor|Your changes were committed to it:"
msgid "StaticSiteEditor|You created a merge request:"
msgstr ""
msgid "StaticSiteEditor|You created a new branch:"
msgstr ""
msgid "StaticSiteEditor|Your changes have been submitted and a merge request has been created. The changes won’t be visible on the site until the merge request has been accepted."
msgstr ""
msgid "Statistics"
......
......@@ -10,7 +10,6 @@ import {
mockPods,
mockLogsResult,
mockTrace,
mockPodName,
mockEnvironmentsEndpoint,
mockDocumentationPath,
} from '../mock_data';
......@@ -302,11 +301,11 @@ describe('EnvironmentLogs', () => {
});
it('refresh button, trace is refreshed', () => {
expect(dispatch).not.toHaveBeenCalledWith(`${module}/showPodLogs`, expect.anything());
expect(dispatch).not.toHaveBeenCalledWith(`${module}/fetchLogs`, undefined);
findLogControlButtons().vm.$emit('refresh');
expect(dispatch).toHaveBeenCalledWith(`${module}/showPodLogs`, mockPodName);
expect(dispatch).toHaveBeenCalledWith(`${module}/fetchLogs`, undefined);
});
});
});
......
import { GlIcon, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { defaultTimeRange } from '~/vue_shared/constants';
import { GlFilteredSearch } from '@gitlab/ui';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import { createStore } from '~/logs/stores';
import { TOKEN_TYPE_POD_NAME } from '~/logs/constants';
import { mockPods, mockSearch } from '../mock_data';
import LogAdvancedFilters from '~/logs/components/log_advanced_filters.vue';
......@@ -15,26 +16,19 @@ describe('LogAdvancedFilters', () => {
let wrapper;
let state;
const findPodsDropdown = () => wrapper.find({ ref: 'podsDropdown' });
const findPodsNoPodsText = () => wrapper.find({ ref: 'noPodsMsg' });
const findPodsDropdownItems = () =>
findPodsDropdown()
.findAll(GlDropdownItem)
.filter(item => !item.is('[disabled]'));
const findPodsDropdownItemsSelected = () =>
findPodsDropdownItems()
.filter(item => {
return !item.find(GlIcon).classes('invisible');
})
.at(0);
const findSearchBox = () => wrapper.find({ ref: 'searchBox' });
const findFilteredSearch = () => wrapper.find(GlFilteredSearch);
const findTimeRangePicker = () => wrapper.find({ ref: 'dateTimePicker' });
const getSearchToken = type =>
findFilteredSearch()
.props('availableTokens')
.filter(token => token.type === type)[0];
const mockStateLoading = () => {
state.timeRange.selected = defaultTimeRange;
state.timeRange.current = convertToFixedRange(defaultTimeRange);
state.pods.options = [];
state.pods.current = null;
state.logs.isLoading = true;
};
const mockStateWithData = () => {
......@@ -42,6 +36,7 @@ describe('LogAdvancedFilters', () => {
state.timeRange.current = convertToFixedRange(defaultTimeRange);
state.pods.options = mockPods;
state.pods.current = null;
state.logs.isLoading = false;
};
const initWrapper = (propsData = {}) => {
......@@ -76,11 +71,18 @@ describe('LogAdvancedFilters', () => {
expect(wrapper.isVueInstance()).toBe(true);
expect(wrapper.isEmpty()).toBe(false);
expect(findPodsDropdown().exists()).toBe(true);
expect(findSearchBox().exists()).toBe(true);
expect(findFilteredSearch().exists()).toBe(true);
expect(findTimeRangePicker().exists()).toBe(true);
});
it('displays search tokens', () => {
expect(getSearchToken(TOKEN_TYPE_POD_NAME)).toMatchObject({
title: 'Pod name',
unique: true,
operators: [expect.objectContaining({ value: '=' })],
});
});
describe('disabled state', () => {
beforeEach(() => {
mockStateLoading();
......@@ -90,9 +92,7 @@ describe('LogAdvancedFilters', () => {
});
it('displays disabled filters', () => {
expect(findPodsDropdown().props('text')).toBe('All pods');
expect(findPodsDropdown().attributes('disabled')).toBeTruthy();
expect(findSearchBox().attributes('disabled')).toBeTruthy();
expect(findFilteredSearch().attributes('disabled')).toBeTruthy();
expect(findTimeRangePicker().attributes('disabled')).toBeTruthy();
});
});
......@@ -103,16 +103,17 @@ describe('LogAdvancedFilters', () => {
initWrapper();
});
it('displays a enabled filters', () => {
expect(findPodsDropdown().props('text')).toBe('All pods');
expect(findPodsDropdown().attributes('disabled')).toBeFalsy();
expect(findSearchBox().attributes('disabled')).toBeFalsy();
it('displays a disabled search', () => {
expect(findFilteredSearch().attributes('disabled')).toBeTruthy();
});
it('displays an enable date filter', () => {
expect(findTimeRangePicker().attributes('disabled')).toBeFalsy();
});
it('displays an empty pods dropdown', () => {
expect(findPodsNoPodsText().exists()).toBe(true);
expect(findPodsDropdownItems()).toHaveLength(0);
it('displays no pod options when no pods are available, so suggestions can be displayed', () => {
expect(getSearchToken(TOKEN_TYPE_POD_NAME).options).toBe(null);
expect(getSearchToken(TOKEN_TYPE_POD_NAME).loading).toBe(true);
});
});
......@@ -122,20 +123,24 @@ describe('LogAdvancedFilters', () => {
initWrapper();
});
it('displays an enabled pods dropdown', () => {
expect(findPodsDropdown().attributes('disabled')).toBeFalsy();
expect(findPodsDropdown().props('text')).toBe('All pods');
it('displays a single token for pods', () => {
initWrapper();
const tokens = findFilteredSearch().props('availableTokens');
expect(tokens).toHaveLength(1);
expect(tokens[0].type).toBe(TOKEN_TYPE_POD_NAME);
});
it('displays options in a pods dropdown', () => {
const items = findPodsDropdownItems();
expect(items).toHaveLength(mockPods.length + 1);
it('displays a enabled filters', () => {
expect(findFilteredSearch().attributes('disabled')).toBeFalsy();
expect(findTimeRangePicker().attributes('disabled')).toBeFalsy();
});
it('displays "all pods" selected in a pods dropdown', () => {
const selected = findPodsDropdownItemsSelected();
it('displays options in the pods token', () => {
const { options } = getSearchToken(TOKEN_TYPE_POD_NAME);
expect(selected.text()).toBe('All pods');
expect(options).toHaveLength(mockPods.length);
});
it('displays options in date time picker', () => {
......@@ -146,30 +151,16 @@ describe('LogAdvancedFilters', () => {
});
describe('when the user interacts', () => {
it('clicks on a all options, showPodLogs is dispatched with null', () => {
const items = findPodsDropdownItems();
items.at(0).vm.$emit('click');
expect(dispatch).toHaveBeenCalledWith(`${module}/showPodLogs`, null);
});
it('clicks on a pod name, showPodLogs is dispatched with pod name', () => {
const items = findPodsDropdownItems();
const index = 2; // any pod
it('clicks on the search button, showFilteredLogs is dispatched', () => {
findFilteredSearch().vm.$emit('submit', null);
items.at(index + 1).vm.$emit('click'); // skip "All pods" option
expect(dispatch).toHaveBeenCalledWith(`${module}/showPodLogs`, mockPods[index]);
expect(dispatch).toHaveBeenCalledWith(`${module}/showFilteredLogs`, null);
});
it('clicks on search, a serches is done', () => {
expect(findSearchBox().attributes('disabled')).toBeFalsy();
// input a query and click `search`
findSearchBox().vm.$emit('input', mockSearch);
findSearchBox().vm.$emit('submit');
it('clicks on the search button, showFilteredLogs is dispatched with null', () => {
findFilteredSearch().vm.$emit('submit', [mockSearch]);
expect(dispatch).toHaveBeenCalledWith(`${module}/setSearch`, mockSearch);
expect(dispatch).toHaveBeenCalledWith(`${module}/showFilteredLogs`, [mockSearch]);
});
it('selects a new time range', () => {
......
import { GlFilteredSearchToken, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import TokenWithLoadingState from '~/logs/components/tokens/token_with_loading_state.vue';
describe('TokenWithLoadingState', () => {
let wrapper;
const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const initWrapper = (props = {}, options) => {
wrapper = shallowMount(TokenWithLoadingState, {
propsData: props,
...options,
});
};
beforeEach(() => {});
it('passes entire config correctly', () => {
const config = {
icon: 'pod',
type: 'pod',
title: 'Pod name',
unique: true,
};
initWrapper({ config });
expect(findFilteredSearchToken().props('config')).toEqual(config);
});
describe('suggestions are replaced', () => {
let mockNoOptsText;
let config;
let stubs;
beforeEach(() => {
mockNoOptsText = 'No suggestions available';
config = {
loading: false,
noOptionsText: mockNoOptsText,
};
stubs = {
GlFilteredSearchToken: {
template: `<div><slot name="suggestions"></slot></div>`,
},
};
});
it('renders a loading icon', () => {
config.loading = true;
initWrapper({ config }, { stubs });
expect(findLoadingIcon().exists()).toBe(true);
expect(wrapper.text()).toBe('');
});
it('renders an empty results message', () => {
initWrapper({ config }, { stubs });
expect(findLoadingIcon().exists()).toBe(false);
expect(wrapper.text()).toBe(mockNoOptsText);
});
});
});
......@@ -6,7 +6,7 @@ import { convertToFixedRange } from '~/lib/utils/datetime_range';
import logsPageState from '~/logs/stores/state';
import {
setInitData,
setSearch,
showFilteredLogs,
showPodLogs,
fetchEnvironments,
fetchLogs,
......@@ -31,6 +31,7 @@ import {
mockCursor,
mockNextCursor,
} from '../mock_data';
import { TOKEN_TYPE_POD_NAME } from '~/logs/constants';
jest.mock('~/flash');
jest.mock('~/lib/utils/datetime_range');
......@@ -93,13 +94,80 @@ describe('Logs Store actions', () => {
));
});
describe('setSearch', () => {
it('should commit search mutation', () =>
describe('showFilteredLogs', () => {
it('empty search should filter with defaults', () =>
testAction(
setSearch,
mockSearch,
showFilteredLogs,
undefined,
state,
[
{ type: types.SET_CURRENT_POD_NAME, payload: null },
{ type: types.SET_SEARCH, payload: '' },
],
[{ type: 'fetchLogs' }],
));
it('text search should filter with a search term', () =>
testAction(
showFilteredLogs,
[mockSearch],
state,
[
{ type: types.SET_CURRENT_POD_NAME, payload: null },
{ type: types.SET_SEARCH, payload: mockSearch },
],
[{ type: 'fetchLogs' }],
));
it('pod search should filter with a search term', () =>
testAction(
showFilteredLogs,
[{ type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } }],
state,
[{ type: types.SET_SEARCH, payload: mockSearch }],
[
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.SET_SEARCH, payload: '' },
],
[{ type: 'fetchLogs' }],
));
it('pod search should filter with a pod selection and a search term', () =>
testAction(
showFilteredLogs,
[{ type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } }, mockSearch],
state,
[
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.SET_SEARCH, payload: mockSearch },
],
[{ type: 'fetchLogs' }],
));
it('pod search should filter with a pod selection and two search terms', () =>
testAction(
showFilteredLogs,
['term1', 'term2'],
state,
[
{ type: types.SET_CURRENT_POD_NAME, payload: null },
{ type: types.SET_SEARCH, payload: `term1 term2` },
],
[{ type: 'fetchLogs' }],
));
it('pod search should filter with a pod selection and a search terms before and after', () =>
testAction(
showFilteredLogs,
[
'term1',
{ type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } },
'term2',
],
state,
[
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.SET_SEARCH, payload: `term1 term2` },
],
[{ type: 'fetchLogs' }],
));
});
......
......@@ -655,4 +655,26 @@ describe GroupPolicy do
end
end
end
it_behaves_like 'model with wiki policies' do
let(:container) { create(:group) }
def set_access_level(access_level)
allow(container).to receive(:wiki_access_level).and_return(access_level)
end
before do
stub_feature_flags(group_wiki: true)
end
context 'when the feature flag is disabled' do
before do
stub_feature_flags(group_wiki: false)
end
it 'does not include the wiki permissions' do
expect_disallowed(*permissions)
end
end
end
end
......@@ -121,147 +121,11 @@ describe ProjectPolicy do
expect(Ability).not_to be_allowed(user, :read_issue, project)
end
context 'wiki feature' do
let(:permissions) { %i(read_wiki create_wiki update_wiki admin_wiki download_wiki_code) }
it_behaves_like 'model with wiki policies' do
let(:container) { project }
subject { described_class.new(owner, project) }
context 'when the feature is disabled' do
before do
project.project_feature.update_attribute(:wiki_access_level, ProjectFeature::DISABLED)
end
it 'does not include the wiki permissions' do
expect_disallowed(*permissions)
end
context 'when there is an external wiki' do
it 'does not include the wiki permissions' do
allow(project).to receive(:has_external_wiki?).and_return(true)
expect_disallowed(*permissions)
end
end
end
describe 'read_wiki' do
subject { described_class.new(user, project) }
member_roles = %i[guest developer]
stranger_roles = %i[anonymous non_member]
user_roles = stranger_roles + member_roles
# When a user is anonymous, their `current_user == nil`
let(:user) { create(:user) unless user_role == :anonymous }
before do
project.visibility = project_visibility
project.project_feature.update_attribute(:wiki_access_level, wiki_access_level)
project.add_user(user, user_role) if member_roles.include?(user_role)
end
title = ->(project_visibility, wiki_access_level, user_role) do
[
"project is #{Gitlab::VisibilityLevel.level_name project_visibility}",
"wiki is #{ProjectFeature.str_from_access_level wiki_access_level}",
"user is #{user_role}"
].join(', ')
end
describe 'Situations where :read_wiki is always false' do
where(case_names: title,
project_visibility: Gitlab::VisibilityLevel.options.values,
wiki_access_level: [ProjectFeature::DISABLED],
user_role: user_roles)
with_them do
it { is_expected.to be_disallowed(:read_wiki) }
end
end
describe 'Situations where :read_wiki is always true' do
where(case_names: title,
project_visibility: [Gitlab::VisibilityLevel::PUBLIC],
wiki_access_level: [ProjectFeature::ENABLED],
user_role: user_roles)
with_them do
it { is_expected.to be_allowed(:read_wiki) }
end
end
describe 'Situations where :read_wiki requires project membership' do
context 'the wiki is private, and the user is a member' do
where(case_names: title,
project_visibility: [Gitlab::VisibilityLevel::PUBLIC,
Gitlab::VisibilityLevel::INTERNAL],
wiki_access_level: [ProjectFeature::PRIVATE],
user_role: member_roles)
with_them do
it { is_expected.to be_allowed(:read_wiki) }
end
end
context 'the wiki is private, and the user is not member' do
where(case_names: title,
project_visibility: [Gitlab::VisibilityLevel::PUBLIC,
Gitlab::VisibilityLevel::INTERNAL],
wiki_access_level: [ProjectFeature::PRIVATE],
user_role: stranger_roles)
with_them do
it { is_expected.to be_disallowed(:read_wiki) }
end
end
context 'the wiki is enabled, and the user is a member' do
where(case_names: title,
project_visibility: [Gitlab::VisibilityLevel::PRIVATE],
wiki_access_level: [ProjectFeature::ENABLED],
user_role: member_roles)
with_them do
it { is_expected.to be_allowed(:read_wiki) }
end
end
context 'the wiki is enabled, and the user is not a member' do
where(case_names: title,
project_visibility: [Gitlab::VisibilityLevel::PRIVATE],
wiki_access_level: [ProjectFeature::ENABLED],
user_role: stranger_roles)
with_them do
it { is_expected.to be_disallowed(:read_wiki) }
end
end
end
describe 'Situations where :read_wiki prohibits anonymous access' do
context 'the user is not anonymous' do
where(case_names: title,
project_visibility: [Gitlab::VisibilityLevel::INTERNAL],
wiki_access_level: [ProjectFeature::ENABLED, ProjectFeature::PUBLIC],
user_role: user_roles.reject { |u| u == :anonymous })
with_them do
it { is_expected.to be_allowed(:read_wiki) }
end
end
context 'the user is not anonymous' do
where(case_names: title,
project_visibility: [Gitlab::VisibilityLevel::INTERNAL],
wiki_access_level: [ProjectFeature::ENABLED, ProjectFeature::PUBLIC],
user_role: %i[anonymous])
with_them do
it { is_expected.to be_disallowed(:read_wiki) }
end
end
end
def set_access_level(access_level)
project.project_feature.update_attribute(:wiki_access_level, access_level)
end
end
......
......@@ -14,16 +14,17 @@ RSpec.shared_context 'GroupPolicy context' do
%i[
read_label read_group upload_file read_namespace read_group_activity
read_group_issues read_group_boards read_group_labels read_group_milestones
read_group_merge_requests
read_group_merge_requests read_wiki
]
end
let(:read_group_permissions) { %i[read_label read_list read_milestone read_board] }
let(:reporter_permissions) { %i[admin_label read_container_image read_metrics_dashboard_annotation] }
let(:developer_permissions) { %i[admin_milestone create_metrics_dashboard_annotation delete_metrics_dashboard_annotation update_metrics_dashboard_annotation] }
let(:reporter_permissions) { %i[admin_label read_container_image read_metrics_dashboard_annotation download_wiki_code] }
let(:developer_permissions) { %i[admin_milestone create_metrics_dashboard_annotation delete_metrics_dashboard_annotation update_metrics_dashboard_annotation create_wiki] }
let(:maintainer_permissions) do
%i[
create_projects
read_cluster create_cluster update_cluster admin_cluster add_cluster
admin_wiki
]
end
let(:owner_permissions) do
......
# frozen_string_literal: true
RSpec.shared_examples 'model with wiki policies' do
let(:container) { raise NotImplementedError }
let(:permissions) { %i(read_wiki create_wiki update_wiki admin_wiki download_wiki_code) }
# TODO: Remove this helper once we implement group features
# https://gitlab.com/gitlab-org/gitlab/-/issues/208412
def set_access_level(access_level)
raise NotImplementedError
end
subject { described_class.new(owner, container) }
context 'when the feature is disabled' do
before do
set_access_level(ProjectFeature::DISABLED)
end
it 'does not include the wiki permissions' do
expect_disallowed(*permissions)
end
context 'when there is an external wiki' do
it 'does not include the wiki permissions' do
allow(container).to receive(:has_external_wiki?).and_return(true)
expect_disallowed(*permissions)
end
end
end
describe 'read_wiki' do
subject { described_class.new(user, container) }
member_roles = %i[guest developer]
stranger_roles = %i[anonymous non_member]
user_roles = stranger_roles + member_roles
# When a user is anonymous, their `current_user == nil`
let(:user) { create(:user) unless user_role == :anonymous }
before do
container.visibility = container_visibility
set_access_level(wiki_access_level)
container.add_user(user, user_role) if member_roles.include?(user_role)
end
title = ->(container_visibility, wiki_access_level, user_role) do
[
"container is #{Gitlab::VisibilityLevel.level_name container_visibility}",
"wiki is #{ProjectFeature.str_from_access_level wiki_access_level}",
"user is #{user_role}"
].join(', ')
end
describe 'Situations where :read_wiki is always false' do
where(case_names: title,
container_visibility: Gitlab::VisibilityLevel.options.values,
wiki_access_level: [ProjectFeature::DISABLED],
user_role: user_roles)
with_them do
it { is_expected.to be_disallowed(:read_wiki) }
end
end
describe 'Situations where :read_wiki is always true' do
where(case_names: title,
container_visibility: [Gitlab::VisibilityLevel::PUBLIC],
wiki_access_level: [ProjectFeature::ENABLED],
user_role: user_roles)
with_them do
it { is_expected.to be_allowed(:read_wiki) }
end
end
describe 'Situations where :read_wiki requires membership' do
context 'the wiki is private, and the user is a member' do
where(case_names: title,
container_visibility: [Gitlab::VisibilityLevel::PUBLIC,
Gitlab::VisibilityLevel::INTERNAL],
wiki_access_level: [ProjectFeature::PRIVATE],
user_role: member_roles)
with_them do
it { is_expected.to be_allowed(:read_wiki) }
end
end
context 'the wiki is private, and the user is not member' do
where(case_names: title,
container_visibility: [Gitlab::VisibilityLevel::PUBLIC,
Gitlab::VisibilityLevel::INTERNAL],
wiki_access_level: [ProjectFeature::PRIVATE],
user_role: stranger_roles)
with_them do
it { is_expected.to be_disallowed(:read_wiki) }
end
end
context 'the wiki is enabled, and the user is a member' do
where(case_names: title,
container_visibility: [Gitlab::VisibilityLevel::PRIVATE],
wiki_access_level: [ProjectFeature::ENABLED],
user_role: member_roles)
with_them do
it { is_expected.to be_allowed(:read_wiki) }
end
end
context 'the wiki is enabled, and the user is not a member' do
where(case_names: title,
container_visibility: [Gitlab::VisibilityLevel::PRIVATE],
wiki_access_level: [ProjectFeature::ENABLED],
user_role: stranger_roles)
with_them do
it { is_expected.to be_disallowed(:read_wiki) }
end
end
end
describe 'Situations where :read_wiki prohibits anonymous access' do
context 'the user is not anonymous' do
where(case_names: title,
container_visibility: [Gitlab::VisibilityLevel::INTERNAL],
wiki_access_level: [ProjectFeature::ENABLED, ProjectFeature::PUBLIC],
user_role: user_roles.reject { |u| u == :anonymous })
with_them do
it { is_expected.to be_allowed(:read_wiki) }
end
end
context 'the user is anonymous' do
where(case_names: title,
container_visibility: [Gitlab::VisibilityLevel::INTERNAL],
wiki_access_level: [ProjectFeature::ENABLED, ProjectFeature::PUBLIC],
user_role: %i[anonymous])
with_them do
it { is_expected.to be_disallowed(:read_wiki) }
end
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment