diff --git a/web/satellite/src/api/projects.ts b/web/satellite/src/api/projects.ts index abd16e331..fee58bca6 100644 --- a/web/satellite/src/api/projects.ts +++ b/web/satellite/src/api/projects.ts @@ -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 { + 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'); + } } diff --git a/web/satellite/src/components/project/ProjectDetails.vue b/web/satellite/src/components/project/ProjectDetails.vue index f9fde2061..23530952f 100644 --- a/web/satellite/src/components/project/ProjectDetails.vue +++ b/web/satellite/src/components/project/ProjectDetails.vue @@ -49,6 +49,18 @@ +
+ +
+

+ To increase your limits please contact us at + + support@tardigrade.io + +

@@ -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 { diff --git a/web/satellite/src/components/project/ProjectLimitsArea.vue b/web/satellite/src/components/project/ProjectLimitsArea.vue new file mode 100644 index 000000000..325976b0b --- /dev/null +++ b/web/satellite/src/components/project/ProjectLimitsArea.vue @@ -0,0 +1,118 @@ +// Copyright (C) 2019 Storj Labs, Inc. +// See LICENSE for copying information. + + + + + + \ No newline at end of file diff --git a/web/satellite/src/store/modules/projects.ts b/web/satellite/src/store/modules/projects.ts index 36cdb01e9..5870ad05f 100644 --- a/web/satellite/src/store/modules/projects.ts +++ b/web/satellite/src/store/modules/projects.ts @@ -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 { @@ -101,17 +105,26 @@ export function makeProjectsModule(api: ProjectsApi): StoreModule 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 { + [FETCH]: async function ({commit, state}: any): Promise { 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 { @@ -121,8 +134,12 @@ export function makeProjectsModule(api: ProjectsApi): StoreModule return project; }, - [SELECT]: function ({commit}: any, projectID: string): void { + [SELECT]: async function ({commit}: any, projectID: string): Promise { commit(SELECT_PROJECT, projectID); + + const limits = await api.getLimits(projectID); + + commit(SET_LIMITS, limits); }, [UPDATE]: async function ({commit}: any, updateProjectModel: UpdateProjectModel): Promise { await api.update(updateProjectModel.id, updateProjectModel.description); @@ -134,6 +151,13 @@ export function makeProjectsModule(api: ProjectsApi): StoreModule commit(REMOVE, projectID); }, + [GET_LIMITS]: async function ({commit}: any, projectID: string): Promise { + const limits = await api.getLimits(projectID); + + commit(SET_LIMITS, limits); + + return limits; + }, [CLEAR]: function({commit}: any): void { commit(CLEAR_PROJECTS); }, diff --git a/web/satellite/src/types/projects.ts b/web/satellite/src/types/projects.ts index ed911c6bf..5ed565251 100644 --- a/web/satellite/src/types/projects.ts +++ b/web/satellite/src/types/projects.ts @@ -35,26 +35,30 @@ export interface ProjectsApi { * @throws Error */ delete(projectId: string): Promise; + + /** + * Get project limits + * + * @param projectId- project ID + * throws Error + */ + getLimits(projectId: string): Promise; } // 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, + ) {} +} diff --git a/web/satellite/src/types/usage.ts b/web/satellite/src/types/usage.ts index 9da053f09..a1c8151ab 100644 --- a/web/satellite/src/types/usage.ts +++ b/web/satellite/src/types/usage.ts @@ -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; diff --git a/web/satellite/src/utils/bytesSize.ts b/web/satellite/src/utils/bytesSize.ts index 53eae29e9..a2e34b99d 100644 --- a/web/satellite/src/utils/bytesSize.ts +++ b/web/satellite/src/utils/bytesSize.ts @@ -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: diff --git a/web/satellite/tests/unit/components/navigation/navigationArea.spec.ts b/web/satellite/tests/unit/components/navigation/navigationArea.spec.ts index 3c6b24baa..ae4191290 100644 --- a/web/satellite/tests/unit/components/navigation/navigationArea.spec.ts +++ b/web/satellite/tests/unit/components/navigation/navigationArea.spec.ts @@ -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, diff --git a/web/satellite/tests/unit/components/project/ProjectLimitsArea.spec.ts b/web/satellite/tests/unit/components/project/ProjectLimitsArea.spec.ts new file mode 100644 index 000000000..c8815933c --- /dev/null +++ b/web/satellite/tests/unit/components/project/ProjectLimitsArea.spec.ts @@ -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(); + }); +}); diff --git a/web/satellite/tests/unit/components/project/__snapshots__/ProjectLimitsArea.spec.ts.snap b/web/satellite/tests/unit/components/project/__snapshots__/ProjectLimitsArea.spec.ts.snap new file mode 100644 index 000000000..678f648a2 --- /dev/null +++ b/web/satellite/tests/unit/components/project/__snapshots__/ProjectLimitsArea.spec.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProjectLimitsArea snapshot not changed 1`] = ` +
+
+

Egress limits used

+
+

0

+

/ 0

+
+
+
+

Storage limits used

+
+

0

+

/ 0

+
+
+
+`; diff --git a/web/satellite/tests/unit/mock/api/projects.ts b/web/satellite/tests/unit/mock/api/projects.ts index 56e41990c..96cae1de2 100644 --- a/web/satellite/tests/unit/mock/api/projects.ts +++ b/web/satellite/tests/unit/mock/api/projects.ts @@ -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 { throw new Error('not implemented'); } @@ -28,4 +33,8 @@ export class ProjectsApiMock implements ProjectsApi { update(projectId: string, description: string): Promise { throw new Error('not implemented'); } + + getLimits(projectId: string): Promise { + throw Promise.resolve(this.mockLimits); + } } diff --git a/web/satellite/tests/unit/store/projects.spec.ts b/web/satellite/tests/unit/store/projects.spec.ts index a7dd7ed03..8013292bd 100644 --- a/web/satellite/tests/unit/store/projects.spec.ts +++ b/web/satellite/tests/unit/store/projects.spec.ts @@ -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);