web/satellite: vuetify upload progress panel

vuetify snackbar with expansion panels displaying upload statuses.

https://github.com/storj/storj/issues/6257

Change-Id: Ife01616f5a07a4987153ef85331ff71f53b8cf78
This commit is contained in:
Cameron 2023-09-11 09:02:48 -04:00 committed by Storj Robot
parent 6555a68fa9
commit 8689f609d7
4 changed files with 299 additions and 92 deletions

View File

@ -9,6 +9,7 @@
elevation="24" elevation="24"
rounded="lg" rounded="lg"
class="upload-snackbar" class="upload-snackbar"
:max-width="xs ? '400px' : ''"
> >
<v-row> <v-row>
<v-col> <v-col>
@ -18,27 +19,29 @@
rounded="lg" rounded="lg"
> >
<v-expansion-panel-title color=""> <v-expansion-panel-title color="">
<span>Uploading 3 items</span> <span>{{ statusLabel }}</span>
</v-expansion-panel-title> </v-expansion-panel-title>
<v-progress-linear <v-progress-linear
v-if="!isClosable"
rounded rounded
model-value="73" :indeterminate="!progress"
:model-value="progress"
height="6" height="6"
color="success" color="success"
class="mt-1" class="mt-1"
/> />
<v-expansion-panel-text> <v-expansion-panel-text v-if="!isClosable && objectsInProgress.length > 1">
<v-row class="pt-2"> <v-row justify="space-between" class="pt-2">
<v-col cols="10"> <v-col cols="auto">
<p class="text-medium-emphasis">4 seconds left...</p> <p class="text-medium-emphasis">{{ remainingTimeString }}</p>
</v-col> </v-col>
<v-col cols="2"> <v-col cols="auto">
<v-tooltip text="Cancel all uploads"> <v-tooltip text="Cancel all uploads">
<template #activator="{ props: activatorProps }"> <template #activator="{ props: activatorProps }">
<v-icon <v-icon
v-bind="activatorProps" v-bind="activatorProps"
icon="mdi-close-circle" icon="mdi-close-circle"
@click="onCancel" @click="cancelAll"
/> />
</template> </template>
</v-tooltip> </v-tooltip>
@ -46,83 +49,14 @@
</v-row> </v-row>
</v-expansion-panel-text> </v-expansion-panel-text>
<v-divider /> <v-divider />
<v-expansion-panel-text> <v-expansion-panel-text class="uploading-content">
<v-row class="pt-2"> <UploadItem
<v-col cols="10"> v-for="item in uploading"
<p>Image.jpg</p> :key="item.Key"
</v-col> :item="item"
<v-col cols="2"> />
<v-tooltip text="Uploading..."> <v-expansion-panel-text />
<template #activator="{ props: activatorProps }">
<v-progress-circular
v-bind="activatorProps"
:size="20"
color="secondary"
model-value="20"
/>
</template>
</v-tooltip>
</v-col>
</v-row>
</v-expansion-panel-text> </v-expansion-panel-text>
<v-divider />
<v-expansion-panel-text>
<v-row class="pt-2">
<v-col cols="10">
<p>Video.mp4</p>
</v-col>
<v-col cols="2">
<v-tooltip text="Upload complete">
<template #activator="{ props: activatorProps }">
<v-icon
v-bind="activatorProps"
icon="mdi-checkbox-marked-circle"
color="success"
/>
</template>
</v-tooltip>
</v-col>
</v-row>
</v-expansion-panel-text>
<v-divider />
<v-expansion-panel-text>
<v-row class="pt-2">
<v-col cols="10">
<p>Text.pdf</p>
</v-col>
<v-col cols="2">
<v-tooltip text="Upload failed">
<template #activator="{ props: activatorProps }">
<v-icon
v-bind="activatorProps"
icon="mdi-information"
color="warning"
/>
</template>
</v-tooltip>
</v-col>
</v-row>
</v-expansion-panel-text>
<v-divider />
<v-expansion-panel-text>
<v-row class="pt-2">
<v-col cols="10">
<p>Bigvideo.mov</p>
</v-col>
<v-col cols="2">
<v-tooltip text="File is too big">
<template #activator="{ props: activatorProps }">
<v-icon
v-bind="activatorProps"
icon="mdi-cancel"
color="error2"
/>
</template>
</v-tooltip>
</v-col>
</v-row>
</v-expansion-panel-text>
<v-divider />
</v-expansion-panel> </v-expansion-panel>
</v-expansion-panels> </v-expansion-panels>
</v-col> </v-col>
@ -131,6 +65,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { import {
VSnackbar, VSnackbar,
VRow, VRow,
@ -143,10 +78,156 @@ import {
VTooltip, VTooltip,
VIcon, VIcon,
VDivider, VDivider,
VProgressCircular,
} from 'vuetify/components'; } from 'vuetify/components';
import { useDisplay } from 'vuetify';
const props = defineProps<{ import { UploadingBrowserObject, UploadingStatus, useObjectBrowserStore } from '@/store/modules/objectBrowserStore';
onCancel: () => void, import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
}>(); import { Duration } from '@/utils/time';
</script> import { useNotify } from '@/utils/hooks';
import UploadItem from '@poc/components/UploadItem.vue';
const obStore = useObjectBrowserStore();
const remainingTimeString = ref<string>('');
const interval = ref<NodeJS.Timer>();
const notify = useNotify();
const startDate = ref<number>(Date.now());
const { xs } = useDisplay();
/**
* Returns header's status label.
*/
const statusLabel = computed((): string => {
let inProgress = 0, finished = 0, failed = 0, cancelled = 0;
uploading.value.forEach(u => {
switch (u.status) {
case UploadingStatus.InProgress:
inProgress++;
break;
case UploadingStatus.Failed:
failed++;
break;
case UploadingStatus.Cancelled:
cancelled++;
break;
default:
finished++;
}
});
if (failed === uploading.value.length) return 'Uploading failed';
if (cancelled === uploading.value.length) return 'Uploading cancelled';
if (inProgress) return `Uploading ${inProgress} item${inProgress > 1 ? 's' : ''}`;
const statuses = [
failed ? `${failed} failed` : '',
cancelled ? `${cancelled} cancelled` : '',
].filter(s => s).join(', ');
return `Uploading completed${statuses ? ` (${statuses})` : ''}`;
});
/**
* Returns upload progress.
*/
const progress = computed((): number => {
return uploading.value.reduce((total: number, item: UploadingBrowserObject) => {
total += item.progress || 0;
return total;
}, 0) / uploading.value.length;
});
/**
* Returns uploading objects from store.
*/
const uploading = computed((): UploadingBrowserObject[] => {
return obStore.state.uploading;
});
/**
* Calculates remaining seconds.
*/
function calculateRemainingTime(): void {
const progress = uploading.value.reduce((total: number, item: UploadingBrowserObject) => {
if (item.progress && item.progress !== 100) {
total += item.progress;
}
return total;
}, 0);
const remainingProgress = 100 - progress;
const averageProgressPerNanosecond = progress / ((Date.now() - startDate.value) * 1000000);
const remainingNanoseconds = remainingProgress / averageProgressPerNanosecond;
if (!isFinite(remainingNanoseconds) || remainingNanoseconds < 0) {
remainingTimeString.value = 'Unknown ETA';
return;
}
remainingTimeString.value = new Duration(remainingNanoseconds).remainingFormatted;
}
/**
* Cancels all uploads in progress.
*/
function cancelAll(): void {
objectsInProgress.value.forEach(item => {
try {
obStore.cancelUpload(item.Key);
} catch (error) {
notify.error(`Unable to cancel upload for '${item.Key}'. ${error.message}`, AnalyticsErrorEventSource.OBJECTS_UPLOAD_MODAL);
}
});
}
/**
* Returns uploading objects with InProgress status.
*/
const objectsInProgress = computed((): UploadingBrowserObject[] => {
return uploading.value.filter(f => f.status === UploadingStatus.InProgress);
});
/**
* Indicates if modal is closable.
*/
const isClosable = computed((): boolean => {
return !objectsInProgress.value.length;
});
/**
* Starts interval for recalculating remaining time.
*/
function startInterval(): void {
const int = setInterval(() => {
if (isClosable.value) {
clearInterval(int);
interval.value = undefined;
remainingTimeString.value = '';
return;
}
calculateRemainingTime();
}, 2000); // recalculate every 2 seconds.
interval.value = int;
}
watch(() => objectsInProgress.value.length, () => {
if (!interval.value) {
startDate.value = Date.now();
startInterval();
}
});
onMounted(() => {
startInterval();
});
</script>
<style scoped lang="scss">
.uploading-content {
overflow-y: auto;
max-height: 200px;
}
</style>

View File

@ -0,0 +1,118 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<v-row class="pt-2" justify="space-between">
<v-col cols="9">
<p class="text-truncate">{{ item.Key }}</p>
</v-col>
<v-col cols="auto">
<v-tooltip :text="uploadStatus">
<template #activator="{ props: activatorProps }">
<v-progress-circular
v-if="props.item.status === UploadingStatus.InProgress"
v-bind="activatorProps"
:indeterminate="!item.progress"
:size="20"
color="secondary"
:model-value="progressStyle"
/>
<v-icon
v-else
v-bind="activatorProps"
:icon="icon"
:color="iconColor"
/>
</template>
</v-tooltip>
<v-tooltip v-if="props.item.status === UploadingStatus.InProgress" text="Cancel upload">
<template #activator="{ props: activatorProps }">
<v-icon
class="ml-2"
v-bind="activatorProps"
icon="mdi-close-circle"
@click="cancelUpload"
/>
</template>
</v-tooltip>
</v-col>
</v-row>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { VCol, VIcon, VProgressCircular, VRow, VTooltip } from 'vuetify/components';
import {
UploadingBrowserObject,
UploadingStatus,
useObjectBrowserStore,
} from '@/store/modules/objectBrowserStore';
import { useNotify } from '@/utils/hooks';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
const obStore = useObjectBrowserStore();
const notify = useNotify();
const props = defineProps<{
item: UploadingBrowserObject
}>();
const progressStyle = computed((): number => {
if (props.item.progress) {
return 360*(props.item.progress/100);
}
return 0;
});
const uploadStatus = computed((): string => {
if (props.item.status === UploadingStatus.InProgress) {
return 'Uploading...';
} else if (props.item.status === UploadingStatus.Finished) {
return 'Upload complete';
} else if (props.item.status === UploadingStatus.Failed) {
return 'Upload failed';
} else if (props.item.status === UploadingStatus.Cancelled) {
return 'Upload cancelled';
} else {
return '';
}
});
const icon = computed((): string => {
if (props.item.status === UploadingStatus.Finished) {
return 'mdi-checkbox-marked-circle';
} else if (props.item.status === UploadingStatus.Failed) {
return 'mdi-information';
} else if (props.item.status === UploadingStatus.Cancelled) {
return 'mdi-cancel';
} else {
return '';
}
});
const iconColor = computed((): string => {
if (props.item.status === UploadingStatus.Finished) {
return 'success';
} else if (props.item.status === UploadingStatus.Failed) {
return 'warning';
} else if (props.item.status === UploadingStatus.Cancelled) {
return 'error2';
} else {
return '';
}
});
/**
* Cancels active upload.
*/
function cancelUpload(): void {
try {
obStore.cancelUpload(props.item.Key);
} catch (error) {
notify.error(error.message, AnalyticsErrorEventSource.OBJECTS_UPLOAD_MODAL);
}
}
</script>

View File

@ -318,4 +318,4 @@ table {
// Positions // Positions
.pos-relative { .pos-relative {
position: relative !important; position: relative !important;
} }

View File

@ -10,7 +10,6 @@
<page-title-component title="Browse Files" /> <page-title-component title="Browse Files" />
<browser-breadcrumbs-component /> <browser-breadcrumbs-component />
<v-col> <v-col>
<v-row class="mt-2 mb-4"> <v-row class="mt-2 mb-4">
<v-menu v-model="menu" location="bottom" transition="scale-transition" offset="5"> <v-menu v-model="menu" location="bottom" transition="scale-transition" offset="5">
@ -21,7 +20,7 @@
:disabled="!isInitialized" :disabled="!isInitialized"
v-bind="props" v-bind="props"
> >
<browser-snackbar-component :on-cancel="() => { snackbar = false }" /> <browser-snackbar-component v-model="isObjectsUploadModal" />
<IconUpload /> <IconUpload />
Upload Upload
</v-btn> </v-btn>
@ -108,6 +107,7 @@ import { EdgeCredentials } from '@/types/accessGrants';
import { useNotify } from '@/utils/hooks'; import { useNotify } from '@/utils/hooks';
import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames'; import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames';
import { useAnalyticsStore } from '@/store/modules/analyticsStore'; import { useAnalyticsStore } from '@/store/modules/analyticsStore';
import { useAppStore } from '@/store/modules/appStore';
import PageTitleComponent from '@poc/components/PageTitleComponent.vue'; import PageTitleComponent from '@poc/components/PageTitleComponent.vue';
import BrowserBreadcrumbsComponent from '@poc/components/BrowserBreadcrumbsComponent.vue'; import BrowserBreadcrumbsComponent from '@poc/components/BrowserBreadcrumbsComponent.vue';
@ -124,6 +124,7 @@ const bucketsStore = useBucketsStore();
const obStore = useObjectBrowserStore(); const obStore = useObjectBrowserStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const analyticsStore = useAnalyticsStore(); const analyticsStore = useAnalyticsStore();
const appStore = useAppStore();
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
@ -158,6 +159,13 @@ const projectId = computed<string>(() => projectsStore.state.selectedProject.id)
*/ */
const isPromptForPassphrase = computed<boolean>(() => bucketsStore.state.promptForPassphrase); const isPromptForPassphrase = computed<boolean>(() => bucketsStore.state.promptForPassphrase);
/**
* Indicates whether objects upload modal should be shown.
*/
const isObjectsUploadModal = computed((): boolean => {
return appStore.state.isUploadingModal;
});
/** /**
* Open the operating system's file system for file upload. * Open the operating system's file system for file upload.
*/ */