Skip to content

Commit 6ddca1f

Browse files
committed
[MS] Display global upload progress
1 parent ff81342 commit 6ddca1f

17 files changed

Lines changed: 202 additions & 37 deletions

File tree

client/src/common/transferRate.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS
2+
3+
interface Sample {
4+
bytes: number;
5+
timestamp: number;
6+
}
7+
8+
const MAX_SAMPLES = 5;
9+
10+
class TransferRateCalculator {
11+
private samples: Sample[] = [];
12+
13+
constructor() {}
14+
15+
update(bytes: number): void {
16+
this.samples.push({ bytes: bytes, timestamp: Date.now() });
17+
if (this.samples.length > MAX_SAMPLES) {
18+
this.samples.shift();
19+
}
20+
}
21+
22+
getRate(): number {
23+
if (this.samples.length < 2) {
24+
return 0;
25+
}
26+
27+
const now = Date.now();
28+
let weightedRate = 0;
29+
let totalWeight = 0;
30+
31+
for (let i = 1; i < this.samples.length; i++) {
32+
const prev = this.samples[i - 1];
33+
const curr = this.samples[i];
34+
const dtMs = curr.timestamp - prev.timestamp;
35+
if (dtMs <= 0) {
36+
continue;
37+
}
38+
const rate = Math.abs(curr.bytes - prev.bytes) / (dtMs / 1000);
39+
// Weight decays by half every 5000 so recent intervals dominate
40+
const weight = Math.pow(0.5, (now - curr.timestamp) / 5000);
41+
weightedRate += rate * weight;
42+
totalWeight += weight;
43+
}
44+
45+
return totalWeight > 0 ? weightedRate / totalWeight : 0;
46+
}
47+
48+
getEta(remainingBytes: number): number {
49+
const rate = this.getRate();
50+
if (rate <= 0) {
51+
return Infinity;
52+
}
53+
return remainingBytes / rate;
54+
}
55+
56+
clear(): void {
57+
this.samples = [];
58+
}
59+
}
60+
61+
export { TransferRateCalculator };

client/src/components/files/operations/FileOperationDownloadArchive.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@
7676
class="progress-info"
7777
v-if="props.status === FileOperationEvents.Progress && props.eventData"
7878
>
79+
<ion-text class="progress-percentage button-small default-state">
80+
{{ (props.eventData as OperationProgressEventData).global.progress }}%
81+
</ion-text>
82+
7983
<ms-spinner class="progress-spinner default-state" />
8084
<ion-button
8185
fill="clear"
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<!-- Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS -->
2+
3+
<template>
4+
<div v-if="!status">ALL FILES ARE SYNCED</div>
5+
<div v-else>
6+
SYNCING FILES... {{ $msTranslate(formatFileSize(status.totalBytes)) }} {{ status.totalFiles }} files<br />
7+
<span v-if="rate > 0">{{ $msTranslate(formatFileSize(rate)) }}/s</span>
8+
<span v-if="eta !== Infinity">{{ $msTranslate(formatETA(eta)) }} left</span>
9+
</div>
10+
</template>
11+
12+
<script setup lang="ts">
13+
import { formatFileSize } from '@/common/file';
14+
import { TransferRateCalculator } from '@/common/transferRate';
15+
import { UploadProgress } from '@/parsec';
16+
import { formatETA } from '@/services/translation';
17+
import { ref, watch } from 'vue';
18+
19+
const rate = ref<number>(0);
20+
const eta = ref<number>(Infinity);
21+
22+
const props = defineProps<{
23+
status?: UploadProgress;
24+
rateCalculator: TransferRateCalculator;
25+
}>();
26+
27+
watch(
28+
() => props.status,
29+
(newValue) => {
30+
if (newValue) {
31+
eta.value = props.rateCalculator.getEta(newValue.totalBytes);
32+
} else {
33+
eta.value = Infinity;
34+
}
35+
rate.value = props.rateCalculator.getRate();
36+
},
37+
);
38+
</script>
39+
40+
<style scoped lang="scss"></style>

client/src/components/notifications/AllImportedElementsNotification.vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ async function openImportedMenu(): Promise<void> {
5252
if (!currentRouteIsFileRoute()) {
5353
await navigateTo(Routes.Workspaces);
5454
}
55-
menu.show();
5655
menu.expand();
5756
}
5857
</script>

client/src/parsec/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export * from '@/parsec/path';
1717
export * from '@/parsec/search';
1818
export * from '@/parsec/server';
1919
export * from '@/parsec/shamir';
20+
export * from '@/parsec/sync';
2021
export * from '@/parsec/terms_of_service';
2122
export * from '@/parsec/totp';
2223
export * from '@/parsec/types';

client/src/parsec/sync.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS
2+
3+
import { ClientGetOutboundSyncBacklogError, Result, UploadProgress } from '@/parsec/types';
4+
import { generateNoHandleError } from '@/parsec/utils';
5+
import { libparsec } from '@/plugins/libparsec';
6+
import { getConnectionHandle } from '@/router';
7+
8+
export async function getGlobalUploadProgress(): Promise<Result<UploadProgress, ClientGetOutboundSyncBacklogError>> {
9+
const handle = getConnectionHandle();
10+
11+
if (!handle) {
12+
return generateNoHandleError<ClientGetOutboundSyncBacklogError>();
13+
}
14+
const result = await libparsec.clientGetOutboundSyncBacklog(handle);
15+
if (result.ok) {
16+
return {
17+
ok: true,
18+
value: {
19+
totalBytes: Number(result.value.totalPendingBytesForStartedWorkspaces),
20+
totalFiles: Number(result.value.totalPendingEntriesForStartedWorkspaces),
21+
},
22+
};
23+
}
24+
return result;
25+
}

client/src/parsec/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@ export type {
171171
ClientExportRecoveryDeviceError,
172172
ClientGetAsyncEnrollmentAddrError,
173173
ClientGetOrganizationBootstrapDateError,
174+
ClientGetOutboundSyncBacklog,
175+
ClientGetOutboundSyncBacklogError,
174176
ClientGetSelfShamirRecoveryError,
175177
ClientGetTosError,
176178
ClientGetUserDeviceError,
@@ -591,6 +593,11 @@ type OtherShamirRecoveryInfo =
591593
| OtherShamirRecoveryInfoSetupButUnusable
592594
| OtherShamirRecoveryInfoSetupWithRevokedRecipients;
593595

596+
interface UploadProgress {
597+
totalBytes: number;
598+
totalFiles: number;
599+
}
600+
594601
export {
595602
AccessToken,
596603
AccountHandle,
@@ -634,6 +641,7 @@ export {
634641
SelfShamirRecoveryInfoSetupWithRevokedRecipients,
635642
ServerConfig,
636643
SystemPath,
644+
UploadProgress,
637645
UserID,
638646
UserInfo,
639647
UserTuple,

client/src/plugins/libparsec/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ function logError(level: 'warn' | 'error', message: string): void {
3535
}
3636

3737
interface SkipData {
38-
tag: string;
38+
tag?: string;
3939
function: string;
4040
}
4141

@@ -52,6 +52,9 @@ const FUNCTIONS_TO_SKIP: Array<SkipData> = [
5252
tag: 'WorkspaceCreateFolderErrorEntryExists',
5353
function: 'workspaceCreateFolderAll',
5454
},
55+
{
56+
function: 'clientGetOutboundSyncBacklog',
57+
},
5558
];
5659

5760
const FUNCTIONS_TO_SLOW_DOWN: Array<string> = [
@@ -93,7 +96,9 @@ class ParsecProxy {
9396
if (
9497
result &&
9598
(result as any).ok === false &&
96-
!FUNCTIONS_TO_SKIP.find((sd) => sd.function === name && (result as any).error && (result as any).error.tag === sd.tag)
99+
!FUNCTIONS_TO_SKIP.find(
100+
(sd) => sd.function === name && (result as any).error && (!sd.tag || (result as any).error.tag === sd.tag),
101+
)
97102
) {
98103
const resultError = result as { ok: boolean; error: { tag: string; error: string } };
99104
logError('warn', `Error when calling ${name}: ${JSON.stringify(resultError.error)}`);

client/src/services/translation.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import { InvitationStatus, UserProfile, WorkspaceRole } from '@/parsec';
44
import { InvoiceStatus } from '@/services/bms';
5-
import { Locale, Translatable } from 'megashark-lib';
5+
import { Duration } from 'luxon';
6+
import { I18n, Locale, Translatable } from 'megashark-lib';
67

78
export function getProfileTranslationKey(profile: UserProfile): Translatable {
89
if (profile === UserProfile.Admin) {
@@ -83,3 +84,10 @@ export function getInvitationStatusTranslationKey(status: InvitationStatus): Tra
8384
export function longLocaleCodeToShort(longCode: Locale): string {
8485
return longCode.split('-')[0];
8586
}
87+
88+
export function formatETA(seconds: number): Translatable {
89+
const entries = Object.entries(Duration.fromObject({ seconds }).rescale().toObject())
90+
.filter(([k, v]) => v > 0 && k !== 'milliseconds')
91+
.slice(0, 2);
92+
return I18n.valueAsTranslatable(Duration.fromObject(Object.fromEntries(entries)).reconfigure({ locale: I18n.getLocale() }).toHuman());
93+
}

client/src/views/files/FileOperationMenu.vue

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,28 @@
22

33
<template>
44
<div
5-
v-if="menu.isVisible() || isFileOperationManagerActive"
65
class="upload-menu"
76
:class="menu.isMinimized() ? 'minimize' : ''"
87
>
98
<div class="upload-menu-header">
109
<ion-text class="title-h4">{{ $msTranslate('FoldersPage.ImportFile.title') }}</ion-text>
10+
<div v-show="menu.isMinimized()">
11+
<span v-if="uploadProgressStatus && uploadProgressStatus.totalBytes === 0">FILES SYNCED</span>
12+
<span v-if="uploadProgressStatus && uploadProgressStatus.totalBytes > 0">SYNCING...</span>
13+
</div>
1114
<div class="menu-header-icons">
1215
<ion-icon
1316
class="menu-header-icons__item"
1417
:icon="chevronDown"
1518
@click="toggleMenu()"
1619
/>
17-
<ion-icon
18-
v-if="!isFileOperationManagerActive"
19-
class="menu-header-icons__item"
20-
:icon="close"
21-
@click="menu.hide()"
22-
/>
2320
</div>
2421
</div>
22+
<upload-status
23+
:rate-calculator="rateCalculator as TransferRateCalculator"
24+
:status="uploadProgressStatus"
25+
class="upload-status"
26+
/>
2527
<ion-list class="upload-menu-tabs">
2628
<ion-item
2729
class="upload-menu-tabs__item button-medium"
@@ -84,8 +86,10 @@
8486
</template>
8587

8688
<script setup lang="ts">
89+
import { TransferRateCalculator } from '@/common/transferRate';
8790
import { FileOperationBase, FileOperationDownloadArchive, FileOperationImport } from '@/components/files';
88-
import { Path } from '@/parsec';
91+
import UploadStatus from '@/components/files/operations/UploadStatus.vue';
92+
import { getGlobalUploadProgress, Path, UploadProgress } from '@/parsec';
8993
import { navigateTo, Routes } from '@/router';
9094
import {
9195
FileOperationCopyData,
@@ -99,7 +103,7 @@ import { FileEventRegistrationCanceller, FileOperationEventData, FileOperationEv
99103
import { FileOperationManager, FileOperationManagerKey } from '@/services/fileOperation/manager';
100104
import useUploadMenu from '@/services/fileUploadMenu';
101105
import { IonButton, IonIcon, IonItem, IonList, IonText } from '@ionic/vue';
102-
import { chevronDown, close } from 'ionicons/icons';
106+
import { chevronDown } from 'ionicons/icons';
103107
import { MsImage, NoImportInProgress } from 'megashark-lib';
104108
import type { Component } from 'vue';
105109
import { computed, inject, onMounted, onUnmounted, ref, Ref } from 'vue';
@@ -148,8 +152,11 @@ const currentItems = computed(() => {
148152
});
149153
150154
let canceller!: FileEventRegistrationCanceller;
155+
let uploadProgressIntervalId: any = null;
151156
const isFileOperationManagerActive = ref(false);
152157
const uploadMenuList = ref();
158+
const uploadProgressStatus = ref<UploadProgress | undefined>(undefined);
159+
const rateCalculator = ref<TransferRateCalculator>(new TransferRateCalculator());
153160
154161
function toggleMenu(): void {
155162
if (menu.isMinimized()) {
@@ -182,12 +189,27 @@ function getOperationComponent(item: OperationItem): Component {
182189
}
183190
}
184191
192+
async function updateProgress(): Promise<void> {
193+
const result = await getGlobalUploadProgress();
194+
if (result.ok) {
195+
uploadProgressStatus.value = result.value;
196+
if (result.value.totalBytes > 0) {
197+
rateCalculator.value.update(result.value.totalBytes);
198+
}
199+
} else {
200+
uploadProgressStatus.value = undefined;
201+
rateCalculator.value.clear();
202+
}
203+
}
204+
185205
onMounted(async () => {
186206
canceller = await fileOperationManager.value.registerCallback(onFileOperationEvent);
207+
uploadProgressIntervalId = setInterval(updateProgress, 2000);
187208
});
188209
189210
onUnmounted(async () => {
190211
canceller.cancel();
212+
clearInterval(uploadProgressIntervalId);
191213
});
192214
193215
async function onFileOperationEvent(
@@ -207,7 +229,6 @@ async function onFileOperationEvent(
207229
switch (event) {
208230
case FileOperationEvents.Added: {
209231
items.value.unshift({ operationData: operationData, status: event, eventData: eventData, refreshKey: 0 });
210-
menu.show();
211232
menu.expand();
212233
scrollToTop();
213234
filter.value = undefined;
@@ -490,7 +511,8 @@ function scrollToTop(): void {
490511
491512
.minimize {
492513
.upload-menu-list,
493-
.upload-menu-tabs {
514+
.upload-menu-tabs,
515+
.upload-status {
494516
height: 0;
495517
padding: 0;
496518
margin: 0;

0 commit comments

Comments
 (0)