satellite/admin/back-office/ui: implement view user functionality

This change adds a search field to the new admin UI through which user
email addresses may be submitted. If the email belongs to a verified
user, the client will be redirected to the Account Details page which
is populated with the user's information.

Resolves #6469
Resolves #6475

Change-Id: Icbf3cb3f8374f2764e73a523f111c5ecf3d06569
This commit is contained in:
Jeremy Wharton 2023-11-16 22:49:54 -06:00 committed by Storj Robot
parent 7186525d5c
commit 4822b18472
18 changed files with 663 additions and 136 deletions

View File

@ -34,13 +34,16 @@ module.exports = {
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-var-requires': 'off',
'@typescript-eslint/no-redeclare': 'error',
'no-redeclare': 'off',
'no-multiple-empty-lines': ['error', { 'max': 1 }],
'import/order': ['error', {
'pathGroups': [
{
'group': 'internal',
'pattern': '@/{components,views}/**',
'pattern': '@/{components,views,layouts}/**',
'position': 'after',
},
{

View File

@ -3,8 +3,25 @@
<template>
<router-view />
<notifications />
</template>
<script setup lang="ts">
//
import { onMounted } from 'vue';
import { useAppStore } from '@/store/app';
import { useNotificationsStore } from '@/store/notifications';
import Notifications from '@/layouts/default/Notifications.vue';
const appStore = useAppStore();
const notify = useNotificationsStore();
onMounted(async () => {
try {
await appStore.getPlacements();
} catch (error) {
notify.notifyError(`Failed to get placements. ${error.message}`);
}
});
</script>

View File

@ -9,10 +9,16 @@
/>
<v-data-table
v-model="selected" v-model:sort-by="sortBy" :headers="headers" :items="files" :search="search"
class="elevation-1" item-key="path" density="comfortable" hover @item-click="handleItemClick"
v-model="selected"
v-model:sort-by="sortBy"
:headers="headers"
:items="projects"
:search="search"
class="elevation-1"
density="comfortable"
hover
>
<template #item.projectid="{ item }">
<template #item.name="{ item }: ProjectTableSlotProps">
<div class="text-no-wrap">
<v-btn
variant="outlined" color="default" size="small" class="mr-1 text-caption" density="comfortable" icon
@ -34,44 +40,84 @@
/>
</svg>
</template>
{{ item.columns.projectid }}
{{ item.raw.name }}
</v-chip>
</div>
</template>
<template #item.storagepercent="{ item }">
<template #item.storage.percent="{ item }: ProjectTableSlotProps">
<v-chip
variant="tonal" :color="getPercentColor(item.raw.storagepercent)" size="small" rounded="lg"
v-if="item.raw.storage.percent !== null"
variant="tonal"
:color="getPercentColor(item.raw.storage.percent)"
size="small"
rounded="lg"
class="font-weight-bold"
>
{{ item.raw.storagepercent }}&percnt;
{{ item.raw.storage.percent }}&percnt;
</v-chip>
<v-icon v-else icon="mdi-alert-circle-outline" color="error" />
</template>
<template #item.storage.used="{ item }: ProjectTableSlotProps">
<template v-if="item.raw.storage.used !== null">
{{ sizeToBase10String(item.raw.storage.used) }}
</template>
<v-icon v-else icon="mdi-alert-circle-outline" color="error" />
</template>
<template #item.storage.limit="{ item }: ProjectTableSlotProps">
{{ sizeToBase10String(item.raw.storage.limit) }}
</template>
<template #item.download.percent="{ item }: ProjectTableSlotProps">
<v-chip
variant="tonal"
:color="getPercentColor(item.raw.download.percent)"
size="small"
rounded="lg"
class="font-weight-bold"
>
{{ item.raw.download.percent }}&percnt;
</v-chip>
</template>
<template #item.downloadpercent="{ item }">
<v-chip
variant="tonal" :color="getPercentColor(item.raw.downloadpercent)" size="small" rounded="lg"
class="font-weight-bold"
>
{{ item.raw.downloadpercent }}&percnt;
</v-chip>
<template #item.download.used="{ item }: ProjectTableSlotProps">
{{ sizeToBase10String(item.raw.download.used) }}
</template>
<template #item.segmentpercent="{ item }">
<v-tooltip text="430,000 / 1,000,000">
<template #item.download.limit="{ item }: ProjectTableSlotProps">
{{ sizeToBase10String(item.raw.download.limit) }}
</template>
<template #item.segment.percent="{ item }: ProjectTableSlotProps">
<v-tooltip>
{{ item.raw.segment.used !== null ? item.raw.segment.used.toLocaleString() + '/' : 'Limit:' }}
{{ item.raw.segment.limit.toLocaleString() }}
<template #activator="{ props }">
<v-chip
v-bind="props" variant="tonal" :color="getPercentColor(item.raw.segmentpercent)" size="small"
rounded="lg" class="font-weight-bold"
v-if="item.raw.segment.percent !== null"
v-bind="props"
variant="tonal"
:color="getPercentColor(item.raw.segment.percent)"
size="small"
rounded="lg"
class="font-weight-bold"
>
{{ item.raw.segmentpercent }}&percnt;
{{ item.raw.segment.percent }}&percnt;
</v-chip>
<v-icon v-else icon="mdi-alert-circle-outline" color="error" v-bind="props" />
</template>
</v-tooltip>
</template>
<template #item.id="{ item }: ProjectTableSlotProps">
<div class="text-caption text-no-wrap text-uppercase">{{ item.raw.id }}</div>
</template>
<!--
<template #item.agent="{ item }">
<v-chip variant="tonal" color="default" size="small" rounded="lg" @click="setSearch(item.raw.agent)">
<v-chip variant="tonal" color="default" size="small" rounded="lg" @click="search = item.raw.agent">
{{ item.raw.agent }}
</v-chip>
</template>
@ -81,79 +127,88 @@
{{ item.raw.date }}
</span>
</template>
-->
</v-data-table>
</v-card>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { VCard, VTextField, VBtn, VIcon, VTooltip, VChip } from 'vuetify/components';
import { VDataTable } from 'vuetify/labs/components';
import { useAppStore } from '@/store/app';
import { sizeToBase10String } from '@/utils/memory';
import ProjectActionsMenu from '@/components/ProjectActionsMenu.vue';
type UsageStats = {
used: number | null;
limit: number;
percent: number | null;
};
type RequiredUsageStats = {
[K in keyof UsageStats]: NonNullable<UsageStats[K]>;
};
type ProjectTableItem = {
id: string;
name: string;
storage: UsageStats;
download: RequiredUsageStats;
segment: UsageStats;
};
type ProjectTableSlotProps = { item: { raw: ProjectTableItem } };
const search = ref<string>('');
const selected = ref<string[]>([]);
const sortBy = ref([{ key: 'name', order: 'asc' }]);
const headers = [
{ title: 'Project ID', key: 'projectid', align: 'start' },
// { title: 'Name', key: 'name'},
{ title: 'Storage Used', key: 'storagepercent' },
{ title: 'Storage Used', key: 'storageused' },
{ title: 'Storage Limit', key: 'storagelimit' },
{ title: 'Download Used', key: 'downloadpercent' },
{ title: 'Download Used', key: 'downloadused' },
{ title: 'Download Limit', key: 'downloadlimit' },
{ title: 'Segments Used', key: 'segmentpercent' },
{ title: 'Name', key: 'name' },
{ title: 'Storage Used', key: 'storage.percent' },
{ title: 'Storage Used', key: 'storage.used' },
{ title: 'Storage Limit', key: 'storage.limit' },
{ title: 'Download Used', key: 'download.percent' },
{ title: 'Download Used', key: 'download.used' },
{ title: 'Download Limit', key: 'download.limit' },
{ title: 'Segments Used', key: 'segment.percent' },
{ title: 'Project ID', key: 'id', align: 'start' },
// { title: 'Value Attribution', key: 'agent' },
// { title: 'Date Created', key: 'date' },
];
const files = [
{
name: 'My First Project',
projectid: 'F82SR21Q284JF',
storageused: '150 TB',
storagelimit: '300 TB',
storagepercent: '50',
downloadused: '100 TB',
downloadlimit: '100 TB',
downloadpercent: '100',
segmentpercent: '43',
agent: 'Test Agent',
date: '02 Mar 2023',
},
{
name: 'Personal Project',
projectid: '284JFF82SR21Q',
storageused: '24 TB',
storagelimit: '30 TB',
storagepercent: '80',
downloadused: '7 TB',
downloadlimit: '100 TB',
segmentpercent: '20',
downloadpercent: '7',
agent: 'Agent',
date: '21 Apr 2023',
},
{
name: 'Test Project',
projectid: '82SR21Q284JFF',
storageused: '99 TB',
storagelimit: '100 TB',
storagepercent: '99',
downloadused: '85 TB',
downloadlimit: '100 TB',
segmentpercent: '83',
downloadpercent: '85',
agent: 'Company',
date: '21 Apr 2023',
},
];
function setSearch(searchText: string) {
search.value = searchText;
}
const appStore = useAppStore();
/**
* Returns the user's project usage data.
*/
const projects = computed<ProjectTableItem[]>(() => {
function makeUsageStats(used: number, limit: number): RequiredUsageStats;
function makeUsageStats(used: number | null, limit: number): UsageStats;
function makeUsageStats(used: number | null, limit: number) {
return {
used,
limit,
percent: used !== null ? Math.round(used * 100 / limit) : null,
};
}
const usageLimits = appStore.state.user?.projectUsageLimits;
if (!usageLimits || !usageLimits.length) {
return [];
}
return usageLimits.map<ProjectTableItem>(usage => ({
id: usage.id,
name: usage.name,
storage: makeUsageStats(usage.storageUsed, usage.storageLimit),
download: makeUsageStats(usage.bandwidthUsed, usage.bandwidthLimit),
segment: makeUsageStats(usage.segmentUsed, usage.segmentLimit),
}));
});
function getPercentColor(percent: number) {
if (percent >= 99) {

View File

@ -4,7 +4,8 @@
<template>
<v-card :title="title" :subtitle="subtitle" variant="flat" :border="true" rounded="xlg">
<v-card-text>
<v-chip :variant="variant" :color="color" class="font-weight-bold">{{ data }}</v-chip>
<slot name="data" />
<v-chip v-if="data !== undefined" :variant="variant" :color="color" class="font-weight-bold">{{ data }}</v-chip>
</v-card-text>
</v-card>
</template>
@ -15,7 +16,7 @@ import { VCard, VCardText, VChip } from 'vuetify/components';
const props = defineProps<{
title: string;
subtitle: string;
data: string;
data?: string;
variant?: 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain';
color?: string;
}>();

View File

@ -0,0 +1,76 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<v-snackbar
v-model="doNotificationsExist"
position="fixed"
location="top right"
z-index="99999"
variant="text"
>
<v-alert
v-for="item in notifications"
:key="item.id"
closable
variant="elevated"
:title="item.title || item.type"
:type="item.type.toLowerCase() as 'error' | 'success' | 'warning' | 'info'"
rounded="lg"
class="my-2"
border
@mouseover="() => onMouseOver(item.id)"
@mouseleave="() => onMouseLeave(item.id)"
@click:close="() => onCloseClick(item.id)"
>
<template #default>
<component :is="item.messageNode" />
</template>
</v-alert>
</v-snackbar>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { VAlert, VSnackbar } from 'vuetify/components';
import { useNotificationsStore } from '@/store/notifications';
import { DelayedNotification } from '@/types/notifications';
const notificationsStore = useNotificationsStore();
/**
* Indicates if any notifications are in queue.
*/
const doNotificationsExist = computed((): boolean => {
return notifications.value.length > 0;
});
/**
* Returns all notification queue from store.
*/
const notifications = computed((): DelayedNotification[] => {
return notificationsStore.state.notificationQueue as DelayedNotification[];
});
/**
* Forces notification to stay on page on mouse over it.
*/
function onMouseOver(id: symbol): void {
notificationsStore.pauseNotification(id);
}
/**
* Resume notification flow when mouse leaves notification.
*/
function onMouseLeave(id: symbol): void {
notificationsStore.resumeNotification(id);
}
/**
* Removes notification when the close button is clicked.
*/
function onCloseClick(id: symbol): void {
notificationsStore.deleteNotification(id);
}
</script>

View File

@ -1,7 +1,7 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
// Composables
import { watch } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
const routes = [
@ -65,4 +65,9 @@ const router = createRouter({
routes,
});
watch(
() => router.currentRoute.value.name as string,
routeName => document.title = 'Storj Admin' + (routeName ? ' - ' + routeName : ''),
);
export default router;

View File

@ -1,11 +1,38 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
// Utilities
import { reactive } from 'vue';
import { defineStore } from 'pinia';
export const useAppStore = defineStore('app', {
state: () => ({
//
}),
import { PlacementInfo, PlacementManagementHttpApiV1, User, UserManagementHttpApiV1 } from '@/api/client.gen';
class AppState {
public placements: PlacementInfo[];
public user: User | null = null;
}
export const useAppStore = defineStore('app', () => {
const state = reactive<AppState>(new AppState());
const userApi = new UserManagementHttpApiV1();
const placementApi = new PlacementManagementHttpApiV1();
async function getUserByEmail(email: string): Promise<void> {
state.user = await userApi.getUserByEmail(email);
}
function clearUser(): void {
state.user = null;
}
async function getPlacements(): Promise<void> {
state.placements = await placementApi.getPlacements();
}
return {
state,
getUserByEmail,
clearUser,
getPlacements,
};
});

View File

@ -0,0 +1,105 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
import { reactive } from 'vue';
import { defineStore } from 'pinia';
import { DelayedNotification, NotificationMessage, NotificationType } from '@/types/notifications';
export class NotificationsState {
public notificationQueue: DelayedNotification[] = [];
}
export const useNotificationsStore = defineStore('notifications', () => {
const state = reactive<NotificationsState>(new NotificationsState());
const deleteCallback = (id: symbol) => deleteNotification(id);
function addNotification(notification: DelayedNotification) {
state.notificationQueue.push(notification);
}
function deleteNotification(id: symbol) {
if (state.notificationQueue.length < 1) {
return;
}
const selectedNotification = state.notificationQueue.find(n => n.id === id);
if (selectedNotification) {
selectedNotification.pause();
state.notificationQueue.splice(state.notificationQueue.indexOf(selectedNotification), 1);
}
}
function pauseNotification(id: symbol) {
const selectedNotification = state.notificationQueue.find(n => n.id === id);
if (selectedNotification) {
selectedNotification.pause();
}
}
function resumeNotification(id: symbol) {
const selectedNotification = state.notificationQueue.find(n => n.id === id);
if (selectedNotification) {
selectedNotification.start();
}
}
function notifySuccess(message: NotificationMessage, title?: string): void {
const notification = new DelayedNotification(
deleteCallback,
NotificationType.Success,
message,
title,
);
addNotification(notification);
}
function notifyInfo(message: NotificationMessage, title?: string): void {
const notification = new DelayedNotification(
deleteCallback,
NotificationType.Info,
message,
title,
);
addNotification(notification);
}
function notifyWarning(message: NotificationMessage): void {
const notification = new DelayedNotification(
deleteCallback,
NotificationType.Warning,
message,
);
addNotification(notification);
}
function notifyError(message: NotificationMessage): void {
const notification = new DelayedNotification(
deleteCallback,
NotificationType.Error,
message,
);
addNotification(notification);
}
function clear(): void {
state.notificationQueue = [];
}
return {
state,
notifyInfo,
notifyWarning,
notifySuccess,
notifyError,
pauseNotification,
resumeNotification,
deleteNotification,
clear,
};
});

View File

@ -0,0 +1,46 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
import { VNode, createTextVNode } from 'vue';
export enum NotificationType {
Success = 'Success',
Info = 'Info',
Error = 'Error',
Warning = 'Warning',
}
type RenderFunction = () => (string | VNode | (string | VNode)[]);
export type NotificationMessage = string | RenderFunction;
export class DelayedNotification {
public readonly id: symbol = Symbol();
private readonly callback: (id: symbol) => void;
private timerId: number;
private startTime: number;
private remainingTime: number;
public readonly type: NotificationType;
public readonly title: string | undefined;
public readonly messageNode: RenderFunction;
constructor(callback: (id: symbol) => void, type: NotificationType, message: NotificationMessage, title?: string) {
this.callback = callback;
this.type = type;
this.title = title;
this.messageNode = typeof message === 'string' ? () => createTextVNode(message) : message;
this.remainingTime = 3000;
this.start();
}
public pause(): void {
clearTimeout(this.timerId);
this.remainingTime -= new Date().getMilliseconds() - this.startTime;
}
public start(): void {
this.startTime = new Date().getMilliseconds();
this.timerId = window.setTimeout(() => this.callback(this.id), this.remainingTime);
}
}

View File

@ -0,0 +1,29 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
export enum Memory {
B = 1e0,
KB = 1e3,
MB = 1e6,
GB = 1e9,
TB = 1e12,
PB = 1e15,
EB = 1e18,
}
/**
* sizeToBase10String converts size to a string using base-10 prefixes.
* @param size - size in bytes
*/
export function sizeToBase10String(size: number, decimals = 2): string {
const _size = Math.abs(size);
const amounts = Object.values(Memory).filter((v): v is number => typeof v === 'number').sort().reverse();
for (const amount of Object.values(amounts)) {
if (_size >= amount * 2 / 3) {
return `${(size / amount).toLocaleString(undefined, { maximumFractionDigits: decimals })} ${Memory[amount]}`;
}
}
return size.toLocaleString(undefined, { maximumFractionDigits: decimals }) + ' B';
}

View File

@ -18,13 +18,14 @@
/>
</svg>
</template>
itacker@gmail.com
{{ user.email }}
</v-chip>
<v-chip class="mr-2 mb-2 mb-md-0" variant="text">
Customer for 17 days
Customer for {{ Math.floor((Date.now() - createdAt.getTime()) / MS_PER_DAY).toLocaleString() }} days
<v-tooltip activator="parent" location="top">
Account created: 24 Apr 2020
Account created:
{{ createdAt.toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }) }}
</v-tooltip>
</v-chip>
</v-col>
@ -39,15 +40,24 @@
</v-col>
</v-row>
<v-row v-if="usageCacheError">
<v-col>
<v-alert variant="tonal" color="error" rounded="lg" density="comfortable" border>
<div class="d-flex align-center">
<v-icon icon="mdi-alert-circle" color="error" class="mr-3" />
An error occurred when retrieving project usage data.
Please retry after a few minutes and report the issue if it persists.
</div>
</v-alert>
</v-col>
</v-row>
<v-row>
<v-col cols="12" sm="6" md="3">
<v-card title="Account" subtitle="Irving Tacker" variant="flat" :border="true" rounded="xlg">
<v-card title="Account" :subtitle="user.fullName" variant="flat" :border="true" rounded="xlg">
<v-card-text>
<v-chip color="success" variant="tonal" class="mr-2 font-weight-bold">
Pro
<v-tooltip activator="parent" location="top">
Pro account since: 2 May 2022
</v-tooltip>
<v-chip :color="user.paidTier ? 'success' : 'default'" variant="tonal" class="mr-2 font-weight-bold">
{{ user.paidTier ? 'Pro' : 'Free' }}
</v-chip>
<v-divider class="my-4" />
<v-btn variant="outlined" size="small" color="default">
@ -62,7 +72,7 @@
<v-card title="Status" subtitle="Account" variant="flat" :border="true" rounded="xlg">
<v-card-text>
<v-chip color="success" variant="tonal" class="mr-2 font-weight-bold">
Active
{{ user.status }}
</v-chip>
<v-divider class="my-4" />
<v-btn variant="outlined" size="small" color="default">
@ -77,7 +87,9 @@
<v-card title="Value" subtitle="Attribution" variant="flat" :border="true" rounded="xlg" class="mb-3">
<v-card-text>
<!-- <p class="mb-3">Attribution</p> -->
<v-chip variant="tonal" class="mr-2">Company</v-chip>
<v-chip :variant="user.userAgent ? 'tonal' : 'text'" class="mr-2">
{{ user.userAgent || 'None' }}
</v-chip>
<v-divider class="my-4" />
<v-btn variant="outlined" size="small" color="default">
Set Value Attribution
@ -92,7 +104,7 @@
<v-card-text>
<!-- <p class="mb-3">Region</p> -->
<v-chip variant="tonal" class="mr-2">
Global
{{ placementText }}
</v-chip>
<v-divider class="my-4" />
<v-btn variant="outlined" size="small" color="default">
@ -106,16 +118,33 @@
<v-row>
<v-col cols="12" sm="6" md="3">
<CardStatsComponent title="Projects" subtitle="Total" data="3" />
<card-stats-component title="Projects" subtitle="Total" :data="user.projectUsageLimits?.length.toString() || '0'" />
</v-col>
<v-col cols="12" sm="6" md="3">
<CardStatsComponent title="Storage" subtitle="Total" data="273 TB" />
<card-stats-component title="Storage" subtitle="Total">
<template #data>
<v-chip v-if="totalUsage.storage !== null" class="font-weight-bold">
{{ sizeToBase10String(totalUsage.storage) }}
</v-chip>
<v-icon v-else icon="mdi-alert-circle-outline" color="error" size="x-large" />
</template>
</card-stats-component>
</v-col>
<v-col cols="12" sm="6" md="3">
<CardStatsComponent title="Download" subtitle="This month" data="192 TB" />
<card-stats-component title="Download" subtitle="This month" :data="sizeToBase10String(totalUsage.download)" />
</v-col>
<v-col cols="12" sm="6" md="3">
<CardStatsComponent title="Segments" subtitle="Total" data="430,721" />
<card-stats-component title="Segments" subtitle="Total">
<template #data>
<v-chip v-if="totalUsage.segments !== null" class="font-weight-bold">
{{ totalUsage.segments.toLocaleString() }}
</v-chip>
<v-icon v-else icon="mdi-alert-circle-outline" color="error" size="x-large" />
</template>
</card-stats-component>
</v-col>
</v-row>
@ -136,7 +165,8 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { computed, onBeforeMount, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import {
VContainer,
VRow,
@ -148,8 +178,13 @@ import {
VCardText,
VDivider,
VBtn,
VAlert,
} from 'vuetify/components';
import { useAppStore } from '@/store/app';
import { User } from '@/api/client.gen';
import { sizeToBase10String } from '@/utils/memory';
import PageTitleComponent from '@/components/PageTitleComponent.vue';
import AccountProjectsTableComponent from '@/components/AccountProjectsTableComponent.vue';
import LogsTableComponent from '@/components/LogsTableComponent.vue';
@ -160,7 +195,80 @@ import AccountInformationDialog from '@/components/AccountInformationDialog.vue'
import AccountStatusDialog from '@/components/AccountStatusDialog.vue';
import CardStatsComponent from '@/components/CardStatsComponent.vue';
onMounted(() => {
document.title = 'Storj Admin - Account Details';
const MS_PER_DAY = 1000 * 60 * 60 * 24;
const appStore = useAppStore();
const router = useRouter();
/**
* Returns user info from store.
*/
const user = computed<User>(() => appStore.state.user as User);
/**
* Returns the date that the user was created.
*/
const createdAt = computed<Date>(() => new Date(user.value.createdAt));
/**
* Returns the string representation of the user's default placement.
*/
const placementText = computed<string>(() => {
for (const placement of appStore.state.placements) {
if (placement.id === user.value.defaultPlacement) {
if (placement.location) {
return placement.location;
}
break;
}
}
return `Unknown (${user.value.defaultPlacement})`;
});
type Usage = {
storage: number | null;
download: number;
segments: number | null;
};
/**
* Returns the user's total project usage.
*/
const totalUsage = computed<Usage>(() => {
const total: Usage = {
storage: 0,
download: 0,
segments: 0,
};
if (!user.value.projectUsageLimits?.length) {
return total;
}
for (const usageLimit of user.value.projectUsageLimits) {
if (total.storage !== null) {
total.storage = usageLimit.storageUsed !== null ? total.storage + usageLimit.storageUsed : null;
}
if (total.segments !== null) {
total.segments = usageLimit.segmentUsed !== null ? total.segments + usageLimit.segmentUsed : null;
}
total.download += usageLimit.bandwidthUsed;
}
return total;
});
/**
* Returns whether an error occurred retrieving usage data from the Redis live accounting cache.
*/
const usageCacheError = computed<boolean>(() => {
return !!user.value.projectUsageLimits?.some(usageLimit =>
usageLimit.storageUsed === null ||
usageLimit.bandwidthUsed === null ||
usageLimit.segmentUsed === null,
);
});
onBeforeMount(() => !user.value && router.push('/accounts'));
onUnmounted(appStore.clearUser);
</script>

View File

@ -6,7 +6,7 @@
<v-row>
<v-col cols="6">
<PageTitleComponent title="Accounts" />
<PageSubtitleComponent subtitle="All accounts on North America US1." />
<PageSubtitleComponent subtitle="Find accounts on North America US1." />
</v-col>
<v-col cols="6" class="d-flex justify-end align-center">
@ -44,20 +44,96 @@
</v-col>
</v-row> -->
<AccountsTableComponent class="my-5" />
<v-row align="center" justify="center">
<v-col cols="12" sm="8" md="6" lg="4">
<v-card variant="flat" class="mt-8 pa-4" rounded="xlg" border>
<v-card-text>
<v-form v-model="isFormValid" @submit.prevent="goToUser">
<h2 class="my-1">Find an account</h2>
<p>Enter account email</p>
<v-text-field
v-model="email"
label="Email"
variant="outlined"
class="mt-5"
:disabled="isLoading"
autofocus
:rules="emailRules"
:error-messages="notFoundError ? 'The user was not found.' : ''"
@click="goToUser"
/>
<v-btn class="mt-3" block size="large" :loading="isLoading" @click="goToUser">
Continue
</v-btn>
</v-form>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { VContainer, VRow, VCol, VBtn } from 'vuetify/components';
import { ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { VContainer, VRow, VCol, VBtn, VCard, VCardText, VForm, VTextField } from 'vuetify/components';
import { useAppStore } from '@/store/app';
import { useNotificationsStore } from '@/store/notifications';
import PageTitleComponent from '@/components/PageTitleComponent.vue';
import PageSubtitleComponent from '@/components/PageSubtitleComponent.vue';
import AccountsTableComponent from '@/components/AccountsTableComponent.vue';
import NewAccountDialog from '@/components/NewAccountDialog.vue';
onMounted(() => {
document.title = 'Storj Admin - Accounts';
});
const isLoading = ref<boolean>(false);
const isFormValid = ref<boolean>(false);
const email = ref<string>('');
const notFoundError = ref<boolean>(false);
const emailRules: ((value: string) => boolean | string)[] = [
v => /.+@.+\..+/.test(v) || 'E-mail must be valid.',
v => !!v || 'Required',
];
const appStore = useAppStore();
const notify = useNotificationsStore();
const router = useRouter();
/**
* Fetches user information and navigates to Account Details page.
* Displays an error message if no user has the input email address.
*/
async function goToUser(): Promise<void> {
if (isLoading.value || !isFormValid.value) return;
isLoading.value = true;
const maxAttempts = 3;
const retryDelay = 1000;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
await appStore.getUserByEmail(email.value);
router.push(`/account-details`);
isLoading.value = false;
return;
} catch (error) {
if (error.responseStatusCode === 404) {
notFoundError.value = true;
break;
} else if (error.responseStatusCode === 409) {
if (attempt >= maxAttempts-1) {
notify.notifyError(`Error getting user. Please wait a few minutes before trying again.`);
break;
}
await new Promise(resolve => setTimeout(resolve, retryDelay * Math.pow(2, attempt)));
} else {
notify.notifyError(`Error getting user. ${error.message}`);
break;
}
}
}
isLoading.value = false;
}
watch(email, () => notFoundError.value = false);
</script>

View File

@ -52,7 +52,6 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import {
VContainer,
VRow,
@ -67,8 +66,4 @@ import {
import PageTitleComponent from '@/components/PageTitleComponent.vue';
import PageSubtitleComponent from '@/components/PageSubtitleComponent.vue';
import AdminAccountDialog from '@/components/AdminAccountDialog.vue';
onMounted(() => {
document.title = 'Storj Admin Settings';
});
</script>

View File

@ -131,7 +131,6 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import {
VContainer,
VRow,
@ -151,8 +150,4 @@ import BucketGeofenceDialog from '@/components/BucketGeofenceDialog.vue';
import BucketUserAgentsDialog from '@/components/BucketUserAgentsDialog.vue';
import BucketInformationDialog from '@/components/BucketInformationDialog.vue';
import CardStatsComponent from '@/components/CardStatsComponent.vue';
onMounted(() => {
document.title = 'Storj Admin - Bucket Details';
});
</script>

View File

@ -48,15 +48,10 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { VContainer, VRow, VCol, VCard, VDivider } from 'vuetify/components';
import PageTitleComponent from '@/components/PageTitleComponent.vue';
import PageSubtitleComponent from '@/components/PageSubtitleComponent.vue';
import CardStatsComponent from '@/components/CardStatsComponent.vue';
import DashboardLimitsTableComponent from '@/components/DashboardLimitsTableComponent.vue';
onMounted(() => {
document.title = 'Storj Admin - Dashboard';
});
</script>

View File

@ -169,7 +169,6 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import {
VContainer,
VRow,
@ -193,8 +192,4 @@ import ProjectGeofenceDialog from '@/components/ProjectGeofenceDialog.vue';
import ProjectUserAgentsDialog from '@/components/ProjectUserAgentsDialog.vue';
import ProjectLimitsDialog from '@/components/ProjectLimitsDialog.vue';
import ProjectInformationDialog from '@/components/ProjectInformationDialog.vue';
onMounted(() => {
document.title = 'Storj Admin - Project Details';
});
</script>

View File

@ -28,15 +28,10 @@
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { VContainer, VRow, VCol, VBtn } from 'vuetify/components';
import PageTitleComponent from '@/components/PageTitleComponent.vue';
import PageSubtitleComponent from '@/components/PageSubtitleComponent.vue';
import ProjectsTableComponent from '@/components/ProjectsTableComponent.vue';
import NewProjectDialog from '@/components/NewProjectDialog.vue';
onMounted(() => {
document.title = 'Storj Admin - Projects';
});
</script>

View File

@ -2,8 +2,12 @@
"compilerOptions": {
"target": "es5",
"module": "esnext",
"strict": true,
"noImplicitAny": false,
"baseUrl": "./",
"moduleResolution": "node",
"strictPropertyInitialization": false,
"useUnknownInCatchVariables": false,
"paths": {
"@/*": [
"src/*"