web/satellite: implemented charts UI for new project dashboard

Added bandwidth/storage charts (with test data) to new project dashboard.
Added functional buttons to new project dashboard.
Fixed this issue https://github.com/storj/storj/issues/4262.

Change-Id: Ie87370b8f7b6015bc84022a6086ef1db40e16535
This commit is contained in:
Vitalii Shpital 2021-11-24 18:50:53 +02:00
parent b78f65e83b
commit be10ce84f8
16 changed files with 1254 additions and 243 deletions

View File

@ -17,6 +17,7 @@
"aws-sdk": "2.853.0",
"bip39": "3.0.3",
"browser": "git+https://github.com/storj/browser#4e5770dfa9883176bbab793138911d92305243ed",
"chart.js": "2.9.4",
"graphql": "15.3.0",
"graphql-tag": "2.11.0",
"load-script": "1.0.0",
@ -25,6 +26,7 @@
"qrcode": "1.4.4",
"stripe": "8.96.0",
"vue": "2.6.12",
"vue-chartjs": "3.5.1",
"vue-class-component": "7.2.5",
"vue-clipboard2": "0.3.1",
"vue-property-decorator": "9.0.0",
@ -3834,6 +3836,14 @@
"magic-string": "^0.25.0"
}
},
"node_modules/@types/chart.js": {
"version": "2.9.34",
"resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.34.tgz",
"integrity": "sha512-CtZVk+kh1IN67dv+fB0CWmCLCRrDJgqOj15qPic2B1VCMovNO6B7Vhf/TgPpNscjhAL1j+qUntDMWb9A4ZmPTg==",
"dependencies": {
"moment": "^2.10.2"
}
},
"node_modules/@types/connect": {
"version": "3.4.35",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz",
@ -9507,6 +9517,32 @@
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
"dev": true
},
"node_modules/chart.js": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.4.tgz",
"integrity": "sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==",
"dependencies": {
"chartjs-color": "^2.1.0",
"moment": "^2.10.2"
}
},
"node_modules/chartjs-color": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz",
"integrity": "sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==",
"dependencies": {
"chartjs-color-string": "^0.6.0",
"color-convert": "^1.9.3"
}
},
"node_modules/chartjs-color-string": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz",
"integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==",
"dependencies": {
"color-name": "^1.0.0"
}
},
"node_modules/check-types": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/check-types/-/check-types-8.0.3.tgz",
@ -21288,6 +21324,14 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/moment": {
"version": "2.29.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
"integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==",
"engines": {
"node": "*"
}
},
"node_modules/move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@ -28413,6 +28457,21 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-2.6.12.tgz",
"integrity": "sha512-uhmLFETqPPNyuLLbsKz6ioJ4q7AZHzD8ZVFNATNyICSZouqP2Sz0rotWQC8UNBF6VGSCs5abnKJoStA6JbCbfg=="
},
"node_modules/vue-chartjs": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-3.5.1.tgz",
"integrity": "sha512-foocQbJ7FtveICxb4EV5QuVpo6d8CmZFmAopBppDIGKY+esJV8IJgwmEW0RexQhxqXaL/E1xNURsgFFYyKzS/g==",
"dependencies": {
"@types/chart.js": "^2.7.55"
},
"engines": {
"node": ">=6.9.0",
"npm": ">= 3.0.0"
},
"peerDependencies": {
"chart.js": ">= 2.5"
}
},
"node_modules/vue-class-component": {
"version": "7.2.5",
"resolved": "https://registry.npmjs.org/vue-class-component/-/vue-class-component-7.2.5.tgz",
@ -33332,6 +33391,14 @@
"magic-string": "^0.25.0"
}
},
"@types/chart.js": {
"version": "2.9.34",
"resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.34.tgz",
"integrity": "sha512-CtZVk+kh1IN67dv+fB0CWmCLCRrDJgqOj15qPic2B1VCMovNO6B7Vhf/TgPpNscjhAL1j+qUntDMWb9A4ZmPTg==",
"requires": {
"moment": "^2.10.2"
}
},
"@types/connect": {
"version": "3.4.35",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz",
@ -37914,6 +37981,32 @@
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
"dev": true
},
"chart.js": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.4.tgz",
"integrity": "sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==",
"requires": {
"chartjs-color": "^2.1.0",
"moment": "^2.10.2"
}
},
"chartjs-color": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz",
"integrity": "sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==",
"requires": {
"chartjs-color-string": "^0.6.0",
"color-convert": "^1.9.3"
}
},
"chartjs-color-string": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz",
"integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==",
"requires": {
"color-name": "^1.0.0"
}
},
"check-types": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/check-types/-/check-types-8.0.3.tgz",
@ -39995,7 +40088,7 @@
"eslint-plugin-storj": {
"version": "git+https://git@github.com/storj/eslint-storj.git#5f952ffab7141e752cc095e5f024c39bab89679f",
"dev": true,
"from": "eslint-plugin-storj@https://github.com/storj/eslint-storj"
"from": "eslint-plugin-storj@github:storj/eslint-storj"
},
"eslint-plugin-vue": {
"version": "7.16.0",
@ -47219,6 +47312,11 @@
"minimist": "^1.2.5"
}
},
"moment": {
"version": "2.29.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
"integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ=="
},
"move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@ -52925,6 +53023,14 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-2.6.12.tgz",
"integrity": "sha512-uhmLFETqPPNyuLLbsKz6ioJ4q7AZHzD8ZVFNATNyICSZouqP2Sz0rotWQC8UNBF6VGSCs5abnKJoStA6JbCbfg=="
},
"vue-chartjs": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-3.5.1.tgz",
"integrity": "sha512-foocQbJ7FtveICxb4EV5QuVpo6d8CmZFmAopBppDIGKY+esJV8IJgwmEW0RexQhxqXaL/E1xNURsgFFYyKzS/g==",
"requires": {
"@types/chart.js": "^2.7.55"
}
},
"vue-class-component": {
"version": "7.2.5",
"resolved": "https://registry.npmjs.org/vue-class-component/-/vue-class-component-7.2.5.tgz",

View File

@ -22,6 +22,7 @@
"aws-sdk": "2.853.0",
"bip39": "3.0.3",
"browser": "git+https://github.com/storj/browser#4e5770dfa9883176bbab793138911d92305243ed",
"chart.js": "2.9.4",
"graphql": "15.3.0",
"graphql-tag": "2.11.0",
"load-script": "1.0.0",
@ -30,6 +31,7 @@
"qrcode": "1.4.4",
"stripe": "8.96.0",
"vue": "2.6.12",
"vue-chartjs": "3.5.1",
"vue-class-component": "7.2.5",
"vue-clipboard2": "0.3.1",
"vue-property-decorator": "9.0.0",

View File

@ -0,0 +1,28 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
<script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
// @vue/component
@Component
export default class BaseChart extends Vue {
@Prop({default: 0})
public readonly width: number;
@Prop({default: 0})
public readonly height: number;
/**
* Used for chart re rendering.
*/
public chartKey = 0;
/**
* Triggers chart re rendering if chart width is being changed.
*/
@Watch('width')
public rebuildChart(): void {
this.chartKey += 1;
}
}
</script>

View File

@ -0,0 +1,165 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
<script lang="ts">
import { Line } from 'vue-chartjs';
import { Component, Prop, Vue } from 'vue-property-decorator';
import { ChartData, RenderChart } from '@/types/chart';
/**
* Used to filter days displayed on x-axis.
*/
class DayShowingConditions {
public constructor(
public day: string,
public daysArray: string[],
) {}
public countMiddleDateValue(): number {
return this.daysArray.length / 2;
}
public isDayFirstOrLast(): boolean {
return this.day === this.daysArray[0] || this.day === this.daysArray[this.daysArray.length - 1];
}
public isDayAfterEighthDayOfTheMonth(): boolean {
return this.daysArray.length > 8 && this.daysArray.length <= 31;
}
}
// @vue/component
@Component({
extends: Line,
})
export default class VChart extends Vue {
@Prop({ default: () => { console.error('Tooltip constructor is undefined'); } })
private tooltipConstructor: (tooltipModel) => void;
@Prop({ default: {} })
private readonly chartData: ChartData;
/**
* Mounted hook after initial render.
* Adds chart plugin to draw dashed line under data point.
* Renders chart.
*/
public mounted(): void {
(this as unknown as RenderChart).addPlugin({
afterDatasetsDraw: (chart): void => {
if (chart.tooltip._active && chart.tooltip._active.length) {
const activePoint = chart.tooltip._active[0];
const ctx = chart.ctx;
const y_axis = chart.scales['y-axis-0'];
const tooltipPosition = activePoint.tooltipPosition();
ctx.save();
ctx.beginPath();
ctx.setLineDash([8, 5]);
ctx.moveTo(tooltipPosition.x, tooltipPosition.y + 12);
ctx.lineTo(tooltipPosition.x, y_axis.bottom);
ctx.lineWidth = 1;
ctx.strokeStyle = '#C8D3DE';
ctx.stroke();
ctx.restore();
}
}
});
(this as unknown as RenderChart).renderChart(this.chartData, this.chartOptions);
}
/**
* Returns chart options.
*/
public get chartOptions(): Record<string, unknown> {
const filterCallback = this.filterDaysDisplayed;
return {
responsive: false,
maintainAspectRatios: false,
legend: {
display: false,
},
layout: {
padding: {
top: 40,
}
},
elements: {
point: {
radius: this.chartData.datasets.length === 1 ? 10 : 0,
hoverRadius: 10,
hitRadius: 8,
},
},
scales: {
yAxes: [{
display: false,
}],
xAxes: [{
display: true,
ticks: {
fontFamily: 'font_regular',
autoSkip: false,
maxRotation: 0,
minRotation: 0,
callback: filterCallback,
},
gridLines: {
display: false,
},
}],
},
tooltips: {
enabled: false,
custom: (tooltipModel) => {
this.tooltipConstructor(tooltipModel);
},
labels: {
enabled: true,
},
},
};
}
/**
* Used as callback to filter days displayed on chart.
*/
private filterDaysDisplayed(day: string, _dayIndex: string, labelArray: string[]): string | undefined {
const eighthDayOfTheMonth = 8;
const isBeforeEighthDayOfTheMonth = labelArray.length <= eighthDayOfTheMonth;
const dayShowingConditions = new DayShowingConditions(day, labelArray);
if (isBeforeEighthDayOfTheMonth || this.areDaysShownOnEvenDaysAmount(dayShowingConditions)
|| this.areDaysShownOnNotEvenDaysAmount(dayShowingConditions)) {
return day;
}
}
/**
* Indicates if days are shown on even days amount.
*/
private areDaysShownOnEvenDaysAmount(dayShowingConditions: DayShowingConditions): boolean {
const isDaysAmountEven = dayShowingConditions.daysArray.length % 2 === 0;
const isDateValueInMiddleInEvenAmount = dayShowingConditions.day ===
dayShowingConditions.daysArray[dayShowingConditions.countMiddleDateValue() - 1];
return dayShowingConditions.isDayFirstOrLast() || (isDaysAmountEven
&& dayShowingConditions.isDayAfterEighthDayOfTheMonth() && isDateValueInMiddleInEvenAmount);
}
/**
* Indicates if days are shown on not even days amount.
*/
private areDaysShownOnNotEvenDaysAmount(dayShowingConditions: DayShowingConditions): boolean {
const isDaysAmountNotEven = dayShowingConditions.daysArray.length % 2 !== 0;
const isDateValueInMiddleInNotEvenAmount = dayShowingConditions.day
=== dayShowingConditions.daysArray[Math.floor(dayShowingConditions.countMiddleDateValue())];
return dayShowingConditions.isDayFirstOrLast() || (isDaysAmountNotEven
&& dayShowingConditions.isDayAfterEighthDayOfTheMonth() && isDateValueInMiddleInNotEvenAmount);
}
}
</script>

View File

@ -1,207 +0,0 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<div class="project-dashboard">
<h1 class="project-dashboard__title">Dashboard</h1>
<VLoader v-if="isDataFetching" class="project-dashboard__loader" width="100px" height="100px" />
<p v-else class="project-dashboard__subtitle">
Your
<span class="project-dashboard__subtitle__value">{{ limits.objectCount }} objects</span>
are stored in
<span class="project-dashboard__subtitle__value">{{ limits.segmentCount }} segments</span>
around the world
</p>
<div class="project-dashboard__info">
<InfoContainer
title="Billing"
:subtitle="status"
:value="estimatedCharges | centsToDollars"
:is-data-fetching="isDataFetching"
>
<template #side-value>
<p class="project-dashboard__info__label">Will be charged during next billing period</p>
</template>
</InfoContainer>
<InfoContainer
class="project-dashboard__info__middle"
title="Objects"
:subtitle="`Updated ${now}`"
:value="limits.objectCount"
:is-data-fetching="isDataFetching"
>
<template #side-value>
<p class="project-dashboard__info__label">Total of {{ usedFormatted }}</p>
</template>
</InfoContainer>
<InfoContainer
title="Segments"
:subtitle="`Updated ${now}`"
:value="limits.segmentCount"
:is-data-fetching="isDataFetching"
>
<template #side-value>
<a
class="project-dashboard__info__link"
href="https://docs.storj.io/dcs/billing-payment-and-accounts-1/pricing/billing-and-payment"
target="_blank"
rel="noopener noreferrer"
>
Learn more ->
</a>
</template>
</InfoContainer>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { PROJECTS_ACTIONS } from "@/store/modules/projects";
import { PAYMENTS_ACTIONS } from "@/store/modules/payments";
import { RouteConfig } from "@/router";
import { ProjectLimits } from "@/types/projects";
import { Dimensions, Size } from "@/utils/bytesSize";
import VLoader from "@/components/common/VLoader.vue";
import InfoContainer from "@/components/project/InfoContainer.vue";
// @vue/component
@Component({
components: {
VLoader,
InfoContainer,
}
})
export default class NewProjectDashboard extends Vue {
public now = new Date().toLocaleDateString('en-US');
public isDataFetching = true;
/**
* Lifecycle hook after initial render.
* Fetches project limits.
*/
public async mounted(): Promise<void> {
if (!this.$store.getters.selectedProject.id) {
await this.$router.push(RouteConfig.OnboardingTour.with(RouteConfig.OverviewStep).path);
return;
}
try {
await this.$store.dispatch(PROJECTS_ACTIONS.GET_LIMITS, this.$store.getters.selectedProject.id);
await this.$store.dispatch(PAYMENTS_ACTIONS.GET_PROJECT_USAGE_AND_CHARGES_CURRENT_ROLLUP);
this.isDataFetching = false;
} catch (error) {
await this.$notify.error(error.message);
}
}
/**
* Returns current limits from store.
*/
public get limits(): ProjectLimits {
return this.$store.state.projectsModule.currentLimits;
}
/**
* Returns status string based on account status.
*/
public get status(): string {
return this.$store.getters.user.paidTier ? 'Pro Account' : 'Free Account';
}
/**
* estimatedCharges returns estimated charges summary for selected project.
*/
public get estimatedCharges(): number {
return this.$store.state.paymentsModule.priceSummaryForSelectedProject;
}
/**
* Returns formatted used amount.
*/
public get usedFormatted(): string {
return this.formattedValue(new Size(this.limits.storageUsed, 2));
}
/**
* Formats value to needed form and returns it.
*/
private formattedValue(value: Size): string {
switch (value.label) {
case Dimensions.Bytes:
case Dimensions.KB:
return '0';
default:
return `${value.formattedBytes.replace(/\\.0+$/, '')}${value.label}`;
}
}
}
</script>
<style scoped lang="scss">
.project-dashboard {
padding: 56px 40px;
background-image: url('../../../static/images/project/background.png');
background-position: top right;
background-size: 70%;
background-repeat: no-repeat;
&__loader {
display: inline-block;
}
&__title {
font-family: 'font_medium', sans-serif;
font-size: 16px;
line-height: 24px;
color: #000;
margin-bottom: 64px;
}
&__subtitle {
font-family: 'font_bold', sans-serif;
font-size: 28px;
line-height: 36px;
letter-spacing: -0.02em;
color: #000;
max-width: 365px;
&__value {
text-decoration: underline;
text-underline-position: under;
text-decoration-color: #00e366;
}
}
&__info {
display: flex;
align-items: center;
margin-top: 100px;
&__middle {
margin: 0 16px;
}
&__label,
&__link {
font-weight: 500;
font-size: 14px;
line-height: 20px;
color: #000;
}
&__link {
text-decoration: underline !important;
text-underline-position: under;
&:visited {
color: #000;
}
}
}
}
</style>

View File

@ -5,14 +5,12 @@
<div class="no-buckets-area">
<img class="no-buckets-area__image" src="@/../static/images/buckets/bucket.png" alt="bucket image">
<h2 class="no-buckets-area__message">Create your first bucket to get started.</h2>
<a
class="no-buckets-area__first-button"
href="https://docs.storj.io/api-reference/uplink-cli"
target="_blank"
rel="noopener noreferrer"
>
Get Started
</a>
<VButton
label="Get Started"
width="156px"
height="47px"
:on-press="navigateToWelcomeScreen"
/>
<a
class="no-buckets-area__second-button"
href="https://docs.storj.io/"
@ -27,9 +25,24 @@
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { RouteConfig } from "@/router";
import VButton from "@/components/common/VButton.vue";
// @vue/component
@Component
export default class NoBucketArea extends Vue {}
@Component({
components: {
VButton,
}
})
export default class NoBucketArea extends Vue {
/**
* Navigates user to welcome screen.
*/
public navigateToWelcomeScreen(): void {
this.$router.push(RouteConfig.OnboardingTour.with(RouteConfig.OverviewStep).path);
}
}
</script>
<style scoped lang="scss">
@ -60,24 +73,6 @@ export default class NoBucketArea extends Vue {}
margin: 15px 0 30px 0;
}
&__first-button {
display: flex;
align-items: center;
justify-content: center;
width: 158px;
height: 49px;
font-size: 15px;
line-height: 22px;
border-radius: 6px;
background-color: #2683ff;
color: #fff;
margin-bottom: 7px;
&:hover {
background-color: #0059d0;
}
}
&__second-button {
display: flex;
align-items: center;
@ -90,6 +85,7 @@ export default class NoBucketArea extends Vue {}
background-color: #fff;
color: #2683ff;
border: 1px solid #2683ff;
margin-top: 7px;
&:hover {
background-color: #2683ff;

View File

@ -0,0 +1,135 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<VChart
:id="`${name}-chart`"
:key="chartKey"
:chart-data="chartData"
:width="width"
:height="height"
:tooltip-constructor="tooltip"
/>
</template>
<script lang="ts">
import { Component, Prop } from 'vue-property-decorator';
import BaseChart from '@/components/common/BaseChart.vue';
import VChart from '@/components/common/VChart.vue';
import { ChartData, Tooltip, TooltipParams, TooltipModel } from '@/types/chart';
import { ChartUtils } from "@/utils/chart";
import { DataStamp } from "@/types/projects";
import { Size } from "@/utils/bytesSize";
/**
* Stores data for chart's tooltip
*/
class ChartTooltip {
public date: string;
public value: string;
public constructor(storage: DataStamp) {
const size = new Size(storage.value, 1)
this.date = storage.intervalStart.toLocaleDateString('en-US', { day: '2-digit', month: 'short' });
this.value = `${size.formattedBytes} ${size.label}`;
}
}
// @vue/component
@Component({
components: { VChart }
})
export default class DashboardChart extends BaseChart {
@Prop({default: []})
public readonly data: DataStamp[];
@Prop({default: 'chart'})
public readonly name: string;
@Prop({default: ''})
public readonly backgroundColor: string;
@Prop({default: ''})
public readonly borderColor: string;
@Prop({default: ''})
public readonly pointBorderColor: string;
/**
* Returns formatted data to render chart.
*/
public get chartData(): ChartData {
const data: number[] = this.data.map(el => parseFloat(new Size(el.value).formattedBytes))
const xAxisDateLabels: string[] = ChartUtils.daysDisplayedOnChart(this.data[0].intervalStart, this.data[this.data.length - 1].intervalStart);
return new ChartData(
xAxisDateLabels,
this.backgroundColor,
this.borderColor,
this.pointBorderColor,
data,
);
}
/**
* Used as constructor of custom tooltip.
*/
public tooltip(tooltipModel: TooltipModel): void {
const tooltipParams = new TooltipParams(tooltipModel, `${this.name}-chart`, `${this.name}-tooltip`,
this.tooltipMarkUp(tooltipModel), 76, 81);
Tooltip.custom(tooltipParams);
}
/**
* Returns tooltip's html mark up.
*/
private tooltipMarkUp(tooltipModel: TooltipModel): string {
if (!tooltipModel.dataPoints) {
return '';
}
const dataIndex = tooltipModel.dataPoints[0].index;
const dataPoint = new ChartTooltip(this.data[dataIndex]);
return `<div class='tooltip' style="background: ${this.pointBorderColor}">
<p class='tooltip__value'>${dataPoint.date}<b class='tooltip__value__bold'> / ${dataPoint.value}</b></p>
<div class='tooltip__arrow' style="background: ${this.pointBorderColor}" />
</div>`;
}
}
</script>
<style lang="scss">
.tooltip {
margin: 8px;
position: relative;
box-shadow: 0 5px 14px rgba(9, 87, 203, 0.26);
border-radius: 100px;
padding-top: 8px;
width: 145px;
font-family: 'font_regular', sans-serif;
display: flex;
flex-direction: column;
align-items: center;
&__value {
font-size: 14px;
line-height: 26px;
text-align: center;
color: #fff;
white-space: nowrap;
&__bold {
font-family: 'font_medium', sans-serif;
}
}
&__arrow {
width: 12px;
height: 12px;
border-radius: 8px 0 0 0;
transform: scale(1, 0.85) translate(0, 20%) rotate(45deg);
margin-bottom: -4px;
}
}
</style>

View File

@ -0,0 +1,447 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<div ref="dashboard" class="project-dashboard">
<h1 class="project-dashboard__title">Dashboard</h1>
<VLoader v-if="isDataFetching" class="project-dashboard__loader" width="100px" height="100px" />
<p v-if="!isDataFetching && limits.objectCount" class="project-dashboard__subtitle">
Your
<span class="project-dashboard__subtitle__value">{{ limits.objectCount }} objects</span>
are stored in
<span class="project-dashboard__subtitle__value">{{ limits.segmentCount }} segments</span>
around the world
</p>
<template v-if="!isDataFetching && !limits.objectCount">
<p class="project-dashboard__subtitle">
Welcome to Storj :) <br> Youre ready to experience the future of cloud storage
</p>
<VButton
class="project-dashboard__upload-button"
label="Upload"
width="100px"
height="40px"
:on-press="onUploadClick"
/>
</template>
<div class="project-dashboard__stats-header">
<h2 class="project-dashboard__stats-header__title">Project Stats</h2>
<div class="project-dashboard__stats-header__buttons">
<VButton
v-if="!isProAccount"
label="Upgrade Plan"
width="114px"
height="40px"
:on-press="onUpgradeClick"
is-white="true"
/>
<VButton
v-else
label="New Project"
width="114px"
height="40px"
:on-press="onCreateProjectClick"
:is-white="!limits.objectCount"
/>
</div>
</div>
<div class="project-dashboard__charts">
<div class="project-dashboard__charts__container">
<h3 class="project-dashboard__charts__container__title">Storage</h3>
<VLoader v-if="isDataFetching" class="project-dashboard__charts__container__loader" height="40px" width="40px" />
<template v-else>
<p class="project-dashboard__charts__container__info">
Using {{ usedLimitFormatted(limits.storageUsed) }} of {{ usedLimitFormatted(limits.storageLimit) }}
</p>
<DashboardChart
name="storage"
:width="chartWidth"
:height="170"
:data="storageUsage"
background-color="#E6EDF7"
border-color="#D7E8FF"
point-border-color="#003DC1"
/>
</template>
</div>
<div class="project-dashboard__charts__container">
<h3 class="project-dashboard__charts__container__title">Bandwidth</h3>
<VLoader v-if="isDataFetching" class="project-dashboard__charts__container__loader" height="40px" width="40px" />
<template v-else>
<p class="project-dashboard__charts__container__info">
Using {{ usedLimitFormatted(limits.bandwidthUsed) }} of {{ usedLimitFormatted(limits.bandwidthLimit) }}
</p>
<DashboardChart
name="bandwidth"
:width="chartWidth"
:height="170"
:data="bandwidthUsage"
background-color="#FFE0E7"
border-color="#FFC0CF"
point-border-color="#FF458B"
/>
</template>
</div>
</div>
<div class="project-dashboard__info">
<InfoContainer
title="Billing"
:subtitle="status"
:value="estimatedCharges | centsToDollars"
:is-data-fetching="isDataFetching"
>
<template #side-value>
<p class="project-dashboard__info__label">Will be charged during next billing period</p>
</template>
</InfoContainer>
<InfoContainer
class="project-dashboard__info__middle"
title="Objects"
:subtitle="`Updated ${now}`"
:value="limits.objectCount"
:is-data-fetching="isDataFetching"
>
<template #side-value>
<p class="project-dashboard__info__label">Total of {{ usedLimitFormatted(limits.storageUsed) }}</p>
</template>
</InfoContainer>
<InfoContainer
title="Segments"
:subtitle="`Updated ${now}`"
:value="limits.segmentCount"
:is-data-fetching="isDataFetching"
>
<template #side-value>
<a
class="project-dashboard__info__link"
href="https://docs.storj.io/dcs/billing-payment-and-accounts-1/pricing/billing-and-payment"
target="_blank"
rel="noopener noreferrer"
>
Learn more ->
</a>
</template>
</InfoContainer>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { PROJECTS_ACTIONS } from "@/store/modules/projects";
import { PAYMENTS_ACTIONS, PAYMENTS_MUTATIONS } from "@/store/modules/payments";
import { RouteConfig } from "@/router";
import { DataStamp, ProjectLimits, ProjectsStorageBandwidthDaily } from "@/types/projects";
import { Dimensions, Size } from "@/utils/bytesSize";
import VLoader from "@/components/common/VLoader.vue";
import InfoContainer from "@/components/project/newProjectDashboard/InfoContainer.vue";
import DashboardChart from "@/components/project/newProjectDashboard/DashboardChart.vue";
import VButton from "@/components/common/VButton.vue";
// @vue/component
@Component({
components: {
VLoader,
VButton,
InfoContainer,
DashboardChart,
}
})
export default class NewProjectDashboard extends Vue {
public now = new Date().toLocaleDateString('en-US');
public isDataFetching = true;
public chartWidth = 0;
public $refs: {
dashboard: HTMLDivElement;
}
/**
* Lifecycle hook after initial render.
* Fetches project limits.
*/
public async mounted(): Promise<void> {
if (!this.$store.getters.selectedProject.id) {
await this.$router.push(RouteConfig.OnboardingTour.with(RouteConfig.OverviewStep).path);
return;
}
window.addEventListener('resize', this.recalculateChartWidth)
this.recalculateChartWidth();
try {
await this.$store.dispatch(PROJECTS_ACTIONS.FETCH_DAILY_DATA, new ProjectsStorageBandwidthDaily(this.chartTestData(), this.chartTestData()));
await this.$store.dispatch(PROJECTS_ACTIONS.GET_LIMITS, this.$store.getters.selectedProject.id);
await this.$store.dispatch(PAYMENTS_ACTIONS.GET_PROJECT_USAGE_AND_CHARGES_CURRENT_ROLLUP);
this.isDataFetching = false;
} catch (error) {
await this.$notify.error(error.message);
}
}
/**
* Lifecycle hook before component destruction.
* Removes event on window resizing.
*/
public beforeDestroy(): void {
window.removeEventListener('resize', this.recalculateChartWidth);
}
/**
* Used container size recalculation for charts resizing.
*/
public recalculateChartWidth(): void {
const fiftyPixels = 50;
this.chartWidth = this.$refs.dashboard.getBoundingClientRect().width / 2 - fiftyPixels;
}
/**
* Holds on upgrade button click logic.
*/
public onUpgradeClick(): void {
this.$store.commit(PAYMENTS_MUTATIONS.TOGGLE_IS_ADD_PM_MODAL_SHOWN);
}
/**
* Holds on create project button click logic.
*/
public onCreateProjectClick(): void {
this.$router.push(RouteConfig.CreateProject.path);
}
/**
* Holds on upload button click logic.
*/
public onUploadClick(): void {
this.$router.push(RouteConfig.Buckets.path).catch(() => {return;})
}
/**
* Returns formatted amount.
*/
public usedLimitFormatted(value: number): string {
return this.formattedValue(new Size(value, 2));
}
/**
* Returns current limits from store.
*/
public get limits(): ProjectLimits {
return this.$store.state.projectsModule.currentLimits;
}
/**
* Returns status string based on account status.
*/
public get status(): string {
return this.isProAccount ? 'Pro Account' : 'Free Account';
}
/**
* Returns pro account status from store.
*/
public get isProAccount(): boolean {
return this.$store.getters.user.paidTier;
}
/**
* Returns user's projects count from store.
*/
public get ownProjectsCount(): number {
return this.$store.getters.projectsCount;
}
/**
* estimatedCharges returns estimated charges summary for selected project.
*/
public get estimatedCharges(): number {
return this.$store.state.paymentsModule.priceSummaryForSelectedProject;
}
/**
* Returns storage chart data from store.
*/
public get storageUsage(): DataStamp[] {
return this.$store.state.projectsModule.storageChartData;
}
/**
* Returns bandwidth chart data from store.
*/
public get bandwidthUsage(): DataStamp[] {
return this.$store.state.projectsModule.bandwidthChartData;
}
/**
* Formats value to needed form and returns it.
*/
private formattedValue(value: Size): string {
switch (value.label) {
case Dimensions.Bytes:
return '0';
default:
return `${value.formattedBytes.replace(/\\.0+$/, '')}${value.label}`;
}
}
// TODO: remove when backend is ready
private chartTestData(): DataStamp[] {
const startDate = new Date("2021-10-01");
const endDate = new Date("2021-10-31");
const arr = new Array<Date>();
const dt = new Date(startDate);
while (dt <= endDate) {
arr.push(new Date(dt));
dt.setDate(dt.getDate() + 1);
}
return arr.map(d => {
return new DataStamp(Math.floor(Math.random() * 1000000000000), d);
})
}
}
</script>
<style scoped lang="scss">
.project-dashboard {
padding: 56px 40px;
height: calc(100% - 112px);
max-width: calc(100vw - 280px - 80px);
background-image: url('../../../../static/images/project/background.png');
background-position: top right;
background-size: 70%;
background-repeat: no-repeat;
font-family: 'font_regular', sans-serif;
&__loader {
display: inline-block;
}
&__title {
font-family: 'font_medium', sans-serif;
font-size: 16px;
line-height: 24px;
color: #000;
margin-bottom: 64px;
}
&__subtitle {
font-family: 'font_bold', sans-serif;
font-size: 28px;
line-height: 36px;
letter-spacing: -0.02em;
color: #000;
max-width: 365px;
&__value {
text-decoration: underline;
text-underline-position: under;
text-decoration-color: #00e366;
}
}
&__upload-button {
margin-top: 24px;
}
&__stats-header {
display: flex;
align-items: center;
justify-content: space-between;
margin: 65px 0 16px 0;
&__title {
font-family: 'font_Bold', sans-serif;
font-size: 24px;
line-height: 31px;
letter-spacing: -0.02em;
color: #000;
}
&__buttons {
display: flex;
align-items: center;
> *:last-child {
margin-left: 25px;
}
}
}
&__charts {
display: flex;
align-items: center;
&__container {
width: 100%;
background-color: #fff;
box-shadow: 0 0 32px rgba(0, 0, 0, 0.04);
border-radius: 10px;
&__title {
margin: 16px 0 2px 24px;
font-family: 'font_medium', sans-serif;
font-size: 18px;
line-height: 27px;
color: #000;
}
&__loader {
margin-bottom: 15px;
}
&__info {
margin: 2px 0 0 24px;
font-weight: 600;
font-size: 14px;
line-height: 20px;
color: #000;
}
}
> *:first-child {
margin-right: 20px;
}
}
&__info {
display: flex;
align-items: center;
margin-top: 16px;
&__middle {
margin: 0 16px;
}
&__label,
&__link {
font-weight: 500;
font-size: 14px;
line-height: 20px;
color: #000;
}
&__link {
text-decoration: underline !important;
text-underline-position: under;
&:visited {
color: #000;
}
}
}
}
@media screen and (max-width: 1280px) {
.project-dashboard {
max-width: calc(100vw - 86px - 80px);
}
}
</style>

View File

@ -32,7 +32,7 @@ import OldOverviewStep from '@/components/onboardingTour/steps/oldFlow/OldOvervi
import CreateProject from '@/components/project/CreateProject.vue';
import EditProjectDetails from '@/components/project/EditProjectDetails.vue';
import ProjectDashboard from '@/components/project/ProjectDashboard.vue';
import NewProjectDashboard from "@/components/project/NewProjectDashboard.vue";
import NewProjectDashboard from "@/components/project/newProjectDashboard/NewProjectDashboard.vue";
import ProjectsList from '@/components/projectsList/ProjectsList.vue';
import ProjectMembersArea from '@/components/team/ProjectMembersArea.vue';
import CLIInstall from "@/components/onboardingTour/steps/cliFlow/CLIInstall.vue";

View File

@ -3,17 +3,20 @@
import { StoreModule } from '@/store';
import {
DataStamp,
Project,
ProjectFields,
ProjectLimits,
ProjectsApi,
ProjectsCursor,
ProjectsPage,
ProjectsStorageBandwidthDaily,
} from '@/types/projects';
export const PROJECTS_ACTIONS = {
FETCH: 'fetchProjects',
FETCH_OWNED: 'fetchOwnedProjects',
FETCH_DAILY_DATA: 'fetchDailyData',
CREATE: 'createProject',
CREATE_DEFAULT_PROJECT: 'createDefaultProject',
SELECT: 'selectProject',
@ -41,6 +44,7 @@ export const PROJECTS_MUTATIONS = {
SET_TOTAL_LIMITS: 'SET_TOTAL_LIMITS',
SET_PAGE_NUMBER: 'SET_PAGE_NUMBER',
SET_PAGE: 'SET_PAGE',
SET_DAILY_DATA: 'SET_DAILY_DATA',
};
const defaultSelectedProject = new Project('', '', '', '', '', true, 0);
@ -52,6 +56,8 @@ export class ProjectsState {
public totalLimits: ProjectLimits = new ProjectLimits();
public cursor: ProjectsCursor = new ProjectsCursor();
public page: ProjectsPage = new ProjectsPage();
public bandwidthChartData: DataStamp[] = [];
public storageChartData: DataStamp[] = [];
}
interface ProjectsContext {
@ -67,6 +73,7 @@ interface ProjectsContext {
const {
FETCH,
FETCH_DAILY_DATA,
CREATE,
CREATE_DEFAULT_PROJECT,
SELECT,
@ -95,6 +102,7 @@ const {
SET_TOTAL_LIMITS,
SET_PAGE_NUMBER,
SET_PAGE,
SET_DAILY_DATA,
} = PROJECTS_MUTATIONS;
const projectsPageLimit = 7;
@ -174,6 +182,10 @@ export function makeProjectsModule(api: ProjectsApi): StoreModule<ProjectsState,
[SET_PAGE](state: ProjectsState, page: ProjectsPage) {
state.page = page;
},
[SET_DAILY_DATA](state: ProjectsState, payload: ProjectsStorageBandwidthDaily) {
state.bandwidthChartData = payload.bandwidth;
state.storageChartData = payload.storage;
},
},
actions: {
[FETCH]: async function ({commit}: ProjectsContext): Promise<Project[]> {
@ -191,6 +203,10 @@ export function makeProjectsModule(api: ProjectsApi): StoreModule<ProjectsState,
return projectsPage;
},
[FETCH_DAILY_DATA]: async function ({commit}: ProjectsContext, payload: ProjectsStorageBandwidthDaily): Promise<void> {
// TODO: rework when backend is ready
commit(SET_DAILY_DATA, payload);
},
[CREATE]: async function ({commit}: ProjectsContext, createProjectFields: ProjectFields): Promise<Project> {
const project = await api.create(createProjectFields);

View File

@ -0,0 +1,261 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
/**
* ChartData class holds info for ChartData entity.
*/
export class ChartData {
public labels: string[];
public datasets: DataSets[] = [];
public constructor(
labels: string[],
backgroundColor: string,
borderColor: string,
pointBorderColor: string,
data: number[],
) {
this.labels = labels;
for (let i = 0; i < this.labels.length; i++) {
this.datasets[i] = new DataSets(
backgroundColor,
borderColor,
pointBorderColor,
data,
);
}
}
}
/**
* DataSets class holds info for chart's DataSets entity.
*/
class DataSets {
public constructor(
public backgroundColor: string,
public borderColor: string,
public pointBorderColor: string,
public data: number[],
public borderWidth: number = 4,
public pointHoverBackgroundColor: string = 'white',
public pointHoverBorderWidth: number = 5,
) {}
}
/**
* TooltipParams holds tooltip's configuration
*/
export class TooltipParams {
public constructor(
public tooltipModel: TooltipModel,
public chartId: string,
public tooltipId: string,
public markUp: string,
public tooltipTop: number,
public tooltipLeft: number,
) {}
}
/**
* StylingConstants holds tooltip styling constants
*/
class StylingConstants {
public static tooltipOpacity = '1';
public static tooltipPosition = 'absolute';
}
/**
* Styling holds tooltip's styling configuration
*/
class Styling {
public constructor(
public tooltipModel: TooltipModel,
public element: HTMLElement,
public topPosition: number,
public leftPosition: number,
public chartPosition: ClientRect,
) {}
}
/**
* Color is a color definition.
*/
export type Color = string
/**
* TooltipItem contains datapoint information.
*/
export interface TooltipItem {
// Label for the tooltip
label: string,
// Value for the tooltip
value: string,
// X Value of the tooltip
// (deprecated) use `value` or `label` instead
xLabel: number | string,
// Y value of the tooltip
// (deprecated) use `value` or `label` instead
yLabel: number | string,
// Index of the dataset the item comes from
datasetIndex: number,
// Index of this data item in the dataset
index: number,
// X position of matching point
x: number,
// Y position of matching point
y: number
}
/**
* TooltipModel contains parameters that can be used to render the tooltip.
*/
export interface TooltipModel {
// The items that we are rendering in the tooltip. See Tooltip Item Interface section
dataPoints: TooltipItem[],
// Positioning
xPadding: number,
yPadding: number,
xAlign: string,
yAlign: string,
// X and Y properties are the top left of the tooltip
x: number,
y: number,
width: number,
height: number,
// Where the tooltip points to
caretX: number,
caretY: number,
// Body
// The body lines that need to be rendered
// Each object contains 3 parameters
// before: string[] // lines of text before the line with the color square
// lines: string[], // lines of text to render as the main item with color square
// after: string[], // lines of text to render after the main lines
body: {before: string[]; lines: string[], after: string[]}[],
// lines of text that appear after the title but before the body
beforeBody: string[],
// line of text that appear after the body and before the footer
afterBody: string[],
bodyFontColor: Color,
_bodyFontFamily: string,
_bodyFontStyle: string,
_bodyAlign: string,
bodyFontSize: number,
bodySpacing: number,
// Title
// lines of text that form the title
title: string[],
titleFontColor: Color,
_titleFontFamily: string,
_titleFontStyle: string,
titleFontSize: number,
_titleAlign: string,
titleSpacing: number,
titleMarginBottom: number,
// Footer
// lines of text that form the footer
footer: string[],
footerFontColor: Color,
_footerFontFamily: string,
_footerFontStyle: string,
footerFontSize: number,
_footerAlign: string,
footerSpacing: number,
footerMarginTop: number,
// Appearance
caretSize: number,
caretPadding: number,
cornerRadius: number,
backgroundColor: Color,
// colors to render for each item in body[]. This is the color of the squares in the tooltip
labelColors: Color[],
labelTextColors: Color[],
// 0 opacity is a hidden tooltip
opacity: number,
legendColorBackground: Color,
displayColors: boolean,
borderColor: Color,
borderWidth: number
}
/**
* Tooltip provides custom tooltip rendering
*/
export class Tooltip {
public static custom(params: TooltipParams): void {
const chart = document.getElementById(params.chartId);
if (!chart) {
return;
}
const tooltip: HTMLElement = Tooltip.createTooltip(params.tooltipId);
if (!params.tooltipModel.opacity) {
Tooltip.remove(tooltip);
return;
}
if (params.tooltipModel.body) {
Tooltip.render(tooltip, params.markUp);
}
const position = chart.getBoundingClientRect();
const tooltipStyling = new Styling(params.tooltipModel, tooltip, params.tooltipTop, params.tooltipLeft, position);
Tooltip.elemStyling(tooltipStyling);
}
private static createTooltip(id: string): HTMLElement {
let tooltipEl = document.getElementById(id);
if (!tooltipEl) {
tooltipEl = document.createElement('div');
tooltipEl.id = id;
document.body.appendChild(tooltipEl);
}
return tooltipEl;
}
private static remove(tooltipEl: HTMLElement) {
document.body.removeChild(tooltipEl);
}
private static render(tooltip: HTMLElement, markUp: string) {
tooltip.innerHTML = markUp;
}
private static elemStyling(elemStyling: Styling) {
elemStyling.element.style.opacity = StylingConstants.tooltipOpacity;
elemStyling.element.style.position = StylingConstants.tooltipPosition;
elemStyling.element.style.left = `${elemStyling.chartPosition.left + elemStyling.tooltipModel.caretX - elemStyling.leftPosition}px`;
elemStyling.element.style.top = `${elemStyling.chartPosition.top + window.pageYOffset + elemStyling.tooltipModel.caretY - elemStyling.topPosition}px`;
}
}
/**
* RenderChart contains definition for renderChart and addPlugin, that can be used to cast
* a derived chart type, with `(this as unknown as RenderChart).renderChart`
*/
export interface RenderChart {
renderChart<A, B>(A, B): void
addPlugin (plugin?: Record<string, (chart) => void>): void
}

View File

@ -23,8 +23,8 @@ export interface ProjectsApi {
* Update project name and description.
*
* @param projectId - project ID
* @param name - project name
* @param description - project description
* @param updateProjectFields - project fields to update
* @param updateProjectLimits - project limits to update
* @returns Project[]
* @throws Error
*/
@ -179,3 +179,39 @@ export class ProjectsCursor {
public page: number = 0,
) {}
}
/**
* DataStamp is storage/bandwidth usage stamp for satellite at some point in time
*/
export class DataStamp {
public value: number;
public intervalStart: Date;
public constructor(value = 0, intervalStart: Date = new Date()) {
this.value = value;
this.intervalStart = intervalStart;
}
/**
* Creates new empty instance of stamp with defined date
* @param date - holds specific date of the month
* @returns Stamp - new empty instance of stamp with defined date
*/
public static emptyWithDate(date: number): DataStamp {
const now = new Date();
now.setUTCDate(date);
now.setUTCHours(0, 0, 0, 0);
return new DataStamp(0, now);
}
}
/**
* ProjectsStorageBandwidthDaily is used to describe project's daily storage and bandwidth usage.
*/
export class ProjectsStorageBandwidthDaily {
public constructor(
public bandwidth: DataStamp[] = [],
public storage: DataStamp[] = [],
) {}
}

View File

@ -11,7 +11,7 @@ export enum Memory {
}
export enum Dimensions {
Bytes = 'Bytes',
Bytes = 'B',
KB = 'KB',
MB = 'MB',
GB = 'GB',

View File

@ -0,0 +1,27 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
export class ChartUtils {
/**
* Used to display correct number of days on chart's labels.
*
* @returns daysDisplayed - array of days converted to a string by using the current locale
*/
public static daysDisplayedOnChart(start: Date, end: Date): string[] {
const arr = Array<string>();
if (start === end) {
arr.push(`${start.getMonth() + 1}/${start.getDate()}`);
return arr;
}
const dt = start;
while (dt <= end) {
arr.push(`${dt.getMonth() + 1}/${dt.getDate()}`);
dt.setDate(dt.getDate() + 1)
}
return arr;
}
}

View File

@ -2,9 +2,8 @@
exports[`NoBucketsArea.vue renders correctly 1`] = `
<div class="no-buckets-area"><img src="@/../static/images/buckets/bucket.png" alt="bucket image" class="no-buckets-area__image">
<h2 class="no-buckets-area__message">Create your first bucket to get started.</h2> <a href="https://docs.storj.io/api-reference/uplink-cli" target="_blank" rel="noopener noreferrer" class="no-buckets-area__first-button">
Get Started
</a> <a href="https://docs.storj.io/" target="_blank" rel="noopener noreferrer" class="no-buckets-area__second-button">
<h2 class="no-buckets-area__message">Create your first bucket to get started.</h2>
<vbutton-stub label="Get Started" width="156px" height="47px" borderradius="6px" onpress="function () { [native code] }"></vbutton-stub> <a href="https://docs.storj.io/" target="_blank" rel="noopener noreferrer" class="no-buckets-area__second-button">
Visit the Docs
</a>
</div>