feat: mrpack upload progress in modal (#3867)

* feat: mrpack upload progress in modal

* fix: remove min progress
This commit is contained in:
IMB11 2025-06-30 22:52:03 +01:00 committed by GitHub
parent f549560e47
commit e5030a8fbe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 329 additions and 141 deletions

View File

@ -1,124 +1,176 @@
<template> <template>
<NewModal ref="mrpackModal" header="Uploading mrpack" @hide="onHide" @show="onShow"> <NewModal ref="mrpackModal" header="Uploading mrpack" :closable="!isLoading" @show="onShow">
<div class="flex flex-col gap-4 md:w-[600px]"> <div class="flex flex-col gap-4 md:w-[600px]">
<p <Transition
v-if="isMrpackModalSecondPhase" enter-active-class="transition-all duration-300 ease-out"
:style="{ enter-from-class="opacity-0 max-h-0"
lineHeight: isMrpackModalSecondPhase ? '1.5' : undefined, enter-to-class="opacity-100 max-h-20"
marginBottom: isMrpackModalSecondPhase ? '-12px' : '0', leave-active-class="transition-all duration-200 ease-in"
marginTop: isMrpackModalSecondPhase ? '-4px' : '-2px', leave-from-class="opacity-100 max-h-20"
}" leave-to-class="opacity-0 max-h-0"
> >
This will reinstall your server and erase all data. You may want to back up your server <div v-if="isLoading" class="w-full">
before proceeding. Are you sure you want to continue? <div class="mb-2 flex justify-between text-sm">
</p> <Transition name="phrase-fade" mode="out-in">
<div v-if="!isMrpackModalSecondPhase" class="flex flex-col gap-4"> <span :key="currentPhrase" class="text-lg font-medium text-contrast">{{
<div class="mx-auto flex flex-row items-center gap-4"> currentPhrase
<div }}</span>
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm" </Transition>
> <div class="flex flex-col items-end">
<UploadIcon class="size-10" /> <span class="text-secondary">{{ Math.round(uploadProgress) }}%</span>
<span class="text-xs text-secondary"
>{{ formatBytes(uploadedBytes) }} / {{ formatBytes(totalBytes) }}</span
>
</div>
</div> </div>
<svg <div class="h-2 w-full rounded-full bg-divider">
xmlns="http://www.w3.org/2000/svg" <div
width="24" class="h-2 animate-pulse rounded-full bg-brand transition-all duration-300 ease-out"
height="24" :style="{ width: `${uploadProgress}%` }"
viewBox="0 0 24 24" ></div>
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-10"
>
<path d="M5 9v6" />
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
</svg>
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
>
<ServerIcon class="size-10" />
</div> </div>
</div> </div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4"> </Transition>
<div class="text-sm font-bold text-contrast">Upload mrpack</div>
<input
type="file"
accept=".mrpack"
class=""
:disabled="isLoading"
@change="uploadMrpack"
/>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4"> <Transition
<div class="flex w-full flex-row items-center justify-between"> enter-active-class="transition-all duration-300 ease-out"
<label class="w-full text-lg font-bold text-contrast" for="hard-reset"> enter-from-class="opacity-0 max-h-0"
Erase all data enter-to-class="opacity-100 max-h-20"
</label> leave-active-class="transition-all duration-200 ease-in"
<input leave-from-class="opacity-100 max-h-20"
id="hard-reset" leave-to-class="opacity-0 max-h-0"
v-model="hardReset" >
class="switch stylized-toggle shrink-0" <div v-if="!isLoading" class="flex flex-col gap-4">
type="checkbox" <p
/> v-if="isMrpackModalSecondPhase"
</div> :style="{
<div> lineHeight: isMrpackModalSecondPhase ? '1.5' : undefined,
Removes all data on your server, including your worlds, mods, and configuration files, marginBottom: isMrpackModalSecondPhase ? '-12px' : '0',
then reinstalls it with the selected version. marginTop: isMrpackModalSecondPhase ? '-4px' : '-2px',
</div> }"
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
</div>
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="canInstall || backupInProgress"
@click="handleReinstall"
> >
<RightArrowIcon /> This will reinstall your server and erase all data. You may want to back up your server
{{ before proceeding. Are you sure you want to continue?
isMrpackModalSecondPhase </p>
? "Erase and install" <div v-if="!isMrpackModalSecondPhase" class="flex flex-col gap-4">
: loadingServerCheck <div class="mx-auto flex flex-row items-center gap-4">
? "Loading..." <div
: isDangerous class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
>
<UploadIcon class="size-10" />
</div>
<ArrowBigRightDashIcon class="size-10" />
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
>
<ServerIcon class="size-10" />
</div>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="text-sm font-bold text-contrast">Upload mrpack</div>
<input
type="file"
accept=".mrpack"
class=""
:disabled="isLoading"
@change="uploadMrpack"
/>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
Erase all data
</label>
<input
id="hard-reset"
v-model="hardReset"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>
Removes all data on your server, including your worlds, mods, and configuration
files, then reinstalls it with the selected version.
</div>
<div class="font-bold">
This does not affect your backups, which are stored off-site.
</div>
</div>
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button
v-tooltip="backupInProgress ? backupInProgress.tooltip : undefined"
:disabled="canInstall || !!backupInProgress"
@click="handleReinstall"
>
<RightArrowIcon />
{{
isMrpackModalSecondPhase
? "Erase and install" ? "Erase and install"
: "Install" : loadingServerCheck
}} ? "Loading..."
</button> : isDangerous
</ButtonStyled> ? "Erase and install"
<ButtonStyled> : "Install"
<button }}
:disabled="isLoading" </button>
@click=" </ButtonStyled>
() => { <ButtonStyled>
if (isMrpackModalSecondPhase) { <button
isMrpackModalSecondPhase = false; :disabled="isLoading"
} else { @click="
hide(); () => {
} if (isMrpackModalSecondPhase) {
} isMrpackModalSecondPhase = false;
" } else {
> hide();
<XIcon /> }
{{ isMrpackModalSecondPhase ? "Go back" : "Cancel" }} }
</button> "
</ButtonStyled> >
</div> <XIcon />
{{ isMrpackModalSecondPhase ? "Go back" : "Cancel" }}
</button>
</ButtonStyled>
</div>
</div>
</Transition>
</div> </div>
</NewModal> </NewModal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui"; import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
import { UploadIcon, RightArrowIcon, XIcon, ServerIcon } from "@modrinth/assets"; import {
import { ModrinthServersFetchError } from "@modrinth/utils"; UploadIcon,
RightArrowIcon,
XIcon,
ServerIcon,
ArrowBigRightDashIcon,
} from "@modrinth/assets";
import { formatBytes, ModrinthServersFetchError } from "@modrinth/utils";
import { onMounted, onUnmounted } from "vue";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue"; import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts"; import type { ModrinthServer } from "~/composables/servers/modrinth-servers";
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (isLoading.value) {
event.preventDefault();
return "Upload in progress. Are you sure you want to leave?";
}
};
onMounted(() => {
window.addEventListener("beforeunload", handleBeforeUnload);
});
onUnmounted(() => {
window.removeEventListener("beforeunload", handleBeforeUnload);
});
const props = defineProps<{ const props = defineProps<{
server: ModrinthServer; server: ModrinthServer;
@ -135,6 +187,49 @@ const hardReset = ref(false);
const isLoading = ref(false); const isLoading = ref(false);
const loadingServerCheck = ref(false); const loadingServerCheck = ref(false);
const mrpackFile = ref<File | null>(null); const mrpackFile = ref<File | null>(null);
const uploadProgress = ref(0);
const uploadedBytes = ref(0);
const totalBytes = ref(0);
const uploadPhrases = [
"Removing Herobrine...",
"Feeding parrots...",
"Teaching villagers new trades...",
"Convincing creepers to be friendly...",
"Polishing diamonds...",
"Training wolves to fetch...",
"Building pixel art...",
"Explaining redstone to beginners...",
"Collecting all the cats...",
"Negotiating with endermen...",
"Planting suspicious stew ingredients...",
"Calibrating TNT blast radius...",
"Teaching chickens to fly...",
"Sorting inventory alphabetically...",
"Convincing iron golems to smile...",
];
const currentPhrase = ref("Uploading...");
let phraseInterval: NodeJS.Timeout | null = null;
const usedPhrases = ref(new Set<number>());
const getNextPhrase = () => {
if (usedPhrases.value.size >= uploadPhrases.length) {
const currentPhraseIndex = uploadPhrases.indexOf(currentPhrase.value);
usedPhrases.value.clear();
if (currentPhraseIndex !== -1) {
usedPhrases.value.add(currentPhraseIndex);
}
}
const availableIndices = uploadPhrases
.map((_, index) => index)
.filter((index) => !usedPhrases.value.has(index));
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)];
usedPhrases.value.add(randomIndex);
return uploadPhrases[randomIndex];
};
const isDangerous = computed(() => hardReset.value); const isDangerous = computed(() => hardReset.value);
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value); const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
@ -153,18 +248,46 @@ const handleReinstall = async () => {
return; return;
} }
if (!mrpackFile.value) {
addNotification({
group: "server",
title: "No file selected",
text: "Choose a .mrpack file before installing.",
type: "error",
});
return;
}
isLoading.value = true; isLoading.value = true;
uploadProgress.value = 0;
uploadProgress.value = 0;
uploadedBytes.value = 0;
totalBytes.value = mrpackFile.value.size;
currentPhrase.value = getNextPhrase();
phraseInterval = setInterval(() => {
currentPhrase.value = getNextPhrase();
}, 4500);
const { onProgress, promise } = props.server.general.reinstallFromMrpack(
mrpackFile.value,
hardReset.value,
);
onProgress(({ loaded, total, progress }) => {
uploadProgress.value = progress;
uploadedBytes.value = loaded;
totalBytes.value = total;
if (phraseInterval && progress >= 100) {
clearInterval(phraseInterval);
phraseInterval = null;
currentPhrase.value = "Installing modpack...";
}
});
try { try {
if (!mrpackFile.value) { await promise;
throw new Error("No mrpack file selected");
}
const mrpack = new File([mrpackFile.value], mrpackFile.value.name, {
type: mrpackFile.value.type,
});
await props.server.general?.reinstallFromMrpack(mrpack, hardReset.value);
emit("reinstall", { emit("reinstall", {
loader: "mrpack", loader: "mrpack",
@ -176,36 +299,44 @@ const handleReinstall = async () => {
window.scrollTo(0, 0); window.scrollTo(0, 0);
hide(); hide();
} catch (error) { } catch (error) {
if (error instanceof ModrinthServersFetchError && error?.statusCode === 429) { if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
addNotification({ addNotification({
group: "server", group: "server",
title: "Cannot reinstall server", title: "Cannot upload and install modpack to server",
text: "You are being rate limited. Please try again later.", text: "You are being rate limited. Please try again later.",
type: "error", type: "error",
}); });
} else { } else {
addNotification({ addNotification({
group: "server", group: "server",
title: "Reinstall Failed", title: "Modpack upload and install failed",
text: "An unexpected error occurred while reinstalling. Please try again later.", text: "An unexpected error occurred while uploading/installing. Please try again later.",
type: "error", type: "error",
}); });
} }
} finally { } finally {
isLoading.value = false; isLoading.value = false;
if (phraseInterval) {
clearInterval(phraseInterval);
phraseInterval = null;
}
} }
}; };
const onShow = () => { const onShow = () => {
hardReset.value = false; hardReset.value = false;
isMrpackModalSecondPhase.value = false; isMrpackModalSecondPhase.value = false;
loadingServerCheck.value = false; loadingServerCheck.value = false;
isLoading.value = false; isLoading.value = false;
mrpackFile.value = null; mrpackFile.value = null;
}; uploadProgress.value = 0;
uploadedBytes.value = 0;
const onHide = () => { totalBytes.value = 0;
onShow(); currentPhrase.value = "Uploading...";
usedPhrases.value.clear();
if (phraseInterval) {
clearInterval(phraseInterval);
phraseInterval = null;
}
}; };
const show = () => mrpackModal.value?.show(); const show = () => mrpackModal.value?.show();
@ -218,4 +349,14 @@ defineExpose({ show, hide });
.stylized-toggle:checked::after { .stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important; background: var(--color-accent-contrast) !important;
} }
.phrase-fade-enter-active,
.phrase-fade-leave-active {
transition: opacity 0.3s ease;
}
.phrase-fade-enter-from,
.phrase-fade-leave-to {
opacity: 0;
}
</style> </style>

View File

@ -98,28 +98,67 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
} }
} }
async reinstallFromMrpack(mrpack: File, hardReset: boolean = false): Promise<void> { reinstallFromMrpack(
mrpack: File,
hardReset: boolean = false,
): {
promise: Promise<void>;
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) => void;
} {
const hardResetParam = hardReset ? "true" : "false"; const hardResetParam = hardReset ? "true" : "false";
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/reinstallFromMrpack`);
const formData = new FormData(); const progressSubject = new EventTarget();
formData.append("file", mrpack);
const response = await fetch( const uploadPromise = (async () => {
`https://${auth.url}/reinstallMrpackMultiparted?hard=${hardResetParam}`, try {
{ const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/reinstallFromMrpack`);
method: "POST",
headers: {
Authorization: `Bearer ${auth.token}`,
},
body: formData,
signal: AbortSignal.timeout(30 * 60 * 1000),
},
);
if (!response.ok) { await new Promise<void>((resolve, reject) => {
throw new Error(`[pyroservers] native fetch err status: ${response.status}`); const xhr = new XMLHttpRequest();
}
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
progressSubject.dispatchEvent(
new CustomEvent("progress", {
detail: {
loaded: e.loaded,
total: e.total,
progress: (e.loaded / e.total) * 100,
},
}),
);
}
});
xhr.onload = () =>
xhr.status >= 200 && xhr.status < 300
? resolve()
: reject(new Error(`[pyroservers] XHR error status: ${xhr.status}`));
xhr.onerror = () => reject(new Error("[pyroservers] .mrpack upload failed"));
xhr.onabort = () => reject(new Error("[pyroservers] .mrpack upload cancelled"));
xhr.ontimeout = () => reject(new Error("[pyroservers] .mrpack upload timed out"));
xhr.timeout = 30 * 60 * 1000;
xhr.open("POST", `https://${auth.url}/reinstallMrpackMultiparted?hard=${hardResetParam}`);
xhr.setRequestHeader("Authorization", `Bearer ${auth.token}`);
const formData = new FormData();
formData.append("file", mrpack);
xhr.send(formData);
});
} catch (err) {
console.error("Error reinstalling from mrpack:", err);
throw err;
}
})();
return {
promise: uploadPromise,
onProgress: (cb: (p: { loaded: number; total: number; progress: number }) => void) =>
progressSubject.addEventListener("progress", ((e: CustomEvent) =>
cb(e.detail)) as EventListener),
};
} }
async suspend(status: boolean): Promise<void> { async suspend(status: boolean): Promise<void> {

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-arrow-big-right-dash-icon lucide-arrow-big-right-dash">
<path d="M5 9v6" />
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
</svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@ -43,6 +43,7 @@ import _YouTubeIcon from './external/youtube.svg?component'
import _AlignLeftIcon from './icons/align-left.svg?component' import _AlignLeftIcon from './icons/align-left.svg?component'
import _ArchiveIcon from './icons/archive.svg?component' import _ArchiveIcon from './icons/archive.svg?component'
import _ArrowBigUpDashIcon from './icons/arrow-big-up-dash.svg?component' import _ArrowBigUpDashIcon from './icons/arrow-big-up-dash.svg?component'
import _ArrowBigRightDashIcon from './icons/arrow-big-right-dash.svg?component'
import _AsteriskIcon from './icons/asterisk.svg?component' import _AsteriskIcon from './icons/asterisk.svg?component'
import _BadgeCheckIcon from './icons/badge-check.svg?component' import _BadgeCheckIcon from './icons/badge-check.svg?component'
import _BanIcon from './icons/ban.svg?component' import _BanIcon from './icons/ban.svg?component'
@ -445,3 +446,4 @@ export const LoaderIcon = _LoaderIcon
export const ImportIcon = _ImportIcon export const ImportIcon = _ImportIcon
export const CardIcon = _CardIcon export const CardIcon = _CardIcon
export const TimerIcon = _TimerIcon export const TimerIcon = _TimerIcon
export const ArrowBigRightDashIcon = _ArrowBigRightDashIcon