web/satellite: project limits

Change-Id: Ia9c3ee9b5bc3dc1bc03e613c8715d299fce569dc
This commit is contained in:
NikolaiYurchenko 2019-12-12 18:25:38 +02:00
parent ab777e823e
commit 11db709066
12 changed files with 363 additions and 33 deletions

View File

@ -2,9 +2,14 @@
// See LICENSE for copying information.
import { BaseGql } from '@/api/baseGql';
import { CreateProjectModel, Project, ProjectsApi } from '@/types/projects';
import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized';
import { CreateProjectModel, Project, ProjectLimits, ProjectsApi } from '@/types/projects';
import { HttpClient } from '@/utils/httpClient';
export class ProjectsApiGql extends BaseGql implements ProjectsApi {
private readonly http: HttpClient = new HttpClient();
private readonly ROOT_PATH: string = '/api/v0/projects';
/**
* Creates project
*
@ -51,7 +56,15 @@ export class ProjectsApiGql extends BaseGql implements ProjectsApi {
const response = await this.query(query);
return response.data.myProjects;
return response.data.myProjects.map((project: Project) => {
return new Project(
project.id,
project.name,
project.description,
project.createdAt,
project.ownerId,
);
});
}
/**
@ -99,4 +112,32 @@ export class ProjectsApiGql extends BaseGql implements ProjectsApi {
await this.mutate(query, variables);
}
/**
* Get project limits
*
* @param projectId- project ID
* throws Error
*/
public async getLimits(projectId): Promise<ProjectLimits> {
const path = `${this.ROOT_PATH}/${projectId}/usage-limits`;
const response = await this.http.get(path, true);
if (response.ok) {
const limits = await response.json();
return new ProjectLimits(
limits.bandwidthLimit,
limits.bandwidthUsed,
limits.storageLimit,
limits.storageUsed,
);
}
if (response.status === 401) {
throw new ErrorUnauthorized();
}
throw new Error('can not get usage limits');
}
}

View File

@ -49,6 +49,18 @@
</div>
</div>
</div>
<div class="project-details-info-container">
<ProjectLimitsArea />
</div>
<p class="project-details__limits-increase-text">
To increase your limits please contact us at
<a
href="mailto:support@tardigrade.io"
class="project-details__limits-increase-text__link"
>
support@tardigrade.io
</a>
</p>
</div>
</div>
</template>
@ -60,6 +72,7 @@ import EmptyState from '@/components/common/EmptyStateArea.vue';
import HeaderedInput from '@/components/common/HeaderedInput.vue';
import VButton from '@/components/common/VButton.vue';
import DeleteProjectPopup from '@/components/project/DeleteProjectPopup.vue';
import ProjectLimitsArea from '@/components/project/ProjectLimitsArea.vue';
import EditIcon from '@/../static/images/project/edit.svg';
@ -77,6 +90,7 @@ import { LocalData } from '@/utils/localData';
EmptyState,
DeleteProjectPopup,
EditIcon,
ProjectLimitsArea,
},
})
export default class ProjectDetailsArea extends Vue {
@ -174,6 +188,18 @@ export default class ProjectDetailsArea extends Vue {
margin-top: 3vh;
margin-bottom: 100px;
}
&__limits-increase-text {
font-family: 'font_regular', sans-serif;
font-size: 16px;
color: #afb7c1;
margin-top: 42px;
&__link {
text-decoration: underline;
color: #2683ff;
}
}
}
.project-details-info-container {

View File

@ -0,0 +1,118 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<div class="limits-container">
<div class="limits-container__item">
<p class="limits-container__item__title">Egress limits used</p>
<div class="limits-container__item__values-container">
<p class="limits-container__item__values-container__remaining">{{ bandwidthUsed }}</p>
<p class="limits-container__item__values-container__total">/ {{ bandwidthLimit }}</p>
</div>
</div>
<div class="limits-container__item">
<p class="limits-container__item__title">Storage limits used</p>
<div class="limits-container__item__values-container">
<p class="limits-container__item__values-container__remaining">{{ storageUsed }}</p>
<p class="limits-container__item__values-container__total">/ {{ storageLimit }}</p>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { PROJECTS_ACTIONS } from '@/store/modules/projects';
import { Dimensions, Size } from '@/utils/bytesSize';
@Component
export default class ProjectLimitsArea extends Vue {
public async mounted() {
await this.$store.dispatch(PROJECTS_ACTIONS.GET_LIMITS, this.$store.getters.selectedProject.id);
}
public get bandwidthUsed(): string {
const bandwidthUsed = new Size(this.$store.getters.selectedProject.limits.bandwidthUsed);
return this.getFormattedLimit(bandwidthUsed);
}
public get bandwidthLimit(): string {
const bandwidthLimit = new Size(this.$store.getters.selectedProject.limits.bandwidthLimit);
return this.getFormattedLimit(bandwidthLimit);
}
public get storageUsed(): string {
const storageUsed = new Size(this.$store.getters.selectedProject.limits.storageUsed);
return this.getFormattedLimit(storageUsed);
}
public get storageLimit(): string {
const storageLimit = new Size(this.$store.getters.selectedProject.limits.storageLimit);
return this.getFormattedLimit(storageLimit);
}
private getFormattedLimit(limit: Size): string {
switch (limit.label) {
case Dimensions.Bytes:
case Dimensions.KB:
return '0';
default:
return `${limit.formattedBytes.replace(/\\.0+$/, '')} ${limit.label}`;
}
}
}
</script>
<style scoped lang="scss">
.limits-container {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
&__item {
padding: 37px 28px;
width: calc(49% - 56px);
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
background-color: #fff;
border-radius: 6px;
&__title {
font-family: 'font_regular', sans-serif;
font-size: 16px;
text-align: left;
color: #afb7c1;
margin: 0;
}
&__values-container {
display: flex;
align-items: flex-start;
justify-content: center;
margin-top: 10px;
&__remaining {
font-family: 'font_bold', sans-serif;
font-size: 36px;
color: #39464f;
margin: 0;
}
&__total {
font-family: 'font_medium', sans-serif;
font-size: 36px;
color: #afb7c1;
margin: 0 0 0 15px;
}
}
}
}
</style>

View File

@ -2,7 +2,7 @@
// See LICENSE for copying information.
import { StoreModule } from '@/store';
import { CreateProjectModel, Project, ProjectsApi, UpdateProjectModel } from '@/types/projects';
import { CreateProjectModel, Project, ProjectLimits, ProjectsApi, UpdateProjectModel } from '@/types/projects';
export const PROJECTS_ACTIONS = {
FETCH: 'fetchProjects',
@ -11,6 +11,7 @@ export const PROJECTS_ACTIONS = {
UPDATE: 'updateProject',
DELETE: 'deleteProject',
CLEAR: 'clearProjects',
GET_LIMITS: 'getProjectLimits',
};
export const PROJECTS_MUTATIONS = {
@ -20,6 +21,7 @@ export const PROJECTS_MUTATIONS = {
SET_PROJECTS: 'SET_PROJECTS',
SELECT_PROJECT: 'SELECT_PROJECT',
CLEAR_PROJECTS: 'CLEAR_PROJECTS',
SET_LIMITS: 'SET_PROJECT_LIMITS',
};
const defaultSelectedProject = new Project('', '', '', '', '', true);
@ -36,6 +38,7 @@ const {
UPDATE,
DELETE,
CLEAR,
GET_LIMITS,
} = PROJECTS_ACTIONS;
const {
@ -45,6 +48,7 @@ const {
SET_PROJECTS,
SELECT_PROJECT,
CLEAR_PROJECTS,
SET_LIMITS,
} = PROJECTS_MUTATIONS;
export function makeProjectsModule(api: ProjectsApi): StoreModule<ProjectsState> {
@ -101,17 +105,26 @@ export function makeProjectsModule(api: ProjectsApi): StoreModule<ProjectsState>
state.selectedProject = new Project();
}
},
[SET_LIMITS](state: ProjectsState, limits: ProjectLimits): void {
state.selectedProject.setLimits(limits);
},
[CLEAR_PROJECTS](state: ProjectsState): void {
state.projects = [];
state.selectedProject = defaultSelectedProject;
},
},
actions: {
[FETCH]: async function ({commit}: any): Promise<Project[]> {
[FETCH]: async function ({commit, state}: any): Promise<Project[]> {
const projects = await api.get();
commit(SET_PROJECTS, projects);
if (state.selectedProject.id) {
const limits = await api.getLimits(state.selectedProject.id);
commit(SET_LIMITS, limits);
}
return projects;
},
[CREATE]: async function ({commit}: any, createProjectModel: CreateProjectModel): Promise<Project> {
@ -121,8 +134,12 @@ export function makeProjectsModule(api: ProjectsApi): StoreModule<ProjectsState>
return project;
},
[SELECT]: function ({commit}: any, projectID: string): void {
[SELECT]: async function ({commit}: any, projectID: string): Promise<void> {
commit(SELECT_PROJECT, projectID);
const limits = await api.getLimits(projectID);
commit(SET_LIMITS, limits);
},
[UPDATE]: async function ({commit}: any, updateProjectModel: UpdateProjectModel): Promise<void> {
await api.update(updateProjectModel.id, updateProjectModel.description);
@ -134,6 +151,13 @@ export function makeProjectsModule(api: ProjectsApi): StoreModule<ProjectsState>
commit(REMOVE, projectID);
},
[GET_LIMITS]: async function ({commit}: any, projectID: string): Promise<ProjectLimits> {
const limits = await api.getLimits(projectID);
commit(SET_LIMITS, limits);
return limits;
},
[CLEAR]: function({commit}: any): void {
commit(CLEAR_PROJECTS);
},

View File

@ -35,26 +35,30 @@ export interface ProjectsApi {
* @throws Error
*/
delete(projectId: string): Promise<void>;
/**
* Get project limits
*
* @param projectId- project ID
* throws Error
*/
getLimits(projectId: string): Promise<ProjectLimits>;
}
// Project is a type, used for creating new project in backend
export class Project {
public id: string;
public constructor(
public id: string = '',
public name: string = '',
public description: string = '',
public createdAt: string = '',
public ownerId: string = '',
public isSelected: boolean = false,
public limits: ProjectLimits = new ProjectLimits(),
) {}
public name: string;
public description: string;
public createdAt: string;
public ownerId: string;
public isSelected: boolean;
public constructor(id: string = '', name: string = '', description: string = '', createdAt: string = '', ownerId: string = '', isSelected: boolean = false) {
this.id = id;
this.name = name;
this.description = description;
this.createdAt = createdAt;
this.isSelected = isSelected;
this.ownerId = ownerId;
public setLimits(limits: ProjectLimits): void {
this.limits = limits;
}
}
@ -74,3 +78,12 @@ export class CreateProjectModel {
public name: string;
public description: string;
}
export class ProjectLimits {
constructor(
public bandwidthLimit = 0,
public bandwidthUsed = 0,
public storageLimit = 0,
public storageUsed = 0,
) {}
}

View File

@ -12,8 +12,8 @@ export class ProjectUsage {
public before: Date;
public constructor(storage: number, egress: number, objectCount: number, since: Date, before: Date) {
this.storage = new Size(storage);
this.egress = new Size(egress);
this.storage = new Size(storage, 4);
this.egress = new Size(egress, 4);
this.objectCount = objectCount;
this.since = since;
this.before = before;

View File

@ -10,7 +10,7 @@ enum Memory {
PB = 1e15,
}
enum Dimensions {
export enum Dimensions {
Bytes = 'Bytes',
KB = 'KB',
MB = 'MB',
@ -20,14 +20,15 @@ enum Dimensions {
}
export class Size {
private readonly precision: number = 4;
private readonly precision: number;
public readonly bytes: number;
public readonly formattedBytes: string;
public readonly label: Dimensions;
public constructor(bytes: number) {
public constructor(bytes: number, precision: number = 0) {
const _bytes = Math.ceil(bytes);
this.bytes = bytes;
this.precision = precision;
switch (true) {
case _bytes === 0:

View File

@ -6,7 +6,7 @@ import Vuex from 'vuex';
import NavigationArea from '@/components/navigation/NavigationArea.vue';
import { RouteConfig } from '@/router';
import { makeProjectsModule } from '@/store/modules/projects';
import { makeProjectsModule, PROJECTS_MUTATIONS } from '@/store/modules/projects';
import { NavigationLink } from '@/types/navigation';
import { Project } from '@/types/projects';
import { createLocalVue, shallowMount } from '@vue/test-utils';
@ -51,7 +51,7 @@ describe('NavigationArea', () => {
it('snapshot not changed with project', async () => {
const projects = await store.dispatch('fetchProjects');
await store.dispatch('selectProject', projects[0].id);
store.commit(PROJECTS_MUTATIONS.SELECT_PROJECT, projects[0].id);
const wrapper = shallowMount(NavigationArea, {
store,

View File

@ -0,0 +1,29 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
import Vuex from 'vuex';
import ProjectLimitsArea from '@/components/project/ProjectLimitsArea.vue';
import { makeProjectsModule } from '@/store/modules/projects';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { ProjectsApiMock } from '../../mock/api/projects';
const localVue = createLocalVue();
localVue.use(Vuex);
const projectsApi = new ProjectsApiMock();
const projectsModule = makeProjectsModule(projectsApi);
const store = new Vuex.Store({ modules: { projectsModule }});
describe('ProjectLimitsArea', () => {
it('snapshot not changed', () => {
const wrapper = shallowMount(ProjectLimitsArea, {
store,
localVue,
});
expect(wrapper).toMatchSnapshot();
});
});

View File

@ -0,0 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ProjectLimitsArea snapshot not changed 1`] = `
<div class="limits-container">
<div class="limits-container__item">
<p class="limits-container__item__title">Egress limits used</p>
<div class="limits-container__item__values-container">
<p class="limits-container__item__values-container__remaining">0</p>
<p class="limits-container__item__values-container__total">/ 0</p>
</div>
</div>
<div class="limits-container__item">
<p class="limits-container__item__title">Storage limits used</p>
<div class="limits-container__item__values-container">
<p class="limits-container__item__values-container__remaining">0</p>
<p class="limits-container__item__values-container__total">/ 0</p>
</div>
</div>
</div>
`;

View File

@ -1,18 +1,23 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
import { CreateProjectModel, Project, ProjectsApi } from '@/types/projects';
import { CreateProjectModel, Project, ProjectLimits, ProjectsApi } from '@/types/projects';
/**
* Mock for ProjectsApi
*/
export class ProjectsApiMock implements ProjectsApi {
private mockProjects: Project[];
private mockLimits: ProjectLimits;
public setMockProjects(mockProjects: Project[]): void {
this.mockProjects = mockProjects;
}
public setMockLimits(mockLimits: ProjectLimits): void {
this.mockLimits = mockLimits;
}
create(createProjectModel: CreateProjectModel): Promise<Project> {
throw new Error('not implemented');
}
@ -28,4 +33,8 @@ export class ProjectsApiMock implements ProjectsApi {
update(projectId: string, description: string): Promise<void> {
throw new Error('not implemented');
}
getLimits(projectId: string): Promise<ProjectLimits> {
throw Promise.resolve(this.mockLimits);
}
}

View File

@ -5,13 +5,13 @@ import Vuex from 'vuex';
import { ProjectsApiGql } from '@/api/projects';
import { makeProjectsModule, PROJECTS_ACTIONS, PROJECTS_MUTATIONS } from '@/store/modules/projects';
import { Project } from '@/types/projects';
import { Project, ProjectLimits } from '@/types/projects';
import { createLocalVue } from '@vue/test-utils';
const Vue = createLocalVue();
const projectsApi = new ProjectsApiGql();
const { FETCH, CREATE, SELECT, DELETE, CLEAR, UPDATE } = PROJECTS_ACTIONS;
const { ADD, SET_PROJECTS, SELECT_PROJECT, UPDATE_PROJECT, REMOVE, CLEAR_PROJECTS } = PROJECTS_MUTATIONS;
const { FETCH, CREATE, SELECT, DELETE, CLEAR, UPDATE, GET_LIMITS } = PROJECTS_ACTIONS;
const { ADD, SET_PROJECTS, SELECT_PROJECT, UPDATE_PROJECT, REMOVE, CLEAR_PROJECTS, SET_LIMITS } = PROJECTS_MUTATIONS;
const projectsModule = makeProjectsModule(projectsApi);
const selectedProject = new Project('1', '', '', '');
@ -24,10 +24,28 @@ const store = new Vuex.Store({ modules: { projectsModule } });
const state = (store.state as any).projectsModule;
const projects = [
new Project('11', 'name', 'descr', '23', 'testOwnerId'),
new Project('1', 'name2', 'descr2', '24', 'testOwnerId1'),
new Project(
'11',
'name',
'descr',
'23',
'testOwnerId',
false,
new ProjectLimits(1, 2, 3, 4),
),
new Project(
'1',
'name2',
'descr2',
'24',
'testOwnerId1',
false,
new ProjectLimits(5, 6, 7, 8),
),
];
const limits = new ProjectLimits(15, 12, 14, 13);
const project = new Project('11', 'name', 'descr', '23', 'testOwnerId');
describe('mutations', () => {
@ -58,6 +76,7 @@ describe('mutations', () => {
store.commit(SELECT_PROJECT, '11');
expect(state.selectedProject.id).toBe('11');
expect(state.selectedProject.limits.bandwidthLimit).toBe(1);
});
it('update project', () => {
@ -79,6 +98,17 @@ describe('mutations', () => {
expect(state.projects[0].id).toBe('1');
});
it('set limits', () => {
state.projects = projects;
store.commit(SET_LIMITS, limits);
expect(state.selectedProject.limits.bandwidthUsed).toBe(12);
expect(state.selectedProject.limits.bandwidthLimit).toBe(15);
expect(state.selectedProject.limits.storageUsed).toBe(13);
expect(state.selectedProject.limits.storageLimit).toBe(14);
});
it('clear projects', () => {
state.projects = projects;
@ -112,6 +142,7 @@ describe('actions', () => {
await store.dispatch(FETCH);
} catch (error) {
expect(state.projects.length).toBe(0);
expect(state.selectedProject.limits.bandwidthLimit).toBe(0);
}
});
@ -123,6 +154,7 @@ describe('actions', () => {
await store.dispatch(CREATE, {name: '', description: ''});
expect(state.projects.length).toBe(1);
expect(state.selectedProject.limits.bandwidthLimit).toBe(0);
});
it('create throws an error when create api call fails', async () => {
@ -134,6 +166,7 @@ describe('actions', () => {
expect(true).toBe(false);
} catch (error) {
expect(state.projects.length).toBe(0);
expect(state.selectedProject.limits.bandwidthLimit).toBe(0);
}
});
@ -169,6 +202,7 @@ describe('actions', () => {
store.dispatch(SELECT, '1');
expect(state.selectedProject.id).toEqual('1');
expect(state.selectedProject.limits.bandwidthLimit).toBe(5);
});
it('success update project', async () => {
@ -198,6 +232,21 @@ describe('actions', () => {
}
});
it('success get project limits', async () => {
jest.spyOn(projectsApi, 'getLimits').mockReturnValue(
Promise.resolve(limits),
);
state.projects = projects;
await store.dispatch(GET_LIMITS, state.selectedProject.id);
expect(state.selectedProject.limits.bandwidthUsed).toBe(12);
expect(state.selectedProject.limits.bandwidthLimit).toBe(15);
expect(state.selectedProject.limits.storageUsed).toBe(13);
expect(state.selectedProject.limits.storageLimit).toBe(14);
});
it('success clearProjects', () => {
state.projects = projects;
store.dispatch(CLEAR);