Commit 5d2b5b3e authored by Sean McGivern's avatar Sean McGivern

Merge branch '5781-operations-homepage-mvc' into 'master'

Resolve "Operations Homepage MVC"

Closes #5781

See merge request gitlab-org/gitlab-ee!7341
parents 7e1f9a5f 58dae1a2
...@@ -9,6 +9,8 @@ ...@@ -9,6 +9,8 @@
# For users who haven't customized the setting, we simply delegate to # For users who haven't customized the setting, we simply delegate to
# `DashboardController#show`, which is the default. # `DashboardController#show`, which is the default.
class RootController < Dashboard::ProjectsController class RootController < Dashboard::ProjectsController
prepend EE::RootController
skip_before_action :authenticate_user!, only: [:index] skip_before_action :authenticate_user!, only: [:index]
before_action :redirect_unlogged_user, if: -> { current_user.nil? } before_action :redirect_unlogged_user, if: -> { current_user.nil? }
......
...@@ -24,6 +24,8 @@ ...@@ -24,6 +24,8 @@
class ProjectsFinder < UnionFinder class ProjectsFinder < UnionFinder
include CustomAttributesFilter include CustomAttributesFilter
prepend ::EE::ProjectsFinder
attr_accessor :params attr_accessor :params
attr_reader :current_user, :project_ids_relation attr_reader :current_user, :project_ids_relation
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
# Helper methods for per-User preferences # Helper methods for per-User preferences
module PreferencesHelper module PreferencesHelper
prepend EE::PreferencesHelper
def layout_choices def layout_choices
[ [
['Fixed', :fixed], ['Fixed', :fixed],
......
# frozen_string_literal: true # frozen_string_literal: true
class GlobalPolicy < BasePolicy class GlobalPolicy < BasePolicy
prepend EE::GlobalPolicy
desc "User is blocked" desc "User is blocked"
with_options scope: :user, score: 0 with_options scope: :user, score: 0
condition(:blocked) { @user&.blocked? } condition(:blocked) { @user&.blocked? }
......
...@@ -3011,6 +3011,16 @@ ActiveRecord::Schema.define(version: 20181031190559) do ...@@ -3011,6 +3011,16 @@ ActiveRecord::Schema.define(version: 20181031190559) do
add_index "users", ["username"], name: "index_users_on_username", using: :btree add_index "users", ["username"], name: "index_users_on_username", using: :btree
add_index "users", ["username"], name: "index_users_on_username_trigram", using: :gin, opclasses: {"username"=>"gin_trgm_ops"} add_index "users", ["username"], name: "index_users_on_username_trigram", using: :gin, opclasses: {"username"=>"gin_trgm_ops"}
create_table "users_ops_dashboard_projects", id: :bigserial, force: :cascade do |t|
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.integer "user_id", null: false
t.integer "project_id", null: false
end
add_index "users_ops_dashboard_projects", ["project_id"], name: "index_users_ops_dashboard_projects_on_project_id", using: :btree
add_index "users_ops_dashboard_projects", ["user_id", "project_id"], name: "index_users_ops_dashboard_projects_on_user_id_and_project_id", unique: true, using: :btree
create_table "users_star_projects", force: :cascade do |t| create_table "users_star_projects", force: :cascade do |t|
t.integer "project_id", null: false t.integer "project_id", null: false
t.integer "user_id", null: false t.integer "user_id", null: false
...@@ -3412,6 +3422,8 @@ ActiveRecord::Schema.define(version: 20181031190559) do ...@@ -3412,6 +3422,8 @@ ActiveRecord::Schema.define(version: 20181031190559) do
add_foreign_key "user_statuses", "users", on_delete: :cascade add_foreign_key "user_statuses", "users", on_delete: :cascade
add_foreign_key "user_synced_attributes_metadata", "users", on_delete: :cascade add_foreign_key "user_synced_attributes_metadata", "users", on_delete: :cascade
add_foreign_key "users", "application_setting_terms", column: "accepted_term_id", name: "fk_789cd90b35", on_delete: :cascade add_foreign_key "users", "application_setting_terms", column: "accepted_term_id", name: "fk_789cd90b35", on_delete: :cascade
add_foreign_key "users_ops_dashboard_projects", "projects", on_delete: :cascade
add_foreign_key "users_ops_dashboard_projects", "users", on_delete: :cascade
add_foreign_key "users_star_projects", "projects", name: "fk_22cd27ddfc", on_delete: :cascade add_foreign_key "users_star_projects", "projects", name: "fk_22cd27ddfc", on_delete: :cascade
add_foreign_key "vulnerability_feedback", "ci_pipelines", column: "pipeline_id", on_delete: :nullify add_foreign_key "vulnerability_feedback", "ci_pipelines", column: "pipeline_id", on_delete: :nullify
add_foreign_key "vulnerability_feedback", "issues", on_delete: :nullify add_foreign_key "vulnerability_feedback", "issues", on_delete: :nullify
......
<script>
import { __, n__, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
},
props: {
count: {
type: Number,
required: false,
default: 0,
},
lastAlert: {
type: Object,
required: false,
default: null,
},
alertPath: {
type: String,
required: false,
default: null,
},
},
computed: {
alertClasses() {
return {
'text-success': this.count <= 0,
'text-warning': this.count > 0,
};
},
alertCount() {
return sprintf(__('%{count} %{alerts}'), {
count: this.count,
alerts: this.pluralizedAlerts,
});
},
alertLinkTitle() {
return sprintf(__('View %{alerts}'), { alerts: this.pluralizedAlerts });
},
lastAlertText() {
if (this.count === 0 || this.lastAlert === null) {
return __('None');
}
const ellipsis = this.count > 1 ? '\u2026' : '';
return `${this.lastAlert.operator} ${this.lastAlert.threshold}${ellipsis}`;
},
pluralizedAlerts() {
return n__('Alert', 'Alerts', this.count);
},
},
};
</script>
<template>
<div class="row">
<div
class="col-12 d-flex align-items-center"
>
<icon
:class="alertClasses"
name="warning"
/>
<span
class="js-alert-count text-secondary prepend-left-4"
>
{{ alertCount }}
</span>
</div>
<div class="js-last-alert col-12">
<a
v-if="alertPath"
:href="alertPath"
class="js-alert-link cgray"
>
<span
v-if="lastAlert"
class="str-truncated-60"
>
{{ lastAlert.title }}
</span>
<span>
{{ lastAlertText }}
</span>
</a>
<span v-else>
{{ lastAlertText }}
</span>
</div>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import DashboardProject from './project.vue';
import ProjectSearch from './project_search.vue';
export default {
components: {
DashboardProject,
ProjectSearch,
},
props: {
addPath: {
type: String,
required: true,
},
listPath: {
type: String,
required: true,
},
emptyDashboardSvgPath: {
type: String,
required: true,
},
},
computed: {
...mapState(['projects', 'projectTokens', 'isLoadingProjects']),
addIsDisabled() {
return !this.projectTokens.length;
},
},
created() {
this.setProjectEndpoints({
list: this.listPath,
add: this.addPath,
});
this.fetchProjects();
},
methods: {
...mapActions(['addProjectsToDashboard', 'fetchProjects', 'setProjectEndpoints']),
addProjects() {
if (!this.addIsDisabled) {
this.addProjectsToDashboard();
}
},
},
};
</script>
<template>
<div class="operations-dashboard">
<nav class="breadcrumbs container-fluid container-limited">
<div class="breadcrumbs-container">
<h2 class="js-dashboard-title breadcrumbs-sub-title">
{{ __('Operations Dashboard') }}
</h2>
</div>
</nav>
<div class="container-fluid container-limited prepend-top-default">
<div class="d-flex align-items-center">
<project-search class="flex-grow-1" />
<button
:class="{ disabled: addIsDisabled }"
type="button"
class="js-add-projects-button btn btn-success prepend-left-8"
@click="addProjects"
>
{{ __('Add projects') }}
</button>
</div>
<div
v-if="projects.length"
class="row m-0 prepend-top-default"
>
<div
v-for="project in projects"
:key="project.id"
class="col-12 col-md-6 odds-md-pad-right evens-md-pad-left"
>
<dashboard-project :project="project" />
</div>
</div>
<div
v-else-if="!isLoadingProjects"
class="row prepend-top-20 text-center"
>
<div class="col-12 d-flex justify-content-center svg-content">
<img
:src="emptyDashboardSvgPath"
class="js-empty-state-svg col-12 prepend-top-20"
/>
</div>
<h4 class="js-title col-12 prepend-top-20">
{{ s__('OperationsDashboard|Add a project to the dashboard') }}
</h4>
<div class="col-12 d-flex justify-content-center">
<span class="js-sub-title mw-460 text-tertiary">
{{ s__(`OperationsDashboard|The operations dashboard provides a summary of each project's
operational health, including pipeline and alert status.`) }}
</span>
</div>
</div>
<gl-loading-icon
v-else
:size="2"
class="prepend-top-20"
/>
</div>
</div>
</template>
<script>
import { mapActions } from 'vuex';
import timeago from '~/vue_shared/mixins/timeago';
import Icon from '~/vue_shared/components/icon.vue';
import Commit from '~/vue_shared/components/commit.vue';
import DashboardAlerts from './alerts.vue';
import ProjectHeader from './project_header.vue';
export default {
components: {
Icon,
Commit,
DashboardAlerts,
ProjectHeader,
},
mixins: [timeago],
props: {
project: {
type: Object,
required: true,
},
},
computed: {
author() {
return this.hasDeployment && this.project.last_deployment.user
? {
avatar_url: this.project.last_deployment.user.avatar_url,
path: this.project.last_deployment.user.web_url,
username: this.project.last_deployment.user.username,
}
: null;
},
commitRef() {
return this.hasDeployment && this.project.last_deployment.ref
? {
name: this.project.last_deployment.ref.name,
ref_url: this.project.last_deployment.ref.ref_path,
}
: null;
},
hasDeployment() {
return this.project.last_deployment !== null;
},
lastDeployed() {
return this.hasDeployment ? this.timeFormated(this.project.last_deployment.created_at) : null;
},
},
methods: {
...mapActions(['removeProject']),
},
};
</script>
<template>
<div class="card">
<project-header
:project="project"
class="card-header"
@remove="removeProject"
/>
<div class="card-body">
<div class="row">
<div class="col-6 col-sm-4 col-md-6 col-lg-4 pr-1">
<dashboard-alerts
:count="project.alert_count"
:last-alert="project.last_alert"
:alert-path="project.alert_path"
/>
</div>
<template v-if="project.last_deployment">
<div class="col-6 col-sm-4 col-md-6 col-lg-4 px-1">
<commit
:commit-ref="commitRef"
:short-sha="project.last_deployment.commit.short_id"
:commit-url="project.last_deployment.commit.web_url"
:title="project.last_deployment.commit.title"
:author="author"
:tag="project.last_deployment.commit.tag"
/>
</div>
<div
class="js-project-container col-12 col-sm-4 col-md-12 col-lg-4 pl-1 d-flex justify-content-end"
>
<div
class="d-flex align-items-end justify-content-end"
>
<div class="prepend-top-default text-secondary d-flex align-items-center flex-wrap">
<icon
name="calendar"
class="append-right-4"
/>
{{ lastDeployed }}
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<script>
import Icon from '~/vue_shared/components/icon.vue';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
export default {
components: {
Icon,
ProjectAvatar,
},
props: {
project: {
type: Object,
required: true,
},
},
methods: {
onRemove() {
this.$emit('remove', this.project.remove_path);
},
},
};
</script>
<template>
<div class="project-header d-flex align-items-center">
<project-avatar
:project="project"
:size="20"
class="flex-shrink-0"
/>
<div class="flex-grow-1">
<a
class="js-project-link cgray"
:href="project.web_url"
>
<span class="js-name-with-namespace bold">
{{ project.name_with_namespace }}
</span>
</a>
</div>
<div class="dropdown">
<div
class="d-flex align-items-center ml-2"
data-toggle="dropdown"
>
<icon
name="ellipsis_v"
class="text-secondary"
/>
</div>
<div class="dropdown-menu dropdown-menu-right">
<button
type="button"
class="js-remove-button dropdown-item btn-link text-danger prepend-left-default append-right-default outline-0"
@click="onRemove"
>
{{ __('Remove') }}
</button>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import _ from 'underscore';
import { __, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import TokenizedInput from '../tokenized_input/input.vue';
import inputFocus from '../../mixins';
const inputSearchDelay = 300;
export default {
components: {
Icon,
ProjectAvatar,
TokenizedInput,
},
mixins: [inputFocus],
data() {
return {
hasSearchedInput: false,
};
},
computed: {
...mapState(['inputValue', 'projectTokens', 'projectSearchResults', 'searchCount']),
isSearchingProjects() {
return this.searchCount > 0;
},
searchDescription() {
return sprintf(__('"%{query}" in projects'), { query: this.inputValue });
},
shouldShowSearch() {
return this.inputValue.length && this.isInputFocused;
},
foundNoResults() {
return !this.projectSearchResults.length && this.hasSearchedInput;
},
},
watch: {
inputValue() {
this.queryInputInProjects();
},
},
methods: {
...mapActions(['addProjectToken', 'searchProjects', 'clearProjectSearchResults']),
queryInputInProjects: _.debounce(function search() {
this.searchProjects(this.inputValue);
this.hasSearchedInput = true;
}, inputSearchDelay),
},
};
</script>
<template>
<div
:class="{ show: shouldShowSearch }"
class="dropdown"
>
<tokenized-input
@focus="onFocus"
@blur="onBlur"
/>
<div
class="js-search-results dropdown-menu w-100 mw-100"
@mousedown.prevent
>
<div class="py-2 px-4 text-tertiary">
<icon name="search"/>
{{ searchDescription }}
</div>
<div class="dropdown-divider"></div>
<gl-loading-icon
v-if="isSearchingProjects"
:size="2"
class="py-2 px-4"
/>
<div
v-else-if="foundNoResults"
class="py-2 px-4 text-tertiary"
>
{{ __('Sorry, no projects matched your search') }}
</div>
<button
v-for="project in projectSearchResults"
:key="project.id"
type="button"
class="js-search-result dropdown-item btn-link d-flex align-items-center cgray py-2 px-4"
@mousedown="addProjectToken(project)"
>
<project-avatar
:project="project"
:size="20"
class="flex-shrink-0 mr-3"
/>
<div class="flex-grow-1">
<div class="js-name-with-namespace bold ws-initial">
{{ project.name_with_namespace }}
</div>
</div>
</button>
</div>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import inputFocus from '../../mixins';
export default {
components: {
Icon,
},
mixins: [inputFocus],
computed: {
...mapState(['inputValue', 'projectTokens']),
localInputValue: {
get() {
return this.inputValue;
},
set(newValue) {
this.setInputValue(newValue);
},
},
},
methods: {
...mapActions(['setInputValue', 'removeProjectTokenAt']),
focusInput() {
this.$refs.input.focus();
},
},
};
</script>
<template>
<div
:class="{ focus: isInputFocused }"
class="form-control tokenized-input-wrapper d-flex flex-wrap align-items-center"
@click="focusInput"
>
<div
v-for="(token, index) in projectTokens"
:key="token.id"
class="d-flex"
@click.stop
>
<div class="js-input-token input-token text-secondary py-0 pl-2 pr-1 rounded-left">
{{ token.name_with_namespace }}
</div>
<div
class="js-token-remove tokenized-input-token-remove d-flex align-items-center text-secondary py-0 px-1 rounded-right"
@click="removeProjectTokenAt(index)"
>
<icon name="close"/>
</div>
</div>
<div class="d-flex align-items-center flex-grow-1">
<input
ref="input"
v-model="localInputValue"
:placeholder="__('Search your projects')"
class="tokenized-input flex-grow-1"
type="text"
@focus="onFocus"
@blur="onBlur"
/>
<icon
name="search"
class="text-secondary"
/>
</div>
</div>
</template>
export default {
data() {
return {
isInputFocused: false,
};
},
methods: {
onFocus() {
this.isInputFocused = true;
this.$emit('focus');
},
onBlur() {
this.isInputFocused = false;
this.$emit('blur');
},
},
};
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import { __, s__, n__, sprintf } from '~/locale';
import * as types from './mutation_types';
export const addProjectsToDashboard = ({ state, dispatch }) => {
axios
.post(state.projectEndpoints.add, {
project_ids: state.projectTokens.map(project => project.id),
})
.then(response => dispatch('requestAddProjectsToDashboardSuccess', response.data))
.catch(() => dispatch('requestAddProjectsToDashboardError'));
};
export const clearInputValue = ({ commit }) => {
commit(types.SET_INPUT_VALUE, '');
};
export const clearProjectTokens = ({ commit }) => {
commit(types.SET_PROJECT_TOKENS, []);
};
export const filterProjectTokensById = ({ commit, state }, ids) => {
const tokens = state.projectTokens.filter(token => ids.includes(token.id));
commit(types.SET_PROJECT_TOKENS, tokens);
};
export const requestAddProjectsToDashboardSuccess = ({ dispatch }, data) => {
const { added, invalid } = data;
dispatch('clearInputValue');
if (invalid.length) {
createFlash(s__('OperationsDashboard|Some projects could not be added to dashboard'));
dispatch('filterProjectTokensById', invalid);
} else {
dispatch('clearProjectTokens');
}
if (added.length) {
dispatch('fetchProjects');
}
};
export const requestAddProjectsToDashboardError = ({ state }) => {
createFlash(
sprintf(__('Something went wrong, unable to add %{project} to dashboard'), {
project: n__('project', 'projects', state.projectTokens.length),
}),
);
};
export const addProjectToken = ({ commit }, project) => {
commit(types.ADD_PROJECT_TOKEN, project);
};
export const clearProjectSearchResults = ({ commit }) => {
commit(types.SET_PROJECT_SEARCH_RESULTS, []);
};
export const fetchProjects = ({ state, dispatch }) => {
dispatch('requestProjects');
axios
.get(state.projectEndpoints.list)
.then(response => dispatch('receiveProjectsSuccess', response.data))
.catch(() => dispatch('receiveProjectsError'))
.then(() => dispatch('requestProjects'))
.catch(() => {});
};
export const requestProjects = ({ commit }) => {
commit(types.TOGGLE_IS_LOADING_PROJECTS);
};
export const receiveProjectsSuccess = ({ commit }, data) => {
commit(types.SET_PROJECTS, data.projects);
};
export const receiveProjectsError = ({ commit }) => {
commit(types.SET_PROJECTS, null);
createFlash(__('Something went wrong, unable to get operations projects'));
};
export const removeProject = ({ dispatch }, removePath) => {
axios
.delete(removePath)
.then(() => dispatch('requestRemoveProjectSuccess'))
.catch(() => dispatch('requestRemoveProjectError'));
};
export const requestRemoveProjectSuccess = ({ dispatch }) => {
dispatch('fetchProjects');
};
export const requestRemoveProjectError = () => {
createFlash(__('Something went wrong, unable to remove project'));
};
export const removeProjectTokenAt = ({ commit }, index) => {
commit(types.REMOVE_PROJECT_TOKEN_AT, index);
};
export const searchProjects = ({ commit }, query) => {
commit(types.INCREMENT_PROJECT_SEARCH_COUNT, 1);
Api.projects(query, {})
.then(data => data)
.catch(() => [])
.then(results => {
commit(types.SET_PROJECT_SEARCH_RESULTS, results);
commit(types.DECREMENT_PROJECT_SEARCH_COUNT, 1);
})
.catch(() => {});
};
export const setInputValue = ({ commit }, value) => {
commit(types.SET_INPUT_VALUE, value);
};
export const setProjectEndpoints = ({ commit }, endpoints) => {
commit(types.SET_PROJECT_ENDPOINT_LIST, endpoints.list);
commit(types.SET_PROJECT_ENDPOINT_ADD, endpoints.add);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import state from './state';
import mutations from './mutations';
import * as actions from './actions';
Vue.use(Vuex);
export default new Vuex.Store({
state,
mutations,
actions,
});
export const ADD_PROJECT_TOKEN = 'ADD_PROJECT_TOKEN';
export const INCREMENT_PROJECT_SEARCH_COUNT = 'INCREMENT_PROJECT_SEARCH_COUNT';
export const DECREMENT_PROJECT_SEARCH_COUNT = 'DECREMENT_PROJECT_SEARCH_COUNT';
export const SET_INPUT_VALUE = 'SET_INPUT_VALUE';
export const SET_PROJECT_ENDPOINT_LIST = 'SET_PROJECT_ENDPOINT_LIST';
export const SET_PROJECT_ENDPOINT_ADD = 'SET_PROJECT_ENDPOINT_ADD';
export const SET_PROJECT_SEARCH_RESULTS = 'SET_PROJECT_SEARCH_RESULTS';
export const SET_PROJECTS = 'SET_PROJECTS';
export const SET_PROJECT_TOKENS = 'SET_PROJECT_TOKENS';
export const REMOVE_PROJECT_TOKEN_AT = 'REMOVE_PROJECT_TOKEN_AT';
export const TOGGLE_IS_LOADING_PROJECTS = 'TOGGLE_IS_LOADING_PROJECTS';
import * as types from './mutation_types';
export default {
[types.ADD_PROJECT_TOKEN](state, project) {
const projectsWithMatchingId = state.projectTokens.filter(token => token.id === project.id);
if (!projectsWithMatchingId.length) {
state.projectTokens.push(project);
}
},
[types.DECREMENT_PROJECT_SEARCH_COUNT](state, value) {
state.searchCount -= value;
},
[types.INCREMENT_PROJECT_SEARCH_COUNT](state, value) {
state.searchCount += value;
},
[types.SET_INPUT_VALUE](state, value) {
state.inputValue = value;
},
[types.SET_PROJECT_ENDPOINT_LIST](state, url) {
state.projectEndpoints.list = url;
},
[types.SET_PROJECT_ENDPOINT_ADD](state, url) {
state.projectEndpoints.add = url;
},
[types.SET_PROJECT_SEARCH_RESULTS](state, results) {
state.projectSearchResults = results;
},
[types.SET_PROJECTS](state, projects) {
state.projects = projects || [];
},
[types.SET_PROJECT_TOKENS](state, tokens) {
state.projectTokens = tokens;
},
[types.REMOVE_PROJECT_TOKEN_AT](state, index) {
state.projectTokens.splice(index, 1);
},
[types.TOGGLE_IS_LOADING_PROJECTS](state) {
state.isLoadingProjects = !state.isLoadingProjects;
},
};
export default () => ({
inputValue: '',
isLoadingProjects: false,
projectEndpoints: {
list: null,
add: null,
},
projects: [],
projectTokens: [],
projectSearchResults: [],
searchCount: 0,
});
import Vue from 'vue';
import store from 'ee/operations/store';
import DashboardComponent from 'ee/operations/components/dashboard/dashboard.vue';
document.addEventListener(
'DOMContentLoaded',
() =>
new Vue({
el: '#js-operations',
store,
components: {
DashboardComponent,
},
render(createElement) {
return createElement(DashboardComponent, {
props: {
listPath: this.$el.dataset.listPath,
addPath: this.$el.dataset.addPath,
emptyDashboardSvgPath: this.$el.dataset.emptyDashboardSvgPath,
},
});
},
}),
);
.odds-md-pad-right:nth-child(odd) {
padding: 0;
@include media-breakpoint-up(md) {
padding-right: $gl-padding-8;
}
}
.evens-md-pad-left:nth-child(even) {
padding: 0;
@include media-breakpoint-up(md) {
padding-left: $gl-padding-8;
}
}
.operations-dashboard {
.branch-commit {
* {
vertical-align: middle;
}
.icon-container,
.commit-icon {
display: inline;
color: $gl-text-color-tertiary;
}
.ref-name {
font-weight: $gl-font-weight-bold;
color: $gl-text-color;
}
}
}
.tokenized-input-wrapper {
height: auto;
padding: 2px $gl-padding-8;
&.focus,
&.focus:hover {
border-color: $blue-300;
box-shadow: 0 0 4px $dropdown-input-focus-shadow;
}
.tokenized-input {
width: auto;
border: 0;
margin: $gl-bar-padding 0;
&:focus {
outline: none;
box-shadow: none;
}
}
.input-token {
word-break: break-all;
background-color: $gray-lighter;
margin: $gl-bar-padding 0;
}
.tokenized-input-token-remove {
background-color: $gray-normal;
margin: $gl-bar-padding $gl-padding-4 $gl-bar-padding 0;
}
}
# frozen_string_literal: true
module EE
module RootController
extend ::Gitlab::Utils::Override
override :redirect_logged_user
def redirect_logged_user
case current_user.dashboard
when 'operations'
if current_user.can?(:read_operations_dashboard)
return redirect_to(operations_path)
end
end
super
end
end
end
# frozen_string_literal: true
class OperationsController < ApplicationController
layout 'fullscreen'
before_action :authorize_read_operations_dashboard!
respond_to :json, only: [:list]
def index
end
def list
projects = load_projects(current_user)
render json: { projects: serialize_as_json(projects) }
end
def create
project_ids = params['project_ids']
result = add_projects(current_user, project_ids)
render json: {
added: result.added_project_ids,
duplicate: result.duplicate_project_ids,
invalid: result.invalid_project_ids
}
end
def destroy
project_id = params['project_id']
if remove_project(current_user, project_id)
head :ok
else
head :no_content
end
end
private
def authorize_read_operations_dashboard!
render_404 unless can?(current_user, :read_operations_dashboard)
end
def load_projects(current_user)
Dashboard::Operations::ListService.new(current_user).execute
end
def add_projects(current_user, project_ids)
UsersOpsDashboardProjects::CreateService.new(current_user).execute(project_ids)
end
def remove_project(current_user, project_id)
UsersOpsDashboardProjects::DestroyService.new(current_user).execute(project_id)
end
def serialize_as_json(projects)
DashboardOperationsSerializer.new(current_user: current_user).represent(projects).as_json
end
end
# frozen_string_literal: true
module EE
# ProjectsFinder
#
# Extends ProjectsFinder
#
# Added arguments:
# params:
# plans: string[]
module ProjectsFinder
extend ::Gitlab::Utils::Override
private
override :filter_projects
def filter_projects(collection)
collection = super(collection)
collection = by_plans(collection)
collection
end
def by_plans(collection)
if names = params[:plans].presence
collection.for_plan_name(names)
else
collection
end
end
end
end
# frozen_string_literal: true
module EE
module PreferencesHelper
extend ::Gitlab::Utils::Override
override :excluded_dashboard_choices
def excluded_dashboard_choices
return [] if can?(current_user, :read_operations_dashboard)
super
end
end
end
# frozen_string_literal: true
module OperationsHelper
def operations_data
{
'add-path' => add_operations_project_path,
'list-path' => operations_list_path,
'empty-dashboard-svg-path' => image_path('illustrations/operations-dashboard_empty.svg')
}
end
end
...@@ -74,6 +74,7 @@ module EE ...@@ -74,6 +74,7 @@ module EE
scope :verified_wikis, -> { joins(:repository_state).merge(ProjectRepositoryState.verified_wikis) } scope :verified_wikis, -> { joins(:repository_state).merge(ProjectRepositoryState.verified_wikis) }
scope :verification_failed_repos, -> { joins(:repository_state).merge(ProjectRepositoryState.verification_failed_repos) } scope :verification_failed_repos, -> { joins(:repository_state).merge(ProjectRepositoryState.verification_failed_repos) }
scope :verification_failed_wikis, -> { joins(:repository_state).merge(ProjectRepositoryState.verification_failed_wikis) } scope :verification_failed_wikis, -> { joins(:repository_state).merge(ProjectRepositoryState.verification_failed_wikis) }
scope :for_plan_name, -> (name) { joins(namespace: :plan).where(plans: { name: name }) }
delegate :shared_runners_minutes, :shared_runners_seconds, :shared_runners_seconds_last_reset, delegate :shared_runners_minutes, :shared_runners_seconds, :shared_runners_seconds_last_reset,
to: :statistics, allow_nil: true to: :statistics, allow_nil: true
......
...@@ -35,6 +35,9 @@ module EE ...@@ -35,6 +35,9 @@ module EE
has_many :developer_groups, -> { where(members: { access_level: ::Gitlab::Access::DEVELOPER }) }, through: :group_members, source: :group has_many :developer_groups, -> { where(members: { access_level: ::Gitlab::Access::DEVELOPER }) }, through: :group_members, source: :group
has_many :users_ops_dashboard_projects
has_many :ops_dashboard_projects, through: :users_ops_dashboard_projects, source: :project
# Protected Branch Access # Protected Branch Access
has_many :protected_branch_merge_access_levels, dependent: :destroy, class_name: ::ProtectedBranch::MergeAccessLevel # rubocop:disable Cop/ActiveRecordDependent has_many :protected_branch_merge_access_levels, dependent: :destroy, class_name: ::ProtectedBranch::MergeAccessLevel # rubocop:disable Cop/ActiveRecordDependent
has_many :protected_branch_push_access_levels, dependent: :destroy, class_name: ::ProtectedBranch::PushAccessLevel # rubocop:disable Cop/ActiveRecordDependent has_many :protected_branch_push_access_levels, dependent: :destroy, class_name: ::ProtectedBranch::PushAccessLevel # rubocop:disable Cop/ActiveRecordDependent
...@@ -122,10 +125,10 @@ module EE ...@@ -122,10 +125,10 @@ module EE
def available_custom_project_templates(search: nil) def available_custom_project_templates(search: nil)
templates = ::Gitlab::CurrentSettings.available_custom_project_templates templates = ::Gitlab::CurrentSettings.available_custom_project_templates
ProjectsFinder.new(current_user: self, ::ProjectsFinder.new(current_user: self,
project_ids_relation: templates, project_ids_relation: templates,
params: { search: search, sort: 'name_asc' }) params: { search: search, sort: 'name_asc' })
.execute .execute
end end
def roadmap_layout def roadmap_layout
......
...@@ -86,6 +86,7 @@ class License < ActiveRecord::Base ...@@ -86,6 +86,7 @@ class License < ActiveRecord::Base
pod_logs pod_logs
pseudonymizer pseudonymizer
prometheus_alerts prometheus_alerts
operations_dashboard
].freeze ].freeze
# List all features available for early adopters, # List all features available for early adopters,
......
...@@ -47,6 +47,18 @@ class PrometheusAlertEvent < ActiveRecord::Base ...@@ -47,6 +47,18 @@ class PrometheusAlertEvent < ActiveRecord::Base
scope :firing, -> { where(status: status_value_for(:firing)) } scope :firing, -> { where(status: status_value_for(:firing)) }
scope :resolved, -> { where(status: status_value_for(:resolved)) } scope :resolved, -> { where(status: status_value_for(:resolved)) }
scope :for_environment, -> (environment) do
joins(:prometheus_alert).where(prometheus_alerts: { environment_id: environment })
end
scope :count_by_project_id, -> { group(:project_id).count }
scope :with_prometheus_alert, -> { includes(:prometheus_alert) }
def self.last_by_project_id
ids = select(arel_table[:id].maximum.as('id')).group(:project_id).map(&:id)
with_prometheus_alert.find(ids)
end
def self.find_or_initialize_by_payload_key(project, alert, payload_key) def self.find_or_initialize_by_payload_key(project, alert, payload_key)
find_or_initialize_by(project: project, prometheus_alert: alert, payload_key: payload_key) find_or_initialize_by(project: project, prometheus_alert: alert, payload_key: payload_key)
end end
......
# frozen_string_literal: true
class UsersOpsDashboardProject < ActiveRecord::Base
belongs_to :project
belongs_to :user
validates :user, presence: true
validates :user_id, uniqueness: { scope: [:project_id] }
validates :project, presence: true
end
# frozen_string_literal: true
module EE
module GlobalPolicy
extend ActiveSupport::Concern
prepended do
condition(:operations_dashboard_available) do
License.feature_available?(:operations_dashboard)
end
rule { operations_dashboard_available }.enable :read_operations_dashboard
end
end
end
# frozen_string_literal: true
class DashboardOperationsProjectEntity < Grape::Entity
include RequestAwareEntity
expose :project, merge: true, using: API::Entities::BasicProjectDetails
expose :remove_path do |dashboard_project|
remove_operations_project_path(project_id: dashboard_project.project.id)
end
expose :last_deployment, if: -> (*) { last_deployment? } do |dashboard_project, options|
new_request = EntityRequest.new(
current_user: request.current_user,
project: dashboard_project.project
)
DeploymentEntity.represent(dashboard_project.last_deployment,
options.merge(request: new_request))
end
expose :alert_count
expose :alert_path, if: -> (*) { last_deployment? } do |dashboard_project|
project = dashboard_project.project
environment = dashboard_project.last_deployment.environment
metrics_project_environment_path(project, environment)
end
expose :last_alert, using: PrometheusAlertEntity, if: -> (*) { last_alert? }
private
alias_method :dashboard_project, :object
def last_deployment?
dashboard_project.last_deployment
end
def last_alert?
dashboard_project.last_alert
end
end
# frozen_string_literal: true
class DashboardOperationsSerializer < BaseSerializer
entity DashboardOperationsProjectEntity
end
# frozen_string_literal: true
module Dashboard
module Operations
class ListService
DashboardProject = Struct.new(:project, :last_deployment, :alert_count, :last_alert)
def initialize(user)
@user = user
end
def execute
projects = load_projects(user)
environments = load_environments(projects, 'production')
last_deployments = load_last_deployments(environments)
event_counts, last_firing_events = load_last_firing_events(environments)
collect_data(projects, last_deployments, event_counts, last_firing_events)
end
private
attr_reader :user
def load_projects(user)
projects = user.ops_dashboard_projects
ProjectsService
.new(user)
.execute(projects)
.to_a # 1 query
end
# 1 query
def load_environments(projects, name)
return {} if projects.empty?
Environment
.available
.for_project(projects)
.for_name(name)
.index_by(&:project_id) # 1 query
end
def load_last_deployments(environments)
return {} if environments.empty?
Deployment
.last_for_environment(environments.values) # 2 queries
.index_by(&:project_id)
end
def load_last_firing_events(environments)
return [0, {}] if environments.empty?
events = PrometheusAlertEvent
.firing
.for_environment(environments.values)
event_counts = events.count_by_project_id # 1 query
last_firing_events = events.last_by_project_id.index_by(&:project_id) # 2 queries
[event_counts, last_firing_events]
end
def collect_data(projects, last_deployments, event_counts, last_firing_events)
projects.map do |project|
last_deployment = last_deployments[project.id]
alert_count = event_counts[project.id] || 0
last_alert = last_firing_events[project.id]&.prometheus_alert
DashboardProject.new(project, last_deployment, alert_count, last_alert)
end
end
end
end
end
# frozen_string_literal: true
module Dashboard
module Operations
class ProjectsService
def initialize(user)
@user = user
end
def execute(project_ids)
find_projects(user, project_ids)
end
private
attr_reader :user, :project_ids
def find_projects(user, project_ids)
ProjectsFinder.new(
current_user: user,
project_ids_relation: project_ids,
params: {
plans: plan_names_for_operations_dashboard,
min_access_level: ProjectMember::DEVELOPER
}
).execute
end
def plan_names_for_operations_dashboard
return unless Gitlab::CurrentSettings.should_check_namespace_plan?
Namespace.plans_with_feature(:operations_dashboard)
end
end
end
end
...@@ -37,7 +37,7 @@ module EE ...@@ -37,7 +37,7 @@ module EE
end end
def file_template_project_visible? def file_template_project_visible?
ProjectsFinder.new( ::ProjectsFinder.new(
current_user: current_user, current_user: current_user,
project_ids_relation: [params[:file_template_project_id]] project_ids_relation: [params[:file_template_project_id]]
).execute.exists? ).execute.exists?
......
# frozen_string_literal: true
module UsersOpsDashboardProjects
class BaseService
attr_reader :user
def initialize(user)
@user = user
end
end
end
# frozen_string_literal: true
module UsersOpsDashboardProjects
class CreateService < UsersOpsDashboardProjects::BaseService
Result = Struct.new(:added_project_ids, :invalid_project_ids, :duplicate_project_ids)
def execute(project_ids)
projects_to_add = load_projects(user, project_ids)
invalid = find_invalid_ids(projects_to_add, project_ids)
added, duplicate = add_projects(projects_to_add, user)
Result.new(added.map(&:id), invalid, duplicate.map(&:id))
end
private
def load_projects(current_user, project_ids)
Dashboard::Operations::ProjectsService.new(current_user).execute(project_ids)
end
def find_invalid_ids(projects_to_add, project_ids)
by_string_id = projects_to_add.index_by { |project| project.id.to_s }
project_ids.reject { |id| by_string_id.key?(id.to_s) }
end
def add_projects(projects, user)
projects.partition { |project| add_project(project, user) }
end
def add_project(project, user)
user.ops_dashboard_projects << project
true
rescue ActiveRecord::RecordInvalid
false
end
end
end
# frozen_string_literal: true
module UsersOpsDashboardProjects
class DestroyService < UsersOpsDashboardProjects::BaseService
def execute(project_id)
remove_project(user, project_id)
end
private
def remove_project(user, project_id)
user.ops_dashboard_projects.destroy(project_id).first
rescue ActiveRecord::RecordNotFound
nil
end
end
end
- if can?(current_user, :read_operations_dashboard)
= nav_link(controller: 'operations') do
= link_to operations_path, title: _('Operations'), aria: { label: _('Operations') } do
= sprite_icon('dashboard', size: 18)
- page_title _('Operations')
#js-operations{ data: operations_data }
---
title: Add project operations dashboard
merge_request: 7973
author:
type: added
# frozen_string_literal: true # frozen_string_literal: true
# Placeholder for https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/7341 get 'operations' => 'operations#index'
# Added to resolve https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/8131 get 'operations/list' => 'operations#list'
post 'operations' => 'operations#create', as: :add_operations_project
delete 'operations' => 'operations#destroy', as: :remove_operations_project
# frozen_string_literal: true
class CreateUsersOpsDashboardProjects < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
create_table :users_ops_dashboard_projects, id: :bigserial do |t|
t.timestamps_with_timezone null: false
t.references :user, null: false, foreign_key: { on_delete: :cascade }
t.references :project, index: true, foreign_key: { on_delete: :cascade }, null: false
t.index [:user_id, :project_id], unique: true
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe RootController do
describe 'GET index' do
let(:user) { create(:user) }
before do
stub_licensed_features(operations_dashboard: true)
sign_in(user)
allow(subject).to receive(:current_user).and_return(user)
end
context 'who has customized their dashboard setting for operations' do
before do
user.dashboard = 'operations'
end
it 'redirects to operations dashboard' do
get :index
expect(response).to redirect_to operations_path
end
context 'when unlicensed' do
before do
stub_licensed_features(operations_dashboard: false)
end
it 'renders the default dashboard' do
get :index
expect(response).to render_template 'dashboard/projects/index'
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe OperationsController do
include Rails.application.routes.url_helpers
let(:user) { create(:user) }
let(:json_response) { JSON.parse(response.body) }
shared_examples 'unlicensed' do |http_method, action|
before do
stub_licensed_features(operations_dashboard: false)
end
it 'renders 404' do
public_send(http_method, action)
expect(response).to have_gitlab_http_status(:not_found)
end
end
before do
stub_licensed_features(operations_dashboard: true)
sign_in(user)
end
describe 'GET #index' do
it_behaves_like 'unlicensed', :get, :index
it 'renders index with 200 status code' do
get :index
expect(response).to have_gitlab_http_status(200)
expect(response).to render_template(:index)
end
context 'with an anonymous user' do
before do
sign_out(user)
end
it 'redirects to sign-in page' do
get :index
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'GET #list' do
let(:now) { Time.now.change(usec: 0) }
let(:project) { create(:project, :repository) }
let(:commit) { project.commit }
let!(:environment) { create(:environment, name: 'production', project: project) }
let!(:deployment) { create(:deployment, environment: environment, sha: commit.id, created_at: now) }
it_behaves_like 'unlicensed', :get, :list
shared_examples 'empty project list' do
it 'returns an empty list' do
get :list
expect(response).to have_gitlab_http_status(200)
expect(json_response).to match_schema('dashboard/operations/list', dir: 'ee')
expect(json_response['projects']).to eq([])
end
end
context 'with added projects' do
let(:alert1) { create(:prometheus_alert, project: project, environment: environment) }
let(:alert2) { create(:prometheus_alert, project: project, environment: environment) }
let!(:alert_events) do
[
create(:prometheus_alert_event, prometheus_alert: alert1),
create(:prometheus_alert_event, prometheus_alert: alert2),
create(:prometheus_alert_event, prometheus_alert: alert1),
create(:prometheus_alert_event, :resolved, prometheus_alert: alert2)
]
end
let(:firing_alert_events) { alert_events.select(&:firing?) }
let(:last_firing_alert) { firing_alert_events.last.prometheus_alert }
let(:alert_path) do
metrics_project_environment_path(project, environment)
end
let(:alert_json_path) do
project_prometheus_alert_path(project, last_firing_alert.prometheus_metric_id,
environment_id: environment, format: :json)
end
let(:expected_project) { json_response['projects'].first }
before do
user.update!(ops_dashboard_projects: [project])
project.add_developer(user)
end
it 'returns a list of added projects' do
get :list
expect(response).to have_gitlab_http_status(200)
expect(response).to match_response_schema('dashboard/operations/list', dir: 'ee')
expect(json_response['projects'].size).to eq(1)
expect(expected_project['id']).to eq(project.id)
expect(expected_project['remove_path'])
.to eq(remove_operations_project_path(project_id: project.id))
expect(expected_project['last_deployment']['id']).to eq(deployment.id)
expect(expected_project['alert_count']).to eq(firing_alert_events.size)
expect(expected_project['alert_path']).to eq(alert_path)
expect(expected_project['last_alert']['id']).to eq(last_firing_alert.id)
end
context 'without sufficient access level' do
before do
project.add_reporter(user)
end
it_behaves_like 'empty project list'
end
end
context 'without projects' do
it_behaves_like 'empty project list'
end
context 'with an anonymous user' do
before do
sign_out(user)
end
it 'redirects to sign-in page' do
get :list
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'POST #create' do
it_behaves_like 'unlicensed', :post, :create
context 'without added projects' do
let(:project_a) { create(:project) }
let(:project_b) { create(:project) }
before do
project_a.add_developer(user)
project_b.add_developer(user)
end
it 'adds projects to the dasboard' do
post :create, project_ids: [project_a.id, project_b.id.to_s]
expect(response).to have_gitlab_http_status(200)
expect(json_response).to match_schema('dashboard/operations/add', dir: 'ee')
expect(json_response['added']).to contain_exactly(project_a.id, project_b.id)
expect(json_response['duplicate']).to be_empty
expect(json_response['invalid']).to be_empty
user.reload
expect(user.ops_dashboard_projects).to contain_exactly(project_a, project_b)
end
it 'cannot add a project twice' do
post :create, project_ids: [project_a.id, project_a.id]
expect(response).to have_gitlab_http_status(200)
expect(json_response).to match_schema('dashboard/operations/add', dir: 'ee')
expect(json_response['added']).to contain_exactly(project_a.id)
expect(json_response['duplicate']).to be_empty
expect(json_response['invalid']).to be_empty
user.reload
expect(user.ops_dashboard_projects).to eq([project_a])
end
it 'does not add invalid project ids' do
post :create, project_ids: [nil, -1, '-2']
expect(response).to have_gitlab_http_status(200)
expect(json_response).to match_schema('dashboard/operations/add', dir: 'ee')
expect(json_response['added']).to be_empty
expect(json_response['duplicate']).to be_empty
expect(json_response['invalid']).to contain_exactly(nil, '-1', '-2')
user.reload
expect(user.ops_dashboard_projects).to be_empty
end
end
context 'with added project' do
let(:project) { create(:project) }
before do
user.ops_dashboard_projects << project
project.add_developer(user)
end
it 'does not add already added project' do
post :create, project_ids: [project.id]
expect(response).to have_gitlab_http_status(200)
expect(json_response).to match_schema('dashboard/operations/add', dir: 'ee')
expect(json_response['added']).to be_empty
expect(json_response['duplicate']).to contain_exactly(project.id)
expect(json_response['invalid']).to be_empty
user.reload
expect(user.ops_dashboard_projects).to eq([project])
end
end
context 'with an anonymous user' do
before do
sign_out(user)
end
it 'redirects to sign-in page' do
post :create
expect(response).to redirect_to(new_user_session_path)
end
end
end
describe 'DELETE #destroy' do
it_behaves_like 'unlicensed', :delete, :destroy
context 'with added projects' do
let(:project) { create(:project) }
before do
user.ops_dashboard_projects << project
end
it 'removes a project succesfully' do
delete :destroy, project_id: project.id
expect(response).to have_gitlab_http_status(200)
user.reload
expect(user.ops_dashboard_projects).not_to eq([project])
end
end
context 'without projects' do
it 'cannot remove invalid project' do
delete :destroy, project_id: -1
expect(response).to have_gitlab_http_status(204)
end
end
context 'with an anonymous user' do
before do
sign_out(user)
end
it 'redirects to sign-in page' do
delete :destroy
expect(response).to redirect_to(new_user_session_path)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe ProjectsFinder do
describe '#execute' do
subject { finder.execute }
let(:user) { create(:user) }
describe 'filter by plans' do
let!(:gold_project) { create_project(:gold_plan) }
let!(:gold_project2) { create_project(:gold_plan) }
let!(:silver_project) { create_project(:silver_plan) }
let!(:no_plan_project) { create_project(nil) }
let(:finder) { described_class.new(params: { plans: plans }) }
context 'with gold plan' do
let(:plans) { ['gold'] }
it { is_expected.to contain_exactly(gold_project, gold_project2) }
end
context 'with multiple plans' do
let(:plans) { %w[gold silver] }
it { is_expected.to contain_exactly(gold_project, gold_project2, silver_project) }
end
context 'with other plans' do
let(:plans) { ['bronze'] }
it { is_expected.to be_empty }
end
context 'without plans' do
let(:plans) { nil }
it { is_expected.to contain_exactly(gold_project, gold_project2, silver_project, no_plan_project) }
end
context 'with empty plans' do
let(:plans) { [] }
it { is_expected.to contain_exactly(gold_project, gold_project2, silver_project, no_plan_project) }
end
private
def create_project(plan)
create(:project, :public, namespace: create(:namespace, plan: plan))
end
end
end
end
{
"type": "object",
"required": [
"added",
"duplicate",
"invalid"
],
"properties": {
"added": {
"type": "array",
"items": { "type": "integer" }
},
"duplicate": {
"type": "array",
"items": { "type": "integer" }
},
"invalid": {
"type": "array",
"items": {
"oneOf": [
{ "type": "string" },
{ "type": "null" }
]
}
}
},
"additionalProperties": false
}
{
"type": "object",
"required": [
"projects"
],
"properties": {
"projects": {
"type": "array",
"items": {
"$ref": "#/definitions/project"
}
}
},
"definitions": {
"project": {
"type": "object",
"required": [
"id",
"name",
"name_with_namespace",
"path",
"path_with_namespace",
"avatar_url",
"remove_path",
"alert_count"
],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"name_with_namespace": { "type": "string" },
"path": { "type": "string"},
"path_with_namespace": { "type": "string" },
"avatar_url": { "type": ["string", "null"] },
"remove_path": { "type": "string" },
"last_deployment": { "$ref": "../../../../../../../spec/fixtures/api/schemas/deployment.json" },
"alert_count": { "type": "integer" },
"alert_path": { "type": "string" },
"last_alert" : { "$ref": "#/definitions/alert" }
}
},
"alert": {
"type": "object",
"required": [
"id",
"title",
"query",
"threshold",
"operator",
"alert_path"
],
"properties": {
"id": { "type": "integer" },
"title": { "type": "string" }
}
}
},
"additionalProperties": false
}
# frozen_string_literal: true
require 'spec_helper'
describe OperationsHelper do
include Gitlab::Routing.url_helpers
describe '#operations_data' do
it 'returns frontend configuration' do
expect(operations_data).to eq(
'add-path' => '/-/operations',
'list-path' => '/-/operations/list',
'empty-dashboard-svg-path' => '/images/illustrations/operations-dashboard_empty.svg'
)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe PreferencesHelper do
describe '#dashboard_choices' do
let(:user) { build(:user) }
before do
allow(helper).to receive(:current_user).and_return(user)
end
context 'when allowed to read operations dashboard' do
before do
allow(helper).to receive(:can?).with(user, :read_operations_dashboard) { true }
end
it 'does not contain operations dashboard' do
expect(helper.dashboard_choices).to include(['Operations Dashboard', 'operations'])
end
end
context 'when not allowed to read operations dashboard' do
before do
allow(helper).to receive(:can?).with(user, :read_operations_dashboard) { false }
end
it 'does not contain operations dashboard' do
expect(helper.dashboard_choices).not_to include(['Operations Dashboard', 'operations'])
end
end
end
end
import Vue from 'vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import Alerts from 'ee/operations/components/dashboard/alerts.vue';
import Icon from '~/vue_shared/components/icon.vue';
import { removeWhitespace } from 'spec/helpers/vue_component_helper';
import { getChildInstances } from '../../helpers';
import { mockOneProject } from '../../mock_data';
describe('alerts component', () => {
const AlertsComponent = Vue.extend(Alerts);
const IconComponent = Vue.extend(Icon);
const mockPath = 'https://mock-alert_path/';
const mount = (props = {}) => mountComponentWithStore(AlertsComponent, { props });
let vm;
beforeEach(() => {
vm = mount();
});
afterEach(() => {
if (vm.$destroy) {
vm.$destroy();
}
});
it('renders multiple alert count when multiple alerts are present', () => {
vm = mount({ count: 2 });
expect(vm.$el.querySelector('.js-alert-count').innerText.trim()).toBe('2 Alerts');
});
it('renders count for one alert when there is one alert', () => {
vm = mount({ count: 1 });
expect(vm.$el.querySelector('.js-alert-count').innerText.trim()).toBe('1 Alert');
});
it('renders last alert when one has fired', () => {
const mockAlert = mockOneProject.last_alert;
const alertMessage = `${mockAlert.title} ${mockAlert.operator} ${mockAlert.threshold}`;
vm = mount({
count: 1,
alertPath: mockPath,
lastAlert: mockAlert,
});
const lastAlert = vm.$el.querySelector('.js-last-alert');
const innerText = removeWhitespace(lastAlert.innerText).trim();
expect(innerText).toBe(alertMessage);
});
it('links last alert to metrics page', () => {
vm = mount({ alertPath: mockPath });
expect(vm.$el.querySelector('.js-alert-link').href).toBe(mockPath);
});
it('does not render last alert message when it has not fired', () => {
vm = mount({ alertPath: mockPath });
const lastAlert = vm.$el.querySelector('.js-last-alert');
expect(lastAlert.innerText.trim()).toBe('None');
});
describe('wrapped components', () => {
describe('icon', () => {
it('renders warning', () => {
const icons = getChildInstances(vm, IconComponent);
expect(icons.length).toBe(1);
const [icon] = icons;
expect(icon.name).toBe('warning');
});
});
});
});
import Vue from 'vue';
import store from 'ee/operations/store/index';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import Dashboard from 'ee/operations/components/dashboard/dashboard.vue';
import ProjectSearch from 'ee/operations/components/dashboard/project_search.vue';
import DashboardProject from 'ee/operations/components/dashboard/project.vue';
import { getChildInstances, clearState } from '../../helpers';
import { mockProjectData, mockText } from '../../mock_data';
describe('dashboard component', () => {
const DashboardComponent = Vue.extend(Dashboard);
const ProjectSearchComponent = Vue.extend(ProjectSearch);
const DashboardProjectComponent = Vue.extend(DashboardProject);
const projectTokens = mockProjectData(1);
const mount = () =>
mountComponentWithStore(DashboardComponent, {
store,
props: {
addPath: 'mock-addPath',
listPath: 'mock-listPath',
emptyDashboardSvgPath: '/assets/illustrations/operations-dashboard_empty.svg',
},
});
let vm;
beforeEach(() => {
vm = mount();
});
afterEach(() => {
vm.$destroy();
clearState(store);
});
it('renders dashboard title', () => {
expect(vm.$el.querySelector('.js-dashboard-title').innerText.trim()).toBe(
mockText.DASHBOARD_TITLE,
);
});
describe('add projects button', () => {
let button;
beforeEach(() => {
button = vm.$el.querySelector('.js-add-projects-button');
});
it('renders add projects text', () => {
expect(button.innerText.trim()).toBe(mockText.ADD_PROJECTS);
});
it('calls action to add projects on click if projectTokens have been added', () => {
const spy = spyOn(vm, 'addProjectsToDashboard');
vm.$store.state.projectTokens = projectTokens;
button.click();
expect(spy).toHaveBeenCalled();
});
it('does not call action to add projects on click when projectTokens is empty', () => {
const spy = spyOn(vm, 'addProjectsToDashboard');
button.click();
expect(spy).not.toHaveBeenCalled();
});
});
describe('wrapped components', () => {
describe('project search component', () => {
it('renders project search component', () => {
expect(getChildInstances(vm, ProjectSearchComponent).length).toBe(1);
});
});
describe('dashboard project component', () => {
const projectCount = 1;
const projects = mockProjectData(projectCount);
beforeEach(() => {
store.state.projects = projects;
vm = mount();
});
it('includes a dashboard project component for each project', () => {
expect(getChildInstances(vm, DashboardProjectComponent).length).toBe(projectCount);
});
it('passes each project to the dashboard project component', () => {
const [oneProject] = projects;
const [projectComponent] = getChildInstances(vm, DashboardProjectComponent);
expect(projectComponent.project).toEqual(oneProject);
});
});
describe('empty state', () => {
beforeAll(done => {
vm.$store
.dispatch('requestProjects')
.then(() => vm.$nextTick(done))
.catch(done.fail);
});
it('renders empty state svg after requesting projects with no results', () => {
const svgSrc = vm.$el
.querySelector('.js-empty-state-svg')
.src.slice(-mockText.EMPTY_SVG_SOURCE.length);
expect(svgSrc).toBe(mockText.EMPTY_SVG_SOURCE);
});
it('renders title', () => {
expect(vm.$el.querySelector('.js-title').innerText.trim()).toBe(mockText.EMPTY_TITLE);
});
it('renders sub-title', () => {
expect(vm.$el.querySelector('.js-sub-title').innerText.trim()).toBe(
mockText.EMPTY_SUBTITLE,
);
});
});
});
});
import Vue from 'vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import ProjectHeader from 'ee/operations/components/dashboard/project_header.vue';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import { removeWhitespace } from 'spec/helpers/vue_component_helper';
import { getChildInstances } from '../../helpers';
import { mockOneProject, mockText } from '../../mock_data';
describe('project header component', () => {
const ProjectHeaderComponent = Vue.extend(ProjectHeader);
const ProjectAvatarComponent = Vue.extend(ProjectAvatar);
let vm;
beforeEach(() => {
vm = mountComponentWithStore(ProjectHeaderComponent, {
props: {
project: mockOneProject,
},
});
});
afterEach(() => {
vm.$destroy();
});
it('renders project name with namespace', () => {
const name = vm.$el.querySelector('.js-name-with-namespace').innerText;
expect(removeWhitespace(name).trim()).toBe(mockOneProject.name_with_namespace);
});
it('links project name to project', () => {
const path = mockOneProject.web_url;
expect(vm.$el.querySelector('.js-project-link').href).toBe(path);
});
describe('wrapped components', () => {
describe('project avatar', () => {
it('renders', () => {
expect(getChildInstances(vm, ProjectAvatarComponent).length).toBe(1);
});
it('binds project', () => {
const [avatar] = getChildInstances(vm, ProjectAvatarComponent);
expect(avatar.project).toEqual(vm.project);
});
});
});
describe('dropdown menu', () => {
it('renders removal button', () => {
expect(vm.$el.querySelector('.js-remove-button').innerText.trim()).toBe(
mockText.REMOVE_PROJECT,
);
});
it('emits project removal link on click', () => {
const spy = spyOn(vm, '$emit');
vm.$el.querySelector('.js-remove-button').click();
expect(spy).toHaveBeenCalledWith('remove', mockOneProject.remove_path);
});
});
});
import Vue from 'vue';
import store from 'ee/operations/store/index';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { GlLoadingIcon } from '@gitlab-org/gitlab-ui';
import Icon from '~/vue_shared/components/icon.vue';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import ProjectSearch from 'ee/operations/components/dashboard/project_search.vue';
import TokenizedInput from 'ee/operations/components/tokenized_input/input.vue';
import { mockText, mockProjectData } from '../../mock_data';
import { getChildInstances, mouseEvent, clearState } from '../../helpers';
describe('project search component', () => {
const ProjectSearchComponent = Vue.extend(ProjectSearch);
const IconComponent = Vue.extend(Icon);
const GlLoadingIconComponent = Vue.extend(GlLoadingIcon);
const TokenizedInputComponent = Vue.extend(TokenizedInput);
const ProjectAvatarComponent = Vue.extend(ProjectAvatar);
const mockProjects = mockProjectData(1);
const [mockOneProject] = mockProjects;
const mockInputValue = 'mock-inputValue';
const mount = () => mountComponentWithStore(ProjectSearchComponent, { store });
let vm;
beforeEach(() => {
vm = mount();
});
afterEach(() => {
vm.$destroy();
clearState(store);
});
describe('dropdown menu', () => {
it('renders dropdown menu when input gains focus', done => {
vm.$store.dispatch('setInputValue', mockInputValue);
vm.isInputFocused = true;
vm.$nextTick(() => {
expect(vm.$el.classList.contains('show')).toBe(true);
expect(vm.$el.querySelector('.js-search-results')).not.toBeNull();
done();
});
});
it('does not render when input is not focused', () => {
vm.$store.dispatch('setInputValue', mockInputValue);
expect(vm.$el.classList.contains('show')).toBe(false);
});
it('does not render when input value is empty', () => {
vm.isInputFocused = true;
expect(vm.$el.classList.contains('show')).toBe(false);
});
it('renders search icon', () => {
const icons = getChildInstances(vm, IconComponent);
expect(icons.length).toBe(1);
const [searchIcon] = icons;
expect(searchIcon.name).toBe('search');
});
it('renders search description', () => {
store.state.inputValue = mockInputValue;
vm = mountComponentWithStore(ProjectSearchComponent, { store });
expect(vm.$el.querySelector('.js-search-results').innerText.trim()).toBe(
`"${mockInputValue}" ${mockText.SEARCH_DESCRIPTION_SUFFIX}`,
);
});
it('renders no search results after searching input with no matches', done => {
vm.hasSearchedInput = true;
vm.$nextTick(() => {
expect(
vm.$el
.querySelector('.js-search-results')
.innerText.trim()
.slice(-mockText.NO_SEARCH_RESULTS.length),
).toBe(mockText.NO_SEARCH_RESULTS);
done();
});
});
it('renders loading icon when searching', () => {
store.state.searchCount = 1;
vm = mount();
expect(getChildInstances(vm, GlLoadingIconComponent).length).toBe(1);
});
it('renders search results', () => {
store.state.projectSearchResults = mockProjects;
vm = mount();
expect(vm.$el.getElementsByClassName('js-search-result').length).toBe(mockProjects.length);
});
});
it('searches projects when input value changes', done => {
const spy = spyOn(vm, 'queryInputInProjects');
vm.$store.dispatch('setInputValue', mockInputValue);
vm.$nextTick(() => {
expect(spy).toHaveBeenCalled();
done();
});
});
describe('project search item', () => {
let item;
beforeEach(() => {
store.state.projectSearchResults = mockProjects;
vm = mount();
item = vm.$el.querySelector('.js-search-result');
});
it('renders project name with namespace', () => {
expect(item.querySelector('.js-name-with-namespace').innerText.trim()).toBe(
mockOneProject.name_with_namespace,
);
});
it('calls action to add project token on mousedown', done => {
const spy = spyOn(vm.$store, 'dispatch');
mouseEvent(item, 'mousedown');
vm.$nextTick(() => {
expect(spy).toHaveBeenCalledWith('addProjectToken', mockOneProject);
done();
});
});
});
describe('wrapped components', () => {
describe('tokenized input', () => {
const getInput = parent => getChildInstances(parent, TokenizedInputComponent)[0];
it('renders', () => {
expect(getChildInstances(vm, TokenizedInputComponent).length).toBe(1);
});
it('handles focus', () => {
getInput(vm).$emit('focus');
expect(vm.isInputFocused).toBe(true);
});
it('handles blur', () => {
getInput(vm).$emit('blur');
expect(vm.isInputFocused).toBe(false);
});
});
describe('project avatar', () => {
let avatars;
beforeEach(() => {
store.state.projectSearchResults = mockProjects;
vm = mount();
avatars = getChildInstances(vm, ProjectAvatarComponent);
});
it('renders project avatar component', () => {
expect(avatars.length).toBe(1);
});
it('binds project to project', () => {
const [avatar] = avatars;
expect(avatar.project).toEqual(mockOneProject);
});
});
});
});
import Vue from 'vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import Icon from '~/vue_shared/components/icon.vue';
import Commit from '~/vue_shared/components/commit.vue';
import Project from 'ee/operations/components/dashboard/project.vue';
import ProjectHeader from 'ee/operations/components/dashboard/project_header.vue';
import Alerts from 'ee/operations/components/dashboard/alerts.vue';
import { getChildInstances } from '../../helpers';
import { mockOneProject } from '../../mock_data';
describe('project component', () => {
const ProjectComponent = Vue.extend(Project);
const ProjectHeaderComponent = Vue.extend(ProjectHeader);
const AlertsComponent = Vue.extend(Alerts);
const CommitComponent = Vue.extend(Commit);
const IconComponent = Vue.extend(Icon);
let vm;
beforeEach(() => {
vm = mountComponentWithStore(ProjectComponent, {
props: {
project: mockOneProject,
},
});
});
afterEach(() => {
vm.$destroy();
});
describe('wrapped components', () => {
describe('project header', () => {
it('binds project', () => {
const [header] = getChildInstances(vm, ProjectHeaderComponent);
expect(header.project).toEqual(mockOneProject);
});
});
describe('alerts', () => {
let alert;
beforeEach(() => {
[alert] = getChildInstances(vm, AlertsComponent);
});
it('binds alert count to count', () => {
expect(alert.count).toBe(mockOneProject.alert_count);
});
it('binds last alert', () => {
expect(alert.lastAlert).toEqual(mockOneProject.last_alert);
});
});
describe('commit', () => {
let commits;
let commit;
beforeEach(() => {
commits = getChildInstances(vm, CommitComponent);
[commit] = commits;
});
it('renders', () => {
expect(commits.length).toBe(1);
});
it('binds commitRef', () => {
expect(commit.commitRef).toBe(vm.commitRef);
});
it('binds short_id to shortSha', () => {
expect(commit.shortSha).toBe(vm.project.last_deployment.commit.short_id);
});
it('binds web_url to commitUrl', () => {
expect(commit.commitUrl).toBe(vm.project.last_deployment.commit.web_url);
});
it('binds title', () => {
expect(commit.title).toBe(vm.project.last_deployment.commit.title);
});
it('binds author', () => {
expect(commit.author).toBe(vm.author);
});
it('binds tag', () => {
expect(commit.tag).toBe(vm.project.last_deployment.commit.tag);
});
});
describe('last deploy', () => {
it('renders calendar icon', () => {
const icons = getChildInstances(vm, IconComponent);
expect(icons.length).toBe(1);
const [icon] = icons;
expect(icon.name).toBe('calendar');
});
it('renders time ago of last deploy', () => {
const timeago = '1 day ago';
const container = vm.$el.querySelector('.js-project-container');
expect(container.innerText.trim()).toBe(timeago);
});
});
});
});
import Vue from 'vue';
import store from 'ee/operations/store/index';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import Icon from '~/vue_shared/components/icon.vue';
import TokenizedInput from 'ee/operations/components/tokenized_input/input.vue';
import { getChildInstances, clearState } from '../../helpers';
import { mockProjectData } from '../../mock_data';
describe('tokenized input component', () => {
const TokenizedInputComponent = Vue.extend(TokenizedInput);
const IconComponent = Vue.extend(Icon);
const mockProjects = mockProjectData(1);
const [mockOneProject] = mockProjects;
const mockInputValue = 'mock-inputValue';
let vm;
const getInput = () => vm.$refs.input;
beforeEach(() => {
store.state.projectTokens = mockProjects;
vm = mountComponentWithStore(TokenizedInputComponent, { store });
});
afterEach(() => {
vm.$destroy();
clearState(store);
});
it('focuses input on click', () => {
const spy = spyOn(getInput(), 'focus');
vm.$el.click();
expect(spy).toHaveBeenCalled();
});
it('renders input token', () => {
expect(vm.$el.querySelector('.js-input-token').innerText.trim()).toBe(
mockOneProject.name_with_namespace,
);
});
it('removes input tokens on click', () => {
const spy = spyOn(vm.$store, 'dispatch');
vm.$el.querySelector('.js-token-remove').click();
expect(spy).toHaveBeenCalledWith('removeProjectTokenAt', mockOneProject.id);
});
describe('input', () => {
it('updates input value when local value changes', done => {
vm.localInputValue = mockInputValue;
vm.$nextTick(() => {
expect(getInput().value).toBe(mockInputValue);
done();
});
});
it('handles focus', () => {
const spy = spyOn(vm, '$emit');
vm.onFocus();
expect(spy).toHaveBeenCalledWith('focus');
});
it('handles blur', () => {
const spy = spyOn(vm, '$emit');
vm.onBlur();
expect(spy).toHaveBeenCalledWith('blur');
});
});
describe('wrapped components', () => {
describe('icon', () => {
it('should render close for input tokens', () => {
expect(
getChildInstances(vm, IconComponent).filter(icon => icon.name === 'close').length,
).toBe(mockProjects.length);
});
it('should render search', () => {
const search = getChildInstances(vm, IconComponent)[1];
expect(search.name).toBe('search');
});
});
});
});
import state from 'ee/operations/store/state';
export function clearState(store) {
store.replaceState(state());
}
export function getChildInstances(vm, WrappedComponent) {
return vm.$children.filter(child => child instanceof WrappedComponent);
}
export function mouseEvent(el, eventType) {
const event = document.createEvent('MouseEvent');
event.initMouseEvent(eventType);
el.dispatchEvent(event);
}
export const mockText = {
ADD_PROJECTS: 'Add projects',
ADD_PROJECTS_ERROR: 'Something went wrong, unable to add projects to dashboard',
ADD_PROJECTS_DUPLICATE_ERROR: 'Some projects could not be added to dashboard',
REMOVE_PROJECT_ERROR: 'Something went wrong, unable to remove project',
DASHBOARD_TITLE: 'Operations Dashboard',
EMPTY_TITLE: 'Add a project to the dashboard',
EMPTY_SUBTITLE:
"The operations dashboard provides a summary of each project's operational health, including pipeline and alert status.",
EMPTY_SVG_SOURCE: '/assets/illustrations/operations-dashboard_empty.svg',
NO_SEARCH_RESULTS: 'Sorry, no projects matched your search',
RECEIVE_PROJECTS_ERROR: 'Something went wrong, unable to get operations projects',
REMOVE_PROJECT: 'Remove',
SEARCH_PROJECTS: 'Search your projects',
SEARCH_DESCRIPTION_SUFFIX: 'in projects',
};
export function mockProjectData(
projectCount = 1,
deployTimeStamp = `${new Date(Date.now() - 86400000).getTime()}`,
alertCount = 1,
isTag = false,
) {
return Array(projectCount)
.fill(null)
.map((_, index) => ({
id: index,
name: 'mock-name',
name_with_namespace: 'mock-namespace / mock-name',
path: 'mock-path',
path_with_namespace: 'mock-path_with-namespace',
avatar_url: null,
last_deployment: {
created_at: deployTimeStamp,
commit: {
short_id: 'mock-short_id',
tag: isTag,
title: 'mock-title',
web_url: 'https://mock-web_url/',
},
user: {
avatar_url: null,
path: 'mock-path',
username: 'mock-username',
web_url: 'https://mock-web_url/',
},
ref: {
name: 'mock-name',
ref_path: 'mock-ref_path',
web_url: 'https://mock-web_url/',
},
},
alert_count: alertCount,
alert_path: 'mock-alert_path',
last_alert: {
id: index,
title: 'mock-title',
threshold: 2,
operator: 'mock-operator',
alert_path: 'mock-alert_path',
},
remove_path: 'mock-remove_path',
web_url: 'https://mock-web_url/',
}));
}
export const [mockOneProject] = mockProjectData(1);
This diff is collapsed.
import state from 'ee/operations/store/state';
import mutations from 'ee/operations/store/mutations';
import * as types from 'ee/operations/store/mutation_types';
import { mockProjectData } from '../mock_data';
describe('mutations', () => {
const projects = mockProjectData(1);
const [oneProject] = projects;
const mockEndpoint = 'https://mock-endpoint';
const mockSearches = new Array(5).fill(null);
let localState;
beforeEach(() => {
localState = state();
});
describe('ADD_PROJECT_TOKEN', () => {
it('adds project token to projectTokens', () => {
mutations[types.ADD_PROJECT_TOKEN](localState, oneProject);
expect(localState.projectTokens[0]).toEqual(oneProject);
});
});
describe('INCREMENT_PROJECT_SEARCH_COUNT', () => {
it('adds search to searchCount', () => {
mockSearches.forEach(() => {
mutations[types.INCREMENT_PROJECT_SEARCH_COUNT](localState, 1);
});
expect(localState.searchCount).toBe(mockSearches.length);
});
});
describe('DECREMENT_PROJECT_SEARCH_COUNT', () => {
it('removes search from searchCount', () => {
localState.searchCount = mockSearches.length + 2;
mockSearches.forEach(() => {
mutations[types.DECREMENT_PROJECT_SEARCH_COUNT](localState, 1);
});
expect(localState.searchCount).toBe(2);
});
});
describe('SET_PROJECT_ENDPOINT_LIST', () => {
it('sets project list endpoint', () => {
mutations[types.SET_PROJECT_ENDPOINT_LIST](localState, mockEndpoint);
expect(localState.projectEndpoints.list).toBe(mockEndpoint);
});
});
describe('SET_PROJECT_ENDPOINT_ADD', () => {
it('sets project add endpoint', () => {
mutations[types.SET_PROJECT_ENDPOINT_ADD](localState, mockEndpoint);
expect(localState.projectEndpoints.add).toBe(mockEndpoint);
});
});
describe('SET_PROJECT_SEARCH_RESULTS', () => {
it('sets project search results', () => {
mutations[types.SET_PROJECT_SEARCH_RESULTS](localState, projects);
expect(localState.projectSearchResults).toEqual(projects);
});
});
describe('SET_PROJECTS', () => {
it('sets projects', () => {
mutations[types.SET_PROJECTS](localState, projects);
expect(localState.projects).toEqual(projects);
});
});
describe('REMOVE_PROJECT_TOKEN_AT', () => {
it('removes project token', () => {
localState.projectTokens = projects;
mutations[types.REMOVE_PROJECT_TOKEN_AT](localState, oneProject.id);
expect(localState.projectTokens.length).toBe(0);
});
});
});
# frozen_string_literal: true
require 'spec_helper'
describe GlobalPolicy do
include ExternalAuthorizationServiceHelpers
let(:current_user) { create(:user) }
let(:user) { create(:user) }
subject { described_class.new(current_user, [user]) }
describe 'reading operations dashboard' do
before do
stub_licensed_features(operations_dashboard: true)
end
it { is_expected.to be_allowed(:read_operations_dashboard) }
context 'when unlicensed' do
before do
stub_licensed_features(operations_dashboard: false)
end
it { is_expected.not_to be_allowed(:read_operations_dashboard) }
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Dashboard::Operations::ListService do
let(:subject) { described_class.new(user).execute }
let(:dashboard_project) { subject.first }
let!(:project) { create(:project, :repository) }
let!(:user) { create(:user) }
describe '#execute' do
shared_examples 'no projects' do
it 'returns an empty list' do
expect(subject).to be_empty
end
it 'ensures only a single query' do
queries = ActiveRecord::QueryRecorder.new { subject }.count
expect(queries).to eq(1)
end
end
shared_examples 'no deployment information' do
it 'has no information' do
expect(dashboard_project.last_deployment).to be_nil
expect(dashboard_project.alert_count).to eq(0)
expect(dashboard_project.last_alert).to be_nil
end
end
shared_examples 'avoiding N+1 queries' do
it 'ensures a fixed amount of queries' do
queries = ActiveRecord::QueryRecorder.new { subject }.count
expect(queries).to eq(7)
end
end
context 'with added projects' do
let(:production) { create(:environment, project: project, name: 'production') }
let(:staging) { create(:environment, project: project, name: 'staging') }
let(:production_deployment) do
create(:deployment, project: project, environment: production, ref: 'master')
end
let(:staging_deployment) do
create(:deployment, project: project, environment: staging, ref: 'wip')
end
before do
user.ops_dashboard_projects << project
project.add_developer(user)
end
it 'returns a list of projects' do
expect(subject.size).to eq(1)
end
it 'has some project information' do
expect(dashboard_project.project).to eq(project)
end
it_behaves_like 'no deployment information'
context 'with `production` deployment' do
before do
staging_deployment
production_deployment
end
it 'provides information about the `production` deployment' do
last_deployment = dashboard_project.last_deployment
expect(last_deployment.ref).to eq(production_deployment.ref)
end
context 'with alerts' do
let(:alert_prd1) { create(:prometheus_alert, project: project, environment: production) }
let(:alert_prd2) { create(:prometheus_alert, project: project, environment: production) }
let(:alert_stg) { create(:prometheus_alert, project: project, environment: staging) }
let!(:alert_events) do
[
create(:prometheus_alert_event, prometheus_alert: alert_prd1),
create(:prometheus_alert_event, prometheus_alert: alert_prd2),
last_firing_event,
create(:prometheus_alert_event, prometheus_alert: alert_stg),
create(:prometheus_alert_event, :resolved, prometheus_alert: alert_prd2)
]
end
let(:last_firing_event) { create(:prometheus_alert_event, prometheus_alert: alert_prd1) }
it_behaves_like 'avoiding N+1 queries'
it 'provides information about alerts' do
expect(dashboard_project.alert_count).to eq(3)
expect(dashboard_project.last_alert).to eq(last_firing_event.prometheus_alert)
end
context 'with more projects' do
before do
project2 = create(:project)
production2 = create(:environment, name: 'production', project: project2)
alert2_prd = create(:prometheus_alert, project: project2, environment: production2)
create(:prometheus_alert_event, prometheus_alert: alert2_prd)
project2.add_developer(user)
user.ops_dashboard_projects << project2
end
it_behaves_like 'avoiding N+1 queries'
end
end
describe 'checking plans' do
using RSpec::Parameterized::TableSyntax
where(:check_namespace_plan, :plan, :available) do
true | :gold_plan | true
true | :silver_plan | false
true | nil | false
false | :gold_plan | true
false | :silver_plan | true
false | nil | true
end
with_them do
before do
stub_application_setting(check_namespace_plan: check_namespace_plan)
project.namespace.update!(plan: create(plan)) if plan
end
if params[:available]
it 'returns this project' do
expect(subject.size).to eq(1)
expect(dashboard_project.project).to eq(project)
end
else
it 'does not return this project' do
expect(subject).to be_empty
end
end
end
end
end
context 'without any `production` deployments' do
before do
staging_deployment
end
it_behaves_like 'no deployment information'
end
context 'without deployments' do
it_behaves_like 'no deployment information'
end
context 'without sufficient access level' do
before do
project.add_reporter(user)
end
it_behaves_like 'no projects'
end
end
context 'without added projects' do
it_behaves_like 'no projects'
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Dashboard::Operations::ProjectsService do
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:service) { described_class.new(user) }
describe '#execute' do
before do
project.add_developer(user)
end
it 'returns the project when passing a project id' do
projects = service.execute([project.id])
expect(projects).to contain_exactly(project)
end
it 'returns the project when passing a project record' do
projects = service.execute([project])
expect(projects).to contain_exactly(project)
end
describe 'with plans' do
let!(:gold_project) { create(:project, namespace: create(:namespace, plan: :gold_plan)) }
let!(:silver_project) { create(:project, namespace: create(:namespace, plan: :silver_plan)) }
let!(:no_plan_project) { create(:project, namespace: create(:namespace)) }
let(:projects) { service.execute([gold_project, silver_project, no_plan_project]) }
before do
gold_project.add_developer(user)
silver_project.add_developer(user)
no_plan_project.add_developer(user)
end
context 'when namespace plan check is enabled' do
before do
stub_application_setting(check_namespace_plan: true)
end
it 'returns the gold project' do
expect(projects).to contain_exactly(gold_project)
end
end
context 'when namespace plan check is disabled' do
before do
stub_application_setting(check_namespace_plan: false)
end
it 'returns all projects' do
expect(projects).to contain_exactly(gold_project, silver_project, no_plan_project)
end
end
end
context 'with insufficient access' do
before do
project.add_reporter(user)
end
it 'returns an empty list' do
projects = service.execute([project.id])
expect(projects).to be_empty
end
end
it 'does not find by invalid project id' do
projects = service.execute([-1])
expect(projects).to be_empty
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe UsersOpsDashboardProjects::CreateService do
let(:user) { create(:user) }
let(:service) { described_class.new(user) }
let(:project) { create(:project, :private) }
describe '#execute' do
context 'with at least developer access level' do
before do
project.add_developer(user)
end
it 'adds a project' do
result = service.execute([project.id])
expect(result).to eq(expected_result(added_project_ids: [project.id]))
end
it 'adds a project with a string id' do
result = service.execute([project.id.to_s])
expect(result).to eq(expected_result(added_project_ids: [project.id]))
end
it 'adds a project only once' do
result = service.execute([project.id, project.id])
expect(result).to eq(expected_result(added_project_ids: [project.id]))
end
context 'with already added project' do
before do
user.ops_dashboard_projects << project
end
it 'does not add duplicates' do
result = service.execute([project.id])
expect(result).to eq(expected_result(duplicate_project_ids: [project.id]))
end
end
context 'checking plans' do
using RSpec::Parameterized::TableSyntax
where(:check_namespace_plan, :plan, :can_add) do
true | :gold_plan | true
true | :silver_plan | false
true | nil | false
false | :gold_plan | true
false | :silver_plan | true
false | nil | true
end
with_them do
before do
stub_application_setting(check_namespace_plan: check_namespace_plan)
project.namespace.update!(plan: create(plan)) if plan
end
subject { service.execute([project.id]) }
if params[:can_add]
it 'adds a project' do
expect(subject).to eq(expected_result(added_project_ids: [project.id]))
end
else
it 'is not allowed to add a project' do
expect(subject).to eq(expected_result(invalid_project_ids: [project.id]))
end
end
end
end
end
context 'with access level lower than developer' do
before do
project.add_reporter(user)
end
it 'does not add a project' do
result = service.execute([project.id])
expect(result).to eq(expected_result(invalid_project_ids: [project.id]))
end
end
context 'with invalid project ids' do
let(:invalid_ids) { [nil, -1, '-1', :symbol] }
it 'does not add invalid project ids' do
result = service.execute(invalid_ids)
expect(result).to eq(expected_result(invalid_project_ids: invalid_ids))
end
end
end
private
def expected_result(
added_project_ids: [],
invalid_project_ids: [],
duplicate_project_ids: []
)
UsersOpsDashboardProjects::CreateService::Result.new(
added_project_ids, invalid_project_ids, duplicate_project_ids
)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe UsersOpsDashboardProjects::DestroyService do
let(:user) { create(:user) }
let(:service) { described_class.new(user) }
let(:project) { create(:project, :private) }
describe '#execute' do
context 'with an added project' do
before do
user.ops_dashboard_projects << project
end
it 'removes the project' do
expect { service.execute(project.id) }.to change { UsersOpsDashboardProject.count }.to(0)
end
it 'returns the removed project' do
removed = service.execute(project.id)
expect(removed).to eq(project)
end
end
context 'without projects added' do
it 'does not remove the project' do
expect { service.execute(project.id) }.not_to change { UsersOpsDashboardProject.count }
end
it 'returns nil' do
expect(service.execute(project.id)).to be_nil
end
end
end
end
...@@ -32,6 +32,9 @@ msgid_plural " improved on %d points" ...@@ -32,6 +32,9 @@ msgid_plural " improved on %d points"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "\"%{query}\" in projects"
msgstr ""
msgid "%d addition" msgid "%d addition"
msgid_plural "%d additions" msgid_plural "%d additions"
msgstr[0] "" msgstr[0] ""
...@@ -116,6 +119,9 @@ msgstr "" ...@@ -116,6 +119,9 @@ msgstr ""
msgid "%{counter_storage} (%{counter_repositories} repositories, %{counter_build_artifacts} build artifacts, %{counter_lfs_objects} LFS)" msgid "%{counter_storage} (%{counter_repositories} repositories, %{counter_build_artifacts} build artifacts, %{counter_lfs_objects} LFS)"
msgstr "" msgstr ""
msgid "%{count} %{alerts}"
msgstr ""
msgid "%{count} participant" msgid "%{count} participant"
msgid_plural "%{count} participants" msgid_plural "%{count} participants"
msgstr[0] "" msgstr[0] ""
...@@ -406,6 +412,9 @@ msgstr "" ...@@ -406,6 +412,9 @@ msgstr ""
msgid "Add new directory" msgid "Add new directory"
msgstr "" msgstr ""
msgid "Add projects"
msgstr ""
msgid "Add reaction" msgid "Add reaction"
msgstr "" msgstr ""
...@@ -499,6 +508,11 @@ msgstr "" ...@@ -499,6 +508,11 @@ msgstr ""
msgid "Advanced settings" msgid "Advanced settings"
msgstr "" msgstr ""
msgid "Alert"
msgid_plural "Alerts"
msgstr[0] ""
msgstr[1] ""
msgid "All" msgid "All"
msgstr "" msgstr ""
...@@ -5562,6 +5576,15 @@ msgstr "" ...@@ -5562,6 +5576,15 @@ msgstr ""
msgid "Operations Dashboard" msgid "Operations Dashboard"
msgstr "" msgstr ""
msgid "OperationsDashboard|Add a project to the dashboard"
msgstr ""
msgid "OperationsDashboard|Some projects could not be added to dashboard"
msgstr ""
msgid "OperationsDashboard|The operations dashboard provides a summary of each project's operational health, including pipeline and alert status."
msgstr ""
msgid "Optionally, you can %{link_to_customize} how FogBugz email addresses and usernames are imported into GitLab." msgid "Optionally, you can %{link_to_customize} how FogBugz email addresses and usernames are imported into GitLab."
msgstr "" msgstr ""
...@@ -6952,6 +6975,9 @@ msgstr "" ...@@ -6952,6 +6975,9 @@ msgstr ""
msgid "Search users" msgid "Search users"
msgstr "" msgstr ""
msgid "Search your projects"
msgstr ""
msgid "SearchAutocomplete|All GitLab" msgid "SearchAutocomplete|All GitLab"
msgstr "" msgstr ""
...@@ -7305,12 +7331,24 @@ msgstr "" ...@@ -7305,12 +7331,24 @@ msgstr ""
msgid "Something went wrong while resolving this discussion. Please try again." msgid "Something went wrong while resolving this discussion. Please try again."
msgstr "" msgstr ""
msgid "Something went wrong, unable to add %{project} to dashboard"
msgstr ""
msgid "Something went wrong, unable to get operations projects"
msgstr ""
msgid "Something went wrong, unable to remove project"
msgstr ""
msgid "Something went wrong. Please try again." msgid "Something went wrong. Please try again."
msgstr "" msgstr ""
msgid "Sorry, no epics matched your search" msgid "Sorry, no epics matched your search"
msgstr "" msgstr ""
msgid "Sorry, no projects matched your search"
msgstr ""
msgid "Sort by" msgid "Sort by"
msgstr "" msgstr ""
...@@ -8592,6 +8630,9 @@ msgstr "" ...@@ -8592,6 +8630,9 @@ msgstr ""
msgid "Version" msgid "Version"
msgstr "" msgstr ""
msgid "View %{alerts}"
msgstr ""
msgid "View app" msgid "View app"
msgstr "" msgstr ""
...@@ -9624,6 +9665,11 @@ msgstr "" ...@@ -9624,6 +9665,11 @@ msgstr ""
msgid "private key does not match certificate." msgid "private key does not match certificate."
msgstr "" msgstr ""
msgid "project"
msgid_plural "projects"
msgstr[0] ""
msgstr[1] ""
msgid "remaining" msgid "remaining"
msgstr "" msgstr ""
......
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