web/multinode: payouts page markup

WHAT: total payouts table, period calendar, details area and payout history area markup
also this component fumctionally connected to api and vuex store

Change-Id: Id3abc87bc7545aa3fc0a7ef2e480a8ed73974b76
This commit is contained in:
NickolaiYurchenko 2021-04-27 20:51:45 +03:00 committed by Nikolay Yurchenko
parent 6ee2210297
commit 2ccbb32e14
25 changed files with 1253 additions and 209 deletions

View File

@ -26,7 +26,8 @@ export class PayoutsClient extends APIClient {
* Thrown if something goes wrong on server side.
*/
public async summary(satelliteId: string | null, period: string | null): Promise<PayoutsSummary> {
const path = `${this.ROOT_PATH}/summary`;
const path = `${this.ROOT_PATH}/summary?satelliteId=${satelliteId}&period=${period}`;
const response = await this.http.get(path);
if (!response.ok) {

View File

@ -0,0 +1,26 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<div class="info-block">
<slot name="body"></slot>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
@Component
export default class InfoBlock extends Vue {}
</script>
<style scoped lang="scss">
.info-block {
box-sizing: border-box;
padding: 30px;
background: transparent;
border: 1px solid var(--c-gray--light);
border-radius: var(--br-block);
width: 100%;
}
</style>

View File

@ -0,0 +1,101 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<div class="options-button" @click.stop="toggleOptions">
<more-icon />
<div class="options" v-if="areOptionsShown" v-click-outside="closeOptions">
<div @click.stop="onCopy" class="options__item">Copy Node ID</div>
<delete-node :node-id="id" />
<update-name :node-id="id" />
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import DeleteNode from '@/app/components/modals/DeleteNode.vue';
import UpdateName from '@/app/components/modals/UpdateName.vue';
import MoreIcon from '@/../static/images/icons/more.svg';
@Component({
components: {
UpdateName,
DeleteNode,
MoreIcon,
},
})
export default class NodeItem extends Vue {
@Prop({default: ''})
public id: string;
public areOptionsShown: boolean = false;
public toggleOptions(): void {
this.areOptionsShown = !this.areOptionsShown;
}
public closeOptions(): void {
if (!this.areOptionsShown) return;
this.areOptionsShown = false;
}
/**
* Copies node id to clipboard and closes popup.
*/
public async onCopy(): Promise<void> {
try {
await this.$copyText(this.id);
} catch (error) {
console.error(error.message);
}
this.closeOptions();
}
}
</script>
<style scoped lang="scss">
.options-button {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
&:hover {
background: var(--c-background);
}
}
.options {
position: absolute;
top: 16px;
right: 55px;
width: 140px;
height: auto;
background: white;
border-radius: var(--br-table);
font-family: 'font_medium', sans-serif;
border: 1px solid var(--c-gray--light);
font-size: 14px;
color: var(--c-title);
z-index: 999;
&__item {
box-sizing: border-box;
padding: 16px;
cursor: pointer;
text-align: left;
&:hover {
background: var(--c-background);
}
}
}
</style>

View File

@ -0,0 +1,43 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<v-dropdown :options="trustedSatellitesOptions" />
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import VDropdown, { Option } from '@/app/components/common/VDropdown.vue';
import NodesTable from '@/app/components/myNodes/tables/NodesTable.vue';
import { NodeURL } from '@/nodes';
@Component({
components: { VDropdown, NodesTable },
})
export default class SatelliteSelectionDropdown extends Vue {
/**
* List of trusted satellites and all satellites options.
*/
public get trustedSatellitesOptions(): Option[] {
const trustedSatellites: NodeURL[] = this.$store.state.nodes.trustedSatellites;
const options: Option[] = trustedSatellites.map(
(satellite: NodeURL) => {
return new Option(satellite.id, () => this.onSatelliteClick(satellite.id));
},
);
return [ new Option('All Satellites', () => this.onSatelliteClick()), ...options ];
}
/**
* Callback for satellite click.
* @param id
*/
public async onSatelliteClick(id: string = ''): Promise<void> {
await this.$store.dispatch('nodes/selectSatellite', id);
}
}
</script>

View File

@ -9,6 +9,9 @@
v-if="options.length"
>
<span class="label">{{ selectedOption.label }}</span>
<svg width="8" height="4" viewBox="0 0 8 4" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.33657 3.73107C3.70296 4.09114 4.29941 4.08814 4.66237 3.73107L7.79796 0.650836C8.16435 0.291517 8.01864 0 7.47247 0L0.526407 0C-0.0197628 0 -0.16292 0.294525 0.200917 0.650836L3.33657 3.73107Z" fill="#131D3A"/>
</svg>
<div class="dropdown__selection" v-if="areOptionsShown" v-click-outside="closeOptions">
<div class="dropdown__selection__overflow-container">
<div v-for="option in options" :key="option.label" class="dropdown__selection__option" @click="onOptionClick(option)">
@ -77,7 +80,8 @@ export default class VDropdown extends Vue {
.dropdown {
position: relative;
box-sizing: border-box;
width: 300px;
width: 100%;
max-width: 300px;
height: 40px;
background: transparent;
display: flex;
@ -114,7 +118,7 @@ export default class VDropdown extends Vue {
&__overflow-container {
overflow: overlay;
overflow-x: hidden;
height: 160px;
max-height: 160px;
}
&__option {

View File

@ -0,0 +1,87 @@
// Copyright (C) 2020 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<tr class="node-item">
<th class="align-left">{{ node.displayedName }}</th>
<template v-if="isSatelliteSelected">
<th>{{ node.suspensionScore | floatToPercentage }}</th>
<th>{{ node.auditScore | floatToPercentage }}</th>
<th>{{ node.onlineScore | floatToPercentage }}</th>
</template>
<template v-else>
<th>{{ node.diskSpaceUsed | bytesToBase10String }}</th>
<th>{{ node.diskSpaceLeft | bytesToBase10String }}</th>
<th>{{ node.bandwidthUsed | bytesToBase10String }}</th>
</template>
<th>{{ node.earned | centsToDollars }}</th>
<th>{{ node.version }}</th>
<th :class="node.status">{{ node.status }}</th>
<th class="overflow-visible">
<node-options :id="node.id" />
</th>
</tr>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import NodeOptions from '@/app/components/common/NodeOptions.vue';
import { Node } from '@/nodes';
@Component({
components: { NodeOptions },
})
export default class NodeItem extends Vue {
@Prop({default: () => new Node()})
public node: Node;
public get isSatelliteSelected(): boolean {
return !!this.$store.state.nodes.selectedSatellite;
}
}
</script>
<style scoped lang="scss">
.node-item {
height: 56px;
text-align: right;
font-size: 16px;
color: var(--c-line);
th {
box-sizing: border-box;
padding: 0 20px;
max-width: 250px;
white-space: nowrap;
text-overflow: ellipsis;
position: relative;
overflow: hidden;
}
&:nth-of-type(even) {
background: var(--c-block-gray);
}
th:not(:first-of-type) {
font-family: 'font_medium', sans-serif;
}
}
.online {
color: var(--c-success);
}
.offline {
color: var(--c-error);
}
.align-left {
text-align: left;
}
.overflow-visible {
overflow: visible !important;
}
</style>

View File

@ -31,7 +31,7 @@
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import NodeItem from '@/app/components/tables/NodeItem.vue';
import NodeItem from '@/app/components/myNodes/tables/NodeItem.vue';
import { Node } from '@/nodes';

View File

@ -0,0 +1,104 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<info-block>
<div class="details-area" slot="body">
<header class="details-area__header">
<h3 class="details-area__header__title">Details</h3>
<p class="details-area__header__period">{{ period }}</p>
</header>
<div class="details-area__content">
<div class="details-area__content__item">
<p class="details-area__content__item__label">EARNED</p>
<p class="details-area__content__item__value">{{ totalEarned | centsToDollars }}</p>
</div>
<div class="details-area__content__item">
<p class="details-area__content__item__label">HELD</p>
<p class="details-area__content__item__value">{{ totalHeld | centsToDollars }}</p>
</div>
<div class="details-area__content__item">
<p class="details-area__content__item__label">PAID</p>
<p class="details-area__content__item__value">{{ totalPaid | centsToDollars }}</p>
</div>
</div>
</div>
</info-block>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import InfoBlock from '../common/InfoBlock.vue';
@Component({
components: { InfoBlock },
})
export default class DetailsArea extends Vue {
@Prop({default: 0})
public totalEarned: number;
@Prop({default: 0})
public totalHeld: number;
@Prop({default: 0})
public totalPaid: number;
public get period(): string {
return this.$store.getters['payouts/periodString'];
}
}
</script>
<style scoped lang="scss">
.details-area {
box-sizing: border-box;
width: 100%;
&__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 30px;
&__title {
font-family: 'font_bold', sans-serif;
font-size: 24px;
line-height: 24px;
color: var(--c-title);
}
&__period {
font-family: 'font_regular', sans-serif;
font-size: 16px;
color: var(--c-gray);
}
}
&__content {
display: flex;
align-items: flex-end;
justify-content: space-between;
&__item {
display: flex;
flex-direction: column;
align-items: flex-start;
font-family: 'font_semiBold', sans-serif;
&:last-of-type {
align-items: flex-end;
}
&__label {
font-size: 12px;
color: var(--c-gray);
margin-bottom: 10px;
}
&__value {
font-size: 18px;
color: var(--c-title);
}
}
}
}
</style>

View File

@ -0,0 +1,48 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<info-block>
<div class="payouts-history-block" slot="body">
<h3 class="payouts-history-block__title">Payout History</h3>
<v-button class="payouts-history-block__button" label="Download" :is-white="true" width="100%" :on-press="() => {}" />
</div>
</info-block>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import InfoBlock from '@/app/components/common/InfoBlock.vue';
import VButton from '@/app/components/common/VButton.vue';
@Component({
components: {
InfoBlock,
VButton,
},
})
export default class PayoutHistoryBlock extends Vue {}
</script>
<style scoped lang="scss">
.payouts-history-block {
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
&__title {
font-family: 'font_bold', sans-serif;
font-size: 24px;
color: var(--c-title);
margin-bottom: 30px;
}
&__button {
background-color: var(--c-button-gray);
border: none !important;
}
}
</style>

View File

@ -0,0 +1,338 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<div class="payout-period-calendar">
<div class="payout-period-calendar__header">
<div class="payout-period-calendar__header__year-selection">
<div class="payout-period-calendar__header__year-selection__prev" @click="decrementYear">
<GrayArrowLeftIcon />
</div>
<p class="payout-period-calendar__header__year-selection__year">{{ displayedYear }}</p>
<div class="payout-period-calendar__header__year-selection__next" @click="incrementYear">
<GrayArrowLeftIcon />
</div>
</div>
<p class="payout-period-calendar__header__all-time" @click="selectAllTime" >All time</p>
</div>
<div class="payout-period-calendar__months-area">
<div
class="month-item"
:class="{ selected: item.selected, disabled: !item.active }"
v-for="item in currentDisplayedMonths"
:key="item.name"
@click="checkMonth(item)"
>
<p class="month-item__label">{{ item.name }}</p>
</div>
</div>
<div class="payout-period-calendar__footer-area">
<p class="payout-period-calendar__footer-area__period">{{ period }}</p>
<v-button label="Done" :is-disabled="!period" width="73px" height="40px" :on-press="submit" />
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import VButton from '@/app/components/common/VButton.vue';
import GrayArrowLeftIcon from '@/../static/images/icons/GrayArrowLeft.svg';
import { UnauthorizedError } from '@/api';
import { monthNames } from '@/app/types/date';
import { MonthButton, StoredMonthsByYear } from '@/app/types/payouts';
@Component({
components: {
VButton,
GrayArrowLeftIcon,
},
})
export default class PayoutHistoryPeriodCalendar extends Vue {
private now: Date = new Date();
/**
* Contains current months list depends on active and selected month state.
*/
public currentDisplayedMonths: MonthButton[] = [];
public displayedYear: number = this.now.getUTCFullYear();
public period: string = '';
private displayedMonths: StoredMonthsByYear = {};
private selectedMonth: MonthButton | null;
/**
* Lifecycle hook after initial render.
* Sets up current calendar state.
*/
public mounted(): void {
this.populateMonths(this.displayedYear);
this.currentDisplayedMonths = this.displayedMonths[this.displayedYear];
}
/**
* Fetches payout information.
*/
public async submit(): Promise<void> {
let period: string | null = null;
if (this.selectedMonth) {
const month = this.selectedMonth.index < 9 ? '0' + (this.selectedMonth.index + 1) : (this.selectedMonth.index + 1);
period = `${this.selectedMonth.year}-${month}`;
}
this.$store.commit('payouts/setPayoutPeriod', period);
try {
await this.$store.dispatch('payouts/summary');
} catch (error) {
if (error instanceof UnauthorizedError) {
// TODO: redirect to login screen.
}
// TODO: notify error
}
this.close();
}
/**
* Updates selected period label.
*/
public updatePeriod(): void {
if (!this.selectedMonth) {
this.period = 'All time';
return;
}
this.period = `${monthNames[this.selectedMonth.index]}, ${this.selectedMonth.year}`;
}
/**
* Updates first selected month on click.
*/
public checkMonth(month: MonthButton): void {
if (!month.active || month.selected) {
return;
}
if (this.selectedMonth) {
this.selectedMonth.selected = false;
}
this.selectedMonth = month;
month.selected = true;
this.updatePeriod();
}
/**
* selectAllTime resets selected payout period.
*/
public selectAllTime(): void {
if (this.selectedMonth) {
this.selectedMonth.selected = false;
}
this.selectedMonth = null;
this.updatePeriod();
}
/**
* Increments year and updates current months set.
*/
public incrementYear(): void {
const isCurrentYear = this.displayedYear === this.now.getUTCFullYear();
if (isCurrentYear) return;
this.displayedYear += 1;
this.populateMonths(this.displayedYear);
this.currentDisplayedMonths = this.displayedMonths[this.displayedYear];
}
/**
* Decrement year and updates current months set.
*/
public decrementYear(): void {
// TODO: remove hardcoded value
const minYear: number = 2000;
if (this.displayedYear === minYear) return;
this.displayedYear -= 1;
this.populateMonths(this.displayedYear);
this.currentDisplayedMonths = this.displayedMonths[this.displayedYear];
}
/**
* Sets months set in displayedMonths with year as key.
*/
private populateMonths(year: number): void {
if (this.displayedMonths[year]) {
this.currentDisplayedMonths = this.displayedMonths[year];
return;
}
const months: MonthButton[] = [];
// Creates months entities and adds them to list.
for (let i = 0; i < 12; i++) {
months.push(new MonthButton(year, i, true, false));
}
this.displayedMonths[year] = months;
}
/**
* Closes calendar.
*/
private close(): void {
this.$emit('onClose');
}
}
</script>
<style scoped lang="scss">
.payout-period-calendar {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
width: 333px;
height: 340px;
background: white;
box-shadow: 0 10px 25px rgba(175, 183, 193, 0.1);
border-radius: var(--br-block);
border: 1px solid #e1e3e6;
padding: 30px;
font-family: 'font_regular', sans-serif;
cursor: default;
z-index: 1001;
&__header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 100%;
&__year-selection {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
&__prev,
&__next {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
height: 30px;
width: 20px;
}
&__prev {
margin-right: 20px;
}
&__year {
font-family: 'font_bold', sans-serif;
font-size: 24px;
line-height: 28px;
color: var(--c-title);
}
&__next {
transform: rotate(180deg);
margin-left: 20px;
}
}
&__all-time {
font-family: 'font_regular', sans-serif;
font-size: 16px;
color: var(--c-primary);
cursor: pointer;
}
}
&__months-area {
display: grid;
grid-template-columns: 93px 93px 93px;
grid-gap: 1px;
background: var(--c-gray--light);
overflow: hidden;
border-radius: var(--br-table);
border: 1px solid var(--c-gray--light);
}
&__footer-area {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
height: 20px;
width: 100%;
margin-top: 7px;
&__period {
font-family: 'font_semiBold', sans-serif;
font-size: 16px;
color: var(--c-payout-period);
max-width: 50%;
}
&__ok-button {
font-family: 'font_bold', sans-serif;
font-size: 16px;
line-height: 23px;
color: var(--c-button-common);
cursor: pointer;
}
}
}
.month-item {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 44px;
background: white;
cursor: pointer;
&__label {
font-size: 16px;
line-height: 18px;
color: var(--c-title);
}
}
.disabled {
background: var(--c-button-disabled);
cursor: default;
.month-item__label {
color: var(--c-gray) !important;
}
}
.selected {
background: var(--c-primary);
.month-item__label {
color: white !important;
}
}
.arrow-icon {
path {
fill: var(--c-gray);
}
}
</style>

View File

@ -0,0 +1,82 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<div
class="calendar-button"
@click.stop="openCalendar"
>
<span class="label">{{ period }}</span>
<svg width="8" height="4" viewBox="0 0 8 4" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.33657 3.73107C3.70296 4.09114 4.29941 4.08814 4.66237 3.73107L7.79796 0.650836C8.16435 0.291517 8.01864 0 7.47247 0L0.526407 0C-0.0197628 0 -0.16292 0.294525 0.200917 0.650836L3.33657 3.73107Z" fill="#131D3A"/>
</svg>
<payout-period-calendar class="calendar-button__calendar" v-if="isCalendarShown" @onClose="closeCalendar" v-click-outside="close" />
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import PayoutPeriodCalendar from './PayoutPeriodCalendar.vue';
@Component({
components: { PayoutPeriodCalendar },
})
export default class Payout extends Vue {
public isCalendarShown: boolean = false;
public get period(): string {
return this.$store.getters['payouts/periodString'];
}
public openCalendar(): void {
this.isCalendarShown = true;
}
public closeCalendar(): void {
if (!this.isCalendarShown) return;
setTimeout(() => {
this.isCalendarShown = false;
}, 1);
}
}
</script>
<style lang="scss">
.calendar-button {
position: relative;
box-sizing: border-box;
width: 100%;
max-width: 300px;
height: 40px;
background: transparent;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border: 1px solid var(--c-gray--light);
border-radius: 6px;
font-size: 16px;
color: var(--c-title);
cursor: pointer;
font-family: 'font_medium', sans-serif;
&:hover {
border-color: var(--c-gray);
color: var(--c-title);
}
&__calendar {
position: absolute;
top: 50px;
right: 0;
}
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,74 @@
// Copyright (C) 2020 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<tr class="payouts-summary-item">
<th class="align-left node-name">{{ payoutsSummary.nodeName }}</th>
<th>{{ payoutsSummary.held | centsToDollars }}</th>
<th>{{ payoutsSummary.paid | centsToDollars }}</th>
<th class="overflow-visible options">
<node-options :id="payoutsSummary.nodeID" />
</th>
</tr>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import NodeOptions from '@/app/components/common/NodeOptions.vue';
import { NodePayoutsSummary } from '@/payouts';
@Component({
components: { NodeOptions },
})
export default class PayoutsSummaryItem extends Vue {
@Prop({default: () => new NodePayoutsSummary()})
public payoutsSummary: NodePayoutsSummary;
}
</script>
<style scoped lang="scss">
.payouts-summary-item {
height: 56px;
text-align: right;
font-size: 16px;
color: var(--c-line);
cursor: pointer;
th {
box-sizing: border-box;
padding: 0 20px;
max-width: 250px;
white-space: nowrap;
text-overflow: ellipsis;
position: relative;
overflow: hidden;
}
&:nth-of-type(even) {
background: var(--c-block-gray);
}
th:not(:first-of-type) {
font-family: 'font_medium', sans-serif;
}
.node-name {
color: var(--c-primary);
}
}
.align-left {
text-align: left;
}
.overflow-visible {
overflow: visible !important;
}
.options {
width: 60px;
text-align: center;
}
</style>

View File

@ -0,0 +1,77 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<table class="payouts-summary-table" v-if="nodePayoutsSummary.length" border="0" cellpadding="0" cellspacing="0">
<thead>
<tr>
<th class="align-left">NODE</th>
<th>HELD</th>
<th>PAID</th>
<th class="options"></th>
</tr>
</thead>
<tbody>
<payouts-summary-item v-for="payoutSummary in nodePayoutsSummary" :key="payoutSummary.nodeID" :payouts-summary="payoutSummary"/>
</tbody>
</table>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import NodeItem from '@/app/components/myNodes/tables/NodeItem.vue';
import PayoutsSummaryItem from '@/app/components/payouts/tables/PayoutsSummaryItem.vue';
import { NodePayoutsSummary } from '@/payouts';
@Component({
components: {
PayoutsSummaryItem,
NodeItem,
},
})
export default class PayoutsSummaryTable extends Vue {
@Prop({default: () => []})
public nodePayoutsSummary: NodePayoutsSummary[];
}
</script>
<style scoped lang="scss">
.payouts-summary-table {
width: 100%;
border: 1px solid var(--c-gray--light);
border-radius: var(--br-table);
font-family: 'font_semiBold', sans-serif;
z-index: 999;
th {
box-sizing: border-box;
padding: 0 20px;
max-width: 250px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
thead {
background: var(--c-block-gray);
tr {
height: 40px;
font-size: 12px;
color: var(--c-gray);
border-radius: var(--br-table);
text-align: right;
}
}
.align-left {
text-align: left;
}
.options {
width: 60px;
}
}
</style>

View File

@ -1,163 +0,0 @@
// Copyright (C) 2020 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<tr class="node-item">
<th class="align-left">{{ node.displayedName }}</th>
<template v-if="isSatelliteSelected">
<th>{{ node.suspensionScore | floatToPercentage }}</th>
<th>{{ node.auditScore | floatToPercentage }}</th>
<th>{{ node.onlineScore | floatToPercentage }}</th>
</template>
<template v-else>
<th>{{ node.diskSpaceUsed | bytesToBase10String }}</th>
<th>{{ node.diskSpaceLeft | bytesToBase10String }}</th>
<th>{{ node.bandwidthUsed | bytesToBase10String }}</th>
</template>
<th>{{ node.earned | centsToDollars }}</th>
<th>{{ node.version }}</th>
<th :class="node.status">{{ node.status }}</th>
<th class="overflow-visible">
<div class="node-item__options-button" @click.stop="toggleOptions">
<more-icon />
</div>
<div class="node-item__options" v-if="areOptionsShown" v-click-outside="closeOptions">
<div @click.stop="() => onCopy(node.id)" class="node-item__options__item">Copy Node ID</div>
<delete-node :node-id="node.id" />
<update-name :node-id="node.id" />
</div>
</th>
</tr>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import DeleteNode from '@/app/components/modals/DeleteNode.vue';
import UpdateName from '@/app/components/modals/UpdateName.vue';
import MoreIcon from '@/../static/images/icons/more.svg';
import { Node } from '@/nodes';
@Component({
components: {
UpdateName,
DeleteNode,
MoreIcon,
},
})
export default class NodeItem extends Vue {
@Prop({default: () => new Node()})
public node: Node;
public areOptionsShown: boolean = false;
public get isSatelliteSelected(): boolean {
return !!this.$store.state.nodes.selectedSatellite;
}
public toggleOptions(): void {
this.areOptionsShown = !this.areOptionsShown;
}
public closeOptions(): void {
if (!this.areOptionsShown) return;
this.areOptionsShown = false;
}
public async onCopy(id: string): Promise<void> {
try {
await this.$copyText(id);
} catch (error) {
console.error(error.message);
}
this.closeOptions();
}
}
</script>
<style scoped lang="scss">
.node-item {
height: 56px;
text-align: right;
font-size: 16px;
color: var(--c-line);
th {
box-sizing: border-box;
padding: 0 20px;
max-width: 250px;
white-space: nowrap;
text-overflow: ellipsis;
position: relative;
overflow: hidden;
}
&:nth-of-type(even) {
background: var(--c-block-gray);
}
th:not(:first-of-type) {
font-family: 'font_medium', sans-serif;
}
&__options-button {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
position: relative;
&:hover {
background: var(--c-background);
}
}
&__options {
position: absolute;
top: 16px;
right: 55px;
width: 140px;
height: auto;
background: white;
border-radius: var(--br-table);
font-family: 'font_medium', sans-serif;
border: 1px solid var(--c-gray--light);
font-size: 14px;
color: var(--c-title);
z-index: 999;
&__item {
box-sizing: border-box;
padding: 16px;
cursor: pointer;
text-align: left;
&:hover {
background: var(--c-background);
}
}
}
}
.online {
color: var(--c-success);
}
.offline {
color: var(--c-error);
}
.align-left {
text-align: left;
}
.overflow-visible {
overflow: visible !important;
}
</style>

View File

@ -7,6 +7,7 @@ import { Component } from 'vue-router/types/router';
import AddFirstNode from '@/app/views/AddFirstNode.vue';
import Dashboard from '@/app/views/Dashboard.vue';
import MyNodes from '@/app/views/MyNodes.vue';
import PayoutsPage from '@/app/views/PayoutsPage.vue';
import WelcomeScreen from '@/app/views/WelcomeScreen.vue';
/**
@ -59,11 +60,13 @@ export class Config {
public static Welcome: Route = new Route('/welcome', 'Welcome', WelcomeScreen);
public static AddFirstNode: Route = new Route('/add-first-node', 'AddFirstNode', AddFirstNode);
public static MyNodes: Route = new Route('/my-nodes', 'MyNodes', MyNodes);
public static Payouts: Route = new Route('/payouts', 'Payouts', PayoutsPage);
public static mode: RouterMode = 'history';
public static routes: Route[] = [
Config.Root.addChildren([
Config.MyNodes,
Config.Payouts,
]),
Config.Welcome,
Config.AddFirstNode,

View File

@ -4,6 +4,7 @@
import { ActionContext, ActionTree, GetterTree, Module, MutationTree } from 'vuex';
import { RootState } from '@/app/store/index';
import { monthNames } from '@/app/types/date';
import { PayoutsSummary } from '@/payouts';
import { Payouts } from '@/payouts/service';
@ -11,11 +12,12 @@ import { Payouts } from '@/payouts/service';
* PayoutsState is a representation of payouts module state.
*/
export class PayoutsState {
public summary: PayoutsSummary;
public summary: PayoutsSummary = new PayoutsSummary();
public selectedPayoutPeriod: string | null = null;
}
/**
* NodesModule is a part of a global store that encapsulates all nodes related logic.
* PayoutsModule is a part of a global store that encapsulates all payouts related logic.
*/
export class PayoutsModule implements Module<PayoutsState, RootState> {
public readonly namespaced: boolean;
@ -33,12 +35,17 @@ export class PayoutsModule implements Module<PayoutsState, RootState> {
this.state = new PayoutsState();
this.mutations = {
populate: this.populate,
setPayoutPeriod: this.setPayoutPeriod,
};
this.actions = {
getSummary: this.getSummary.bind(this),
summary: this.summary.bind(this),
};
this.getters = {
periodString: this.periodString,
};
}
// Mutations
/**
* populate mutation will set payouts state.
* @param state - state of the module.
@ -49,12 +56,37 @@ export class PayoutsModule implements Module<PayoutsState, RootState> {
}
/**
* getSummary action loads payouts summary information.
* setPayoutPeriod mutation will save selected period to store.
* @param state
* @param period representation of month and year
*/
public setPayoutPeriod(state: PayoutsState, period: string | null) {
state.selectedPayoutPeriod = period;
}
// Actions
/**
* summary action loads payouts summary information.
* @param ctx - context of the Vuex action.
*/
public async getSummary(ctx: ActionContext<PayoutsState, RootState>): Promise<void> {
public async summary(ctx: ActionContext<PayoutsState, RootState>): Promise<void> {
// @ts-ignore
const summary = await this.payouts.summary(ctx.rootState.nodes.selectedSatellite.id, '');
const summary = await this.payouts.summary(ctx.rootState.nodes.selectedSatellite.id, ctx.state.selectedPayoutPeriod);
ctx.commit('populate', summary);
}
// Getters
/**
* periodString is full name month and year representation of selected payout period.
*/
public periodString(state: PayoutsState): string {
if (!state.selectedPayoutPeriod) return 'All time';
const splittedPeriod = state.selectedPayoutPeriod.split('-');
const monthIndex = parseInt(splittedPeriod[1]) - 1;
const year = splittedPeriod[0];
return `${monthNames[monthIndex]}, ${year}`;
}
}

View File

@ -0,0 +1,11 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
/**
* Holds all months names.
*/
export const monthNames = [
'January', 'February', 'March', 'April',
'May', 'June', 'July', 'August',
'September', 'October', 'November', 'December',
];

View File

@ -0,0 +1,27 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
import { monthNames } from '@/app/types/date';
/**
* Describes month button entity for calendar.
*/
export class MonthButton {
public constructor(
public year: number = 0,
public index: number = 0,
public active: boolean = false,
public selected: boolean = false,
) {}
/**
* Returns month label depends on index.
*/
public get name(): string {
return monthNames[this.index] ? monthNames[this.index].slice(0, 3) : '';
}
}
export interface StoredMonthsByYear {
[key: number]: MonthButton[];
}

View File

@ -3,13 +3,13 @@
<template>
<div class="dashboard-area">
<div class="dashboard-area__navigation-area">
<nav class="dashboard-area__navigation-area">
<navigation-area />
</div>
</nav>
<div class="dashboard-area__right-area">
<div class="dashboard-area__right-area__header">
<header class="dashboard-area__right-area__header">
<add-new-node />
</div>
</header>
<div class="dashboard-area__right-area__content">
<router-view />
</div>
@ -23,13 +23,26 @@ import { Component, Vue } from 'vue-property-decorator';
import AddNewNode from '@/app/components/modals/AddNewNode.vue';
import NavigationArea from '@/app/components/navigation/NavigationArea.vue';
import { UnauthorizedError } from '@/api';
@Component({
components: {
AddNewNode,
NavigationArea,
},
})
export default class Dashboard extends Vue {}
export default class Dashboard extends Vue {
public async mounted(): Promise<void> {
try {
await this.$store.dispatch('nodes/trustedSatellites');
} catch (error) {
if (error instanceof UnauthorizedError) {
// TODO: redirect to login screen.
}
// TODO: notify error
}
}
}
</script>
<style lang="scss" scoped>

View File

@ -4,7 +4,7 @@
<template>
<div class="my-nodes">
<h1 class="my-nodes__title">My Nodes</h1>
<v-dropdown :options="trustedSatellitesOptions" />
<satellite-selection-dropdown />
<nodes-table class="table"/>
</div>
</template>
@ -12,26 +12,19 @@
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import VDropdown, { Option } from '@/app/components/common/VDropdown.vue';
import NodesTable from '@/app/components/tables/NodesTable.vue';
import SatelliteSelectionDropdown from '@/app/components/common/SatelliteSelectionDropdown.vue';
import NodesTable from '@/app/components/myNodes/tables/NodesTable.vue';
import { UnauthorizedError } from '@/api';
import { NodeURL } from '@/nodes';
@Component({
components: { VDropdown, NodesTable },
components: {
SatelliteSelectionDropdown,
NodesTable,
},
})
export default class MyNodes extends Vue {
public async mounted(): Promise<void> {
try {
await this.$store.dispatch('nodes/trustedSatellites');
} catch (error) {
if (error instanceof UnauthorizedError) {
// TODO: redirect to login screen.
}
// TODO: notify error
}
try {
await this.$store.dispatch('nodes/fetch');
} catch (error) {
@ -42,22 +35,6 @@ export default class MyNodes extends Vue {
// TODO: notify error
}
}
public get trustedSatellitesOptions(): Option[] {
const trustedSatellites: NodeURL[] = this.$store.state.nodes.trustedSatellites;
const options: Option[] = trustedSatellites.map(
(satellite: NodeURL) => {
return new Option(satellite.id, () => this.onSatelliteClick(satellite.id));
},
);
return [ new Option('All Satellites', () => this.onSatelliteClick()), ...options ];
}
public async onSatelliteClick(id: string = ''): Promise<void> {
await this.$store.dispatch('nodes/selectSatellite', id);
}
}
</script>

View File

@ -0,0 +1,124 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<div class="payouts">
<h1 class="payouts__title">Payouts</h1>
<div class="payouts__content-area">
<div class="payouts__left-area">
<div class="payouts__left-area__dropdowns">
<satellite-selection-dropdown />
<payout-period-calendar-button />
</div>
<payouts-summary-table
class="payouts__left-area__table"
v-if="payoutsSummary.nodeSummary"
:node-payouts-summary="payoutsSummary.nodeSummary"
/>
</div>
<div class="payouts__right-area">
<details-area
:total-earned="payoutsSummary.totalEarned"
:total-held="payoutsSummary.totalHeld"
:total-paid="payoutsSummary.totalPaid"
/>
<payout-history-block />
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import SatelliteSelectionDropdown from '@/app/components/common/SatelliteSelectionDropdown.vue';
import NodesTable from '@/app/components/myNodes/tables/NodesTable.vue';
import DetailsArea from '@/app/components/payouts/DetailsArea.vue';
import PayoutHistoryBlock from '@/app/components/payouts/PayoutHistoryBlock.vue';
import PayoutPeriodCalendarButton from '@/app/components/payouts/PayoutPeriodCalendarButton.vue';
import PayoutsSummaryTable from '@/app/components/payouts/tables/PayoutsSummaryTable.vue';
import { UnauthorizedError } from '@/api';
import { PayoutsSummary } from '@/payouts';
@Component({
components: {
PayoutPeriodCalendarButton,
PayoutHistoryBlock,
DetailsArea,
PayoutsSummaryTable,
SatelliteSelectionDropdown,
NodesTable,
},
})
export default class PayoutsPage extends Vue {
public async mounted(): Promise<void> {
try {
await this.$store.dispatch('payouts/summary');
} catch (error) {
if (error instanceof UnauthorizedError) {
// TODO: redirect to login screen.
}
// TODO: notify error
}
}
/**
* payoutsSummary contains payouts summary from store.
*/
public get payoutsSummary(): PayoutsSummary {
return this.$store.state.payouts.summary;
}
}
</script>
<style lang="scss" scoped>
.payouts {
box-sizing: border-box;
padding: 60px;
height: 100%;
overflow-y: auto;
&__title {
font-family: 'font_bold', sans-serif;
font-size: 32px;
color: var(--c-title);
margin-bottom: 36px;
}
&__content-area {
display: flex;
align-items: flex-start;
justify-content: space-between;
width: 100%;
}
&__left-area {
width: 65%;
margin-right: 32px;
&__dropdowns {
display: flex;
align-items: center;
justify-content: flex-start;
& > *:first-of-type {
margin-right: 20px;
}
}
&__table {
margin-top: 20px;
}
}
&__right-area {
width: 35%;
& > *:not(:first-of-type) {
margin-top: 20px;
}
}
}
</style>

View File

@ -1,6 +1,11 @@
// Copyright (C) 2020 Storj Labs, Inc.
// See LICENSE for copying information.
/**
* Divider to convert payout amounts to cents.
*/
const PRICE_DIVIDER: number = 10000;
/**
* Describes node online statuses.
*/
@ -33,11 +38,16 @@ export class Node {
if (now.getTime() - this.lastContact.getTime() < this.STATUS_TRESHHOLD_MILISECONDS) {
this.status = NodeStatus.Online;
}
this.earned = this.convertToCents(this.earned);
}
public get displayedName(): string {
return this.name || this.id;
}
private convertToCents(value: number): number {
return value / PRICE_DIVIDER;
}
}
/**

View File

@ -1,6 +1,12 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
// TODO: move to config.
/**
* Divider to convert payout amounts to cents.
*/
const PRICE_DIVIDER: number = 10000;
/**
* PayoutsSummary is a representation of summary of payout information for node.
*/
@ -22,7 +28,20 @@ export class PayoutsSummary {
public totalHeld: number = 0,
public totalPaid: number = 0,
public nodeSummary: NodePayoutsSummary[] = [],
) {}
) {
this.totalPaid = this.convertToCents(this.totalPaid);
this.totalEarned = this.convertToCents(this.totalEarned);
this.totalHeld = this.convertToCents(this.totalHeld);
this.nodeSummary.forEach((summary: NodePayoutsSummary) => {
summary.paid = this.convertToCents(summary.paid);
summary.held = this.convertToCents(summary.held);
});
}
private convertToCents(value: number): number {
return value / PRICE_DIVIDER;
}
}
/**

View File

@ -0,0 +1,3 @@
<svg width="9" height="15" viewBox="0 0 9 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.54282 0.542969L8.95703 1.95718L3.16414 7.75008L8.95703 13.543L7.54282 14.9572L0.335711 7.75008L7.54282 0.542969Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 286 B

View File

@ -24,6 +24,8 @@
--c-button-red: #ff4f4d;
--c-button-red-hover: #de3e3d;
--c-button-disabled: #dadde5;
--c-button-gray: #e7e9eb;
--c-payout-period: #444c63;
// borders
--b-button-white: 1px solid #afb7c1;
@ -32,4 +34,5 @@
--br-button: 6px;
--br-input: 6px;
--br-table: 6px;
--br-block: 6px;
}