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:
parent
7186525d5c
commit
4822b18472
@ -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',
|
||||
},
|
||||
{
|
||||
|
@ -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>
|
||||
|
@ -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 }}%
|
||||
{{ item.raw.storage.percent }}%
|
||||
</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 }}%
|
||||
</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 }}%
|
||||
</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 }}%
|
||||
{{ item.raw.segment.percent }}%
|
||||
</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) {
|
||||
|
@ -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;
|
||||
}>();
|
||||
|
@ -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>
|
@ -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;
|
||||
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
105
satellite/admin/back-office/ui/src/store/notifications.ts
Normal file
105
satellite/admin/back-office/ui/src/store/notifications.ts
Normal 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,
|
||||
};
|
||||
});
|
46
satellite/admin/back-office/ui/src/types/notifications.ts
Normal file
46
satellite/admin/back-office/ui/src/types/notifications.ts
Normal 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);
|
||||
}
|
||||
}
|
29
satellite/admin/back-office/ui/src/utils/memory.ts
Normal file
29
satellite/admin/back-office/ui/src/utils/memory.ts
Normal 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';
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -2,8 +2,12 @@
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"module": "esnext",
|
||||
"strict": true,
|
||||
"noImplicitAny": false,
|
||||
"baseUrl": "./",
|
||||
"moduleResolution": "node",
|
||||
"strictPropertyInitialization": false,
|
||||
"useUnknownInCatchVariables": false,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
|
Loading…
Reference in New Issue
Block a user