backup page fixes and new impls for new apis (#3437)

* wip: backup page fixes and new impls for new apis

* wip: more progress on backup fixes, almost done

* lint

* Backups cleanup

* Don't show create warning if creating

* Fix ongoing state

* Download support

* Support ready

* Disable auto backup button

* Use auth param for download of backups

* Disable install buttons when backup is in progress, add retrying

* Make prepare button have immediate feedback, don't refresh backups in all cases

* Intl:extract & rebase fixes

* Updated changelog and fix lint

---------

Co-authored-by: Prospector <prospectordev@gmail.com>
This commit is contained in:
Sticks
2025-04-17 04:26:13 -04:00
committed by GitHub
parent 817151e47c
commit f8494030aa
30 changed files with 1550 additions and 1145 deletions

View File

@@ -1,29 +1,32 @@
<template>
<NewModal ref="modal" header="Creating backup" @show="focusInput">
<div class="flex flex-col gap-2 md:w-[600px]">
<div class="font-semibold text-contrast">Name</div>
<label for="backup-name-input">
<span class="text-lg font-semibold text-contrast"> Name </span>
</label>
<input
id="backup-name-input"
ref="input"
v-model="backupName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
placeholder="e.g. Before 1.21"
maxlength="64"
:placeholder="`Backup #${newBackupAmount}`"
maxlength="48"
/>
<div class="flex items-center gap-2">
<InfoIcon class="hidden sm:block" />
<span class="text-sm text-secondary">
If left empty, the backup name will default to
<span class="font-semibold"> Backup #{{ newBackupAmount }}</span>
<div v-if="nameExists && !isCreating" class="flex items-center gap-1">
<IssuesIcon class="hidden text-orange sm:block" />
<span class="text-sm text-orange">
You already have a backup named '<span class="font-semibold">{{ trimmedName }}</span
>'
</span>
</div>
<div v-if="isRateLimited" class="mt-2 text-sm text-red">
You're creating backups too fast. Please wait a moment before trying again.
</div>
</div>
<div class="mb-1 mt-4 flex justify-start gap-4">
<div class="mt-2 flex justify-start gap-2">
<ButtonStyled color="brand">
<button :disabled="isCreating" @click="createBackup">
<button :disabled="isCreating || nameExists" @click="createBackup">
<PlusIcon />
Create backup
</button>
@@ -41,24 +44,30 @@
<script setup lang="ts">
import { ref, nextTick, computed } from "vue";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { PlusIcon, XIcon, InfoIcon } from "@modrinth/assets";
import { IssuesIcon, PlusIcon, XIcon } from "@modrinth/assets";
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const emit = defineEmits(["backupCreated"]);
const modal = ref<InstanceType<typeof NewModal>>();
const input = ref<HTMLInputElement>();
const isCreating = ref(false);
const isRateLimited = ref(false);
const backupError = ref<string | null>(null);
const backupName = ref("");
const newBackupAmount = computed(() =>
props.server.backups?.data?.length === undefined ? 1 : props.server.backups?.data?.length + 1,
);
const trimmedName = computed(() => backupName.value.trim());
const nameExists = computed(() => {
if (!props.server.backups?.data) return false;
return props.server.backups.data.some(
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
);
});
const focusInput = () => {
nextTick(() => {
setTimeout(() => {
@@ -67,30 +76,38 @@ const focusInput = () => {
});
};
function show() {
backupName.value = "";
isCreating.value = false;
modal.value?.show();
}
const hideModal = () => {
modal.value?.hide();
backupName.value = "";
};
const createBackup = async () => {
if (!backupName.value.trim()) {
if (backupName.value.trim().length === 0) {
backupName.value = `Backup #${newBackupAmount.value}`;
}
isCreating.value = true;
isRateLimited.value = false;
try {
await props.server.backups?.create(backupName.value);
await props.server.refresh();
await props.server.backups?.create(trimmedName.value);
hideModal();
emit("backupCreated", { success: true, message: "Backup created successfully" });
await props.server.refresh();
} catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 429) {
isRateLimited.value = true;
backupError.value = "You're creating backups too fast.";
addNotification({
type: "error",
title: "Error creating backup",
text: "You're creating backups too fast.",
});
} else {
backupError.value = error instanceof Error ? error.message : String(error);
emit("backupCreated", { success: false, message: backupError.value });
const message = error instanceof Error ? error.message : String(error);
addNotification({ type: "error", title: "Error creating backup", text: message });
}
} finally {
isCreating.value = false;
@@ -98,7 +115,7 @@ const createBackup = async () => {
};
defineExpose({
show: () => modal.value?.show(),
show,
hide: hideModal,
});
</script>

View File

@@ -1,86 +1,45 @@
<template>
<NewModal ref="modal" danger header="Deleting backup">
<div class="flex flex-col gap-4">
<div class="relative flex w-full flex-col gap-2 rounded-2xl bg-[#0e0e0ea4] p-6">
<div class="text-2xl font-extrabold text-contrast">
{{ backupName }}
</div>
<div class="flex gap-2 font-semibold text-contrast">
<CalendarIcon />
{{ formattedDate }}
</div>
</div>
</div>
<div class="mb-1 mt-4 flex justify-end gap-4">
<ButtonStyled color="red">
<button :disabled="isDeleting" @click="deleteBackup">
<TrashIcon />
Delete backup
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button @click="hideModal">Cancel</button>
</ButtonStyled>
</div>
</NewModal>
<ConfirmModal
ref="modal"
danger
title="Are you sure you want to delete this backup?"
proceed-label="Delete backup"
:confirmation-text="currentBackup?.name ?? 'null'"
has-to-type
@proceed="emit('delete', currentBackup)"
>
<BackupItem
v-if="currentBackup"
:backup="currentBackup"
preview
class="border-px border-solid border-button-border"
/>
</ConfirmModal>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { TrashIcon, CalendarIcon } from "@modrinth/assets";
import { ConfirmModal } from "@modrinth/ui";
import type { Server } from "~/composables/pyroServers";
import BackupItem from "~/components/ui/servers/BackupItem.vue";
const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
backupId: string;
backupName: string;
backupCreatedAt: string;
defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const emit = defineEmits(["backupDeleted"]);
const emit = defineEmits<{
(e: "delete", backup: Backup | undefined): void;
}>();
const modal = ref<InstanceType<typeof NewModal>>();
const isDeleting = ref(false);
const backupError = ref<string | null>(null);
const modal = ref<InstanceType<typeof ConfirmModal>>();
const currentBackup = ref<Backup | undefined>(undefined);
const formattedDate = computed(() => {
return new Date(props.backupCreatedAt).toLocaleString("en-US", {
month: "numeric",
day: "numeric",
year: "2-digit",
hour: "numeric",
minute: "numeric",
hour12: true,
});
});
const hideModal = () => {
modal.value?.hide();
};
const deleteBackup = async () => {
if (!props.backupId) {
emit("backupDeleted", { success: false, message: "No backup selected" });
return;
}
isDeleting.value = true;
try {
await props.server.backups?.delete(props.backupId);
await props.server.refresh();
hideModal();
emit("backupDeleted", { success: true, message: "Backup deleted successfully" });
} catch (error) {
backupError.value = error instanceof Error ? error.message : String(error);
emit("backupDeleted", { success: false, message: backupError.value });
} finally {
isDeleting.value = false;
}
};
function show(backup: Backup) {
currentBackup.value = backup;
modal.value?.show();
}
defineExpose({
show: () => modal.value?.show(),
hide: hideModal,
show,
});
</script>

View File

@@ -0,0 +1,320 @@
<script setup lang="ts">
import dayjs from "dayjs";
import {
MoreVerticalIcon,
HistoryIcon,
DownloadIcon,
SpinnerIcon,
EditIcon,
LockIcon,
TrashIcon,
FolderArchiveIcon,
BotIcon,
XIcon,
LockOpenIcon,
RotateCounterClockwiseIcon,
} from "@modrinth/assets";
import { ButtonStyled, commonMessages, OverflowMenu, ProgressBar } from "@modrinth/ui";
import { defineMessages, useVIntl } from "@vintl/vintl";
import { ref } from "vue";
import type { Backup } from "~/composables/pyroServers.ts";
const flags = useFeatureFlags();
const { formatMessage } = useVIntl();
const emit = defineEmits<{
(e: "prepare" | "download" | "rename" | "restore" | "lock" | "retry"): void;
(e: "delete", skipConfirmation?: boolean): void;
}>();
const props = withDefaults(
defineProps<{
backup: Backup;
preview?: boolean;
kyrosUrl?: string;
jwt?: string;
}>(),
{
preview: false,
kyrosUrl: undefined,
jwt: undefined,
},
);
const backupQueued = computed(
() =>
props.backup.task?.create?.progress === 0 ||
(props.backup.ongoing && !props.backup.task?.create),
);
const automated = computed(() => props.backup.automated);
const failedToCreate = computed(() => props.backup.interrupted);
const preparedDownloadStates = ["ready", "done"];
const inactiveStates = ["failed", "cancelled"];
const hasPreparedDownload = computed(() =>
preparedDownloadStates.includes(props.backup.task?.file?.state ?? ""),
);
const creating = computed(() => {
const task = props.backup.task?.create;
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return task;
}
if (props.backup.ongoing) {
return {
progress: 0,
state: "ongoing",
};
}
return undefined;
});
const restoring = computed(() => {
const task = props.backup.task?.restore;
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return task;
}
return undefined;
});
const initiatedPrepare = ref(false);
const preparingFile = computed(() => {
const task = props.backup.task?.file;
return (
initiatedPrepare.value || (task && task.progress < 1 && !inactiveStates.includes(task.state))
);
});
const failedToRestore = computed(() => props.backup.task?.restore?.state === "failed");
const messages = defineMessages({
locked: {
id: "servers.backups.item.locked",
defaultMessage: "Locked",
},
lock: {
id: "servers.backups.item.lock",
defaultMessage: "Lock",
},
unlock: {
id: "servers.backups.item.unlock",
defaultMessage: "Unlock",
},
restore: {
id: "servers.backups.item.restore",
defaultMessage: "Restore",
},
rename: {
id: "servers.backups.item.rename",
defaultMessage: "Rename",
},
queuedForBackup: {
id: "servers.backups.item.queued-for-backup",
defaultMessage: "Queued for backup",
},
preparingDownload: {
id: "servers.backups.item.preparing-download",
defaultMessage: "Preparing download...",
},
prepareDownload: {
id: "servers.backups.item.prepare-download",
defaultMessage: "Prepare download",
},
alreadyPreparing: {
id: "servers.backups.item.already-preparing",
defaultMessage: "Already preparing backup for download",
},
creatingBackup: {
id: "servers.backups.item.creating-backup",
defaultMessage: "Creating backup...",
},
restoringBackup: {
id: "servers.backups.item.restoring-backup",
defaultMessage: "Restoring from backup...",
},
failedToCreateBackup: {
id: "servers.backups.item.failed-to-create-backup",
defaultMessage: "Failed to create backup",
},
failedToRestoreBackup: {
id: "servers.backups.item.failed-to-restore-backup",
defaultMessage: "Failed to restore from backup",
},
automated: {
id: "servers.backups.item.automated",
defaultMessage: "Automated",
},
retry: {
id: "servers.backups.item.retry",
defaultMessage: "Retry",
},
});
</script>
<template>
<div
:class="
preview
? 'grid-cols-[min-content_1fr_1fr] sm:grid-cols-[min-content_3fr_2fr_1fr] md:grid-cols-[auto_3fr_2fr_1fr]'
: 'grid-cols-[min-content_1fr_1fr] sm:grid-cols-[min-content_3fr_2fr_1fr] md:grid-cols-[auto_3fr_2fr_1fr_2fr]'
"
class="grid items-center gap-4 rounded-2xl bg-bg-raised px-4 py-3"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg"
>
<SpinnerIcon
v-if="creating"
class="h-6 w-6 animate-spin"
:class="{ 'text-orange': backupQueued, 'text-green': !backupQueued }"
/>
<FolderArchiveIcon v-else class="h-6 w-6" />
</div>
<div class="col-span-2 flex flex-col gap-1 sm:col-span-1">
<span class="font-bold text-contrast">
{{ backup.name }}
</span>
<div class="flex items-center gap-2 text-sm">
<span v-if="backup.locked" class="flex items-center gap-1 text-sm text-secondary">
<LockIcon /> {{ formatMessage(messages.locked) }}
</span>
<span v-if="automated && backup.locked"></span>
<span v-if="automated" class="flex items-center gap-1 text-secondary">
<BotIcon /> {{ formatMessage(messages.automated) }}
</span>
<span v-if="(failedToCreate || failedToRestore) && (automated || backup.locked)"></span>
<span
v-if="failedToCreate || failedToRestore"
class="flex items-center gap-1 text-sm text-red"
>
<XIcon />
{{
formatMessage(
failedToCreate ? messages.failedToCreateBackup : messages.failedToRestoreBackup,
)
}}
</span>
</div>
</div>
<div v-if="creating" class="col-span-2 flex flex-col gap-3">
<span v-if="backupQueued" class="text-orange">
{{ formatMessage(messages.queuedForBackup) }}
</span>
<span v-else class="text-green"> {{ formatMessage(messages.creatingBackup) }} </span>
<ProgressBar
:progress="creating.progress"
:color="backupQueued ? 'orange' : 'green'"
:waiting="creating.progress === 0"
class="max-w-full"
/>
</div>
<div v-else-if="restoring" class="col-span-2 flex flex-col gap-3 text-purple">
{{ formatMessage(messages.restoringBackup) }}
<ProgressBar
:progress="restoring.progress"
color="purple"
:waiting="restoring.progress === 0"
class="max-w-full"
/>
</div>
<template v-else>
<div class="col-span-2">
{{ dayjs(backup.created_at).format("MMMM D, YYYY [at] h:mm A") }}
</div>
<div v-if="false">{{ 245 }} MiB</div>
</template>
<div
v-if="!preview"
class="col-span-full flex justify-normal gap-2 md:col-span-1 md:justify-end"
>
<template v-if="failedToCreate">
<ButtonStyled>
<button @click="() => emit('retry')">
<RotateCounterClockwiseIcon />
{{ formatMessage(messages.retry) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="() => emit('delete', true)">
<TrashIcon />
Remove
</button>
</ButtonStyled>
</template>
<ButtonStyled v-else-if="creating">
<button @click="() => emit('delete')">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<template v-else>
<ButtonStyled>
<a
v-if="hasPreparedDownload"
:class="{
disabled: !kyrosUrl || !jwt,
}"
:href="`https://${kyrosUrl}/modrinth/v0/backups/${backup.id}/download?auth=${jwt}`"
@click="() => emit('download')"
>
<DownloadIcon />
{{ formatMessage(commonMessages.downloadButton) }}
</a>
<button
v-else
:disabled="!!preparingFile"
@click="
() => {
initiatedPrepare = true;
emit('prepare');
}
"
>
<SpinnerIcon v-if="preparingFile" class="animate-spin" />
<DownloadIcon v-else />
{{
formatMessage(preparingFile ? messages.preparingDownload : messages.prepareDownload)
}}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{ id: 'rename', action: () => emit('rename') },
{
id: 'restore',
action: () => emit('restore'),
disabled: !!restoring || !!preparingFile,
},
{ id: 'lock', action: () => emit('lock') },
{ divider: true },
{
id: 'delete',
color: 'red',
action: () => emit('delete'),
disabled: !!restoring || !!preparingFile,
},
]"
>
<MoreVerticalIcon />
<template #rename> <EditIcon /> {{ formatMessage(messages.rename) }} </template>
<template #restore> <HistoryIcon /> {{ formatMessage(messages.restore) }} </template>
<template v-if="backup.locked" #lock>
<LockOpenIcon /> {{ formatMessage(messages.unlock) }}
</template>
<template v-else #lock> <LockIcon /> {{ formatMessage(messages.lock) }} </template>
<template #delete>
<TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</div>
<pre
v-if="!preview && flags.advancedDebugInfo"
class="col-span-full m-0 rounded-xl bg-button-bg text-xs"
>{{ backup }}</pre
>
</div>
</template>

View File

@@ -1,24 +1,41 @@
<template>
<NewModal ref="modal" header="Renaming backup" @show="focusInput">
<div class="flex flex-col gap-2 md:w-[600px]">
<div class="font-semibold text-contrast">Name</div>
<label for="backup-name-input">
<span class="text-lg font-semibold text-contrast"> Name </span>
</label>
<input
id="backup-name-input"
ref="input"
v-model="backupName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
placeholder="e.g. Before 1.21"
:placeholder="`Backup #${backupNumber}`"
maxlength="48"
/>
<div v-if="nameExists" class="flex items-center gap-1">
<IssuesIcon class="hidden text-orange sm:block" />
<span class="text-sm text-orange">
You already have a backup named '<span class="font-semibold">{{ trimmedName }}</span
>'
</span>
</div>
</div>
<div class="mb-1 mt-4 flex justify-start gap-4">
<div class="mt-2 flex justify-start gap-2">
<ButtonStyled color="brand">
<button :disabled="isRenaming" @click="renameBackup">
<SaveIcon />
Rename backup
<button :disabled="isRenaming || nameExists" @click="renameBackup">
<template v-if="isRenaming">
<SpinnerIcon class="animate-spin" />
Renaming...
</template>
<template v-else>
<SaveIcon />
Save changes
</template>
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hideModal">
<button @click="hide">
<XIcon />
Cancel
</button>
@@ -28,23 +45,38 @@
</template>
<script setup lang="ts">
import { ref, nextTick } from "vue";
import { ref, nextTick, computed } from "vue";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { SaveIcon, XIcon } from "@modrinth/assets";
import { SpinnerIcon, SaveIcon, XIcon, IssuesIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
currentBackupId: string;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const emit = defineEmits(["backupRenamed"]);
const modal = ref<InstanceType<typeof NewModal>>();
const input = ref<HTMLInputElement>();
const backupName = ref("");
const originalName = ref("");
const isRenaming = ref(false);
const backupError = ref<string | null>(null);
const currentBackup = ref<Backup | null>(null);
const trimmedName = computed(() => backupName.value.trim());
const nameExists = computed(() => {
if (!props.server.backups?.data || trimmedName.value === originalName.value || isRenaming.value) {
return false;
}
return props.server.backups.data.some(
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
);
});
const backupNumber = computed(
() => (props.server.backups?.data?.findIndex((b) => b.id === currentBackup.value?.id) ?? 0) + 1,
);
const focusInput = () => {
nextTick(() => {
@@ -54,33 +86,55 @@ const focusInput = () => {
});
};
const hideModal = () => {
backupName.value = "";
function show(backup: Backup) {
currentBackup.value = backup;
backupName.value = backup.name;
originalName.value = backup.name;
isRenaming.value = false;
modal.value?.show();
}
function hide() {
modal.value?.hide();
};
}
const renameBackup = async () => {
if (!backupName.value.trim() || !props.currentBackupId) {
emit("backupRenamed", { success: false, message: "Backup name cannot be empty" });
if (!currentBackup.value) {
addNotification({
type: "error",
title: "Error renaming backup",
text: "Current backup is null",
});
return;
}
if (trimmedName.value === originalName.value) {
hide();
return;
}
isRenaming.value = true;
try {
await props.server.backups?.rename(props.currentBackupId, backupName.value);
let newName = trimmedName.value;
if (newName.length === 0) {
newName = `Backup #${backupNumber.value}`;
}
await props.server.backups?.rename(currentBackup.value.id, newName);
hide();
await props.server.refresh();
hideModal();
emit("backupRenamed", { success: true, message: "Backup renamed successfully" });
} catch (error) {
backupError.value = error instanceof Error ? error.message : String(error);
emit("backupRenamed", { success: false, message: backupError.value });
const message = error instanceof Error ? error.message : String(error);
addNotification({ type: "error", title: "Error renaming backup", text: message });
} finally {
hide();
isRenaming.value = false;
}
};
defineExpose({
show: () => modal.value?.show(),
hide: hideModal,
show,
hide,
});
</script>

View File

@@ -1,82 +1,58 @@
<template>
<NewModal ref="modal" header="Restoring backup">
<div class="flex flex-col gap-4">
<div class="relative flex w-full flex-col gap-2 rounded-2xl bg-bg p-6">
<div class="text-2xl font-extrabold text-contrast">
{{ backupName }}
</div>
<div class="flex gap-2 font-semibold text-contrast">
<CalendarIcon />
{{ formattedDate }}
</div>
</div>
</div>
<div class="mb-1 mt-4 flex justify-end gap-4">
<ButtonStyled color="brand">
<button :disabled="isRestoring" @click="restoreBackup">Restore backup</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button @click="hideModal">Cancel</button>
</ButtonStyled>
</div>
</NewModal>
<ConfirmModal
ref="modal"
danger
title="Are you sure you want to restore from this backup?"
proceed-label="Restore from backup"
description="This will **overwrite all files on your server** and replace them with the files from the backup."
@proceed="restoreBackup"
>
<BackupItem
v-if="currentBackup"
:backup="currentBackup"
preview
class="border-px border-solid border-button-border"
/>
</ConfirmModal>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { CalendarIcon } from "@modrinth/assets";
import { ConfirmModal, NewModal } from "@modrinth/ui";
import type { Server } from "~/composables/pyroServers";
import BackupItem from "~/components/ui/servers/BackupItem.vue";
const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
backupId: string;
backupName: string;
backupCreatedAt: string;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const emit = defineEmits(["backupRestored"]);
const modal = ref<InstanceType<typeof NewModal>>();
const isRestoring = ref(false);
const backupError = ref<string | null>(null);
const currentBackup = ref<Backup | null>(null);
const formattedDate = computed(() => {
return new Date(props.backupCreatedAt).toLocaleString("en-US", {
month: "numeric",
day: "numeric",
year: "2-digit",
hour: "numeric",
minute: "numeric",
hour12: true,
});
});
const hideModal = () => {
modal.value?.hide();
};
function show(backup: Backup) {
currentBackup.value = backup;
modal.value?.show();
}
const restoreBackup = async () => {
if (!props.backupId) {
emit("backupRestored", { success: false, message: "No backup selected" });
if (!currentBackup.value) {
addNotification({
type: "error",
title: "Failed to restore backup",
text: "Current backup is null",
});
return;
}
isRestoring.value = true;
try {
await props.server.backups?.restore(props.backupId);
hideModal();
emit("backupRestored", { success: true, message: "Backup restored successfully" });
await props.server.backups?.restore(currentBackup.value.id);
} catch (error) {
backupError.value = error instanceof Error ? error.message : String(error);
emit("backupRestored", { success: false, message: backupError.value });
} finally {
isRestoring.value = false;
const message = error instanceof Error ? error.message : String(error);
addNotification({ type: "error", title: "Failed to restore backup", text: message });
}
};
defineExpose({
show: () => modal.value?.show(),
hide: hideModal,
show,
});
</script>

View File

@@ -42,6 +42,9 @@
:column="true"
class="mb-6 flex flex-col gap-2"
/>
<div v-if="flags.advancedDebugInfo" class="markdown-body">
<pre>{{ serverData }}</pre>
</div>
<ButtonStyled type="standard" color="brand" @click="closeDetailsModal">
<button class="w-full">Close</button>
</ButtonStyled>

View File

@@ -67,37 +67,27 @@
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>
<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="backup-server-mrpack">
Backup server
</label>
<input
id="backup-server-mrpack"
v-model="backupServer"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>Creates a backup of your server before proceeding.</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 :disabled="canInstall" @click="handleReinstall">
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="canInstall || backupInProgress"
@click="handleReinstall"
>
<RightArrowIcon />
{{
isBackingUp
? "Backing up..."
: isMrpackModalSecondPhase
? "Erase and install"
: loadingServerCheck
? "Loading..."
: isDangerous
? "Erase and install"
: "Install"
isMrpackModalSecondPhase
? "Erase and install"
: loadingServerCheck
? "Loading..."
: isDangerous
? "Erase and install"
: "Install"
}}
</button>
</ButtonStyled>
@@ -124,12 +114,14 @@
</template>
<script setup lang="ts">
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
import { UploadIcon, RightArrowIcon, XIcon, ServerIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
backupInProgress?: BackupInProgressReason;
}>();
const emit = defineEmits<{
@@ -139,9 +131,7 @@ const emit = defineEmits<{
const mrpackModal = ref();
const isMrpackModalSecondPhase = ref(false);
const hardReset = ref(false);
const backupServer = ref(false);
const isLoading = ref(false);
const isBackingUp = ref(false);
const loadingServerCheck = ref(false);
const mrpackFile = ref<File | null>(null);
@@ -156,64 +146,12 @@ const uploadMrpack = (event: Event) => {
mrpackFile.value = target.files[0];
};
const performBackup = async (): Promise<boolean> => {
try {
const date = new Date();
const format = date.toLocaleString(navigator.language || "en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZoneName: "short",
});
const backupName = `Reinstallation - ${format}`;
isLoading.value = true;
const backupId = await props.server.backups?.create(backupName);
isBackingUp.value = true;
let attempts = 0;
while (true) {
attempts++;
if (attempts > 100) {
addNotification({
group: "server",
title: "Backup Failed",
text: "An unexpected error occurred while backing up. Please try again later.",
});
return false;
}
await props.server.refresh(["backups"]);
const backups = await props.server.backups?.data;
const backup = backupId ? backups?.find((x) => x.id === backupId) : undefined;
if (backup && !backup.ongoing) {
isBackingUp.value = false;
break;
}
await new Promise((resolve) => setTimeout(resolve, 5000));
}
return true;
} catch {
addNotification({
group: "server",
title: "Backup Failed",
text: "An unexpected error occurred while backing up. Please try again later.",
});
return false;
}
};
const handleReinstall = async () => {
if (hardReset.value && !backupServer.value && !isMrpackModalSecondPhase.value) {
if (hardReset.value && !isMrpackModalSecondPhase.value) {
isMrpackModalSecondPhase.value = true;
return;
}
if (backupServer.value && !(await performBackup())) {
isLoading.value = false;
return;
}
isLoading.value = true;
try {
@@ -259,7 +197,6 @@ const handleReinstall = async () => {
const onShow = () => {
hardReset.value = false;
backupServer.value = false;
isMrpackModalSecondPhase.value = false;
loadingServerCheck.value = false;
isLoading.value = false;

View File

@@ -20,9 +20,7 @@
}"
>
{{
backupServer
? "A backup will be created before proceeding with the reinstallation, then all data will be erased from your server. Are you sure you want to continue?"
: "This will reinstall your server and erase all data. Are you sure you want to continue?"
"This will reinstall your server and erase all data. Are you sure you want to continue?"
}}
</p>
<div v-if="!isSecondPhase" class="flex flex-col gap-4">
@@ -131,41 +129,28 @@
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>
<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="backup-server">
Backup server
</label>
<input
id="backup-server"
v-model="backupServer"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>
Creates a backup of your server before proceeding with the installation or
reinstallation.
</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 :disabled="canInstall" @click="handleReinstall">
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="canInstall || !!backupInProgress"
@click="handleReinstall"
>
<RightArrowIcon />
{{
isBackingUp
? "Backing up..."
: isLoading
? "Installing..."
: isSecondPhase
? "Erase and install"
: hardReset
? "Continue"
: "Install"
isLoading
? "Installing..."
: isSecondPhase
? "Erase and install"
: hardReset
? "Continue"
: "Install"
}}
</button>
</ButtonStyled>
@@ -192,10 +177,13 @@
</template>
<script setup lang="ts">
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
import { RightArrowIcon, XIcon, ServerIcon, DropdownIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
import type { Loaders } from "~/types/servers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
const { formatMessage } = useVIntl();
interface LoaderVersion {
id: string;
@@ -213,6 +201,7 @@ type VersionCache = Record<string, any>;
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
currentLoader: Loaders | undefined;
backupInProgress?: BackupInProgressReason;
}>();
const emit = defineEmits<{
@@ -222,9 +211,7 @@ const emit = defineEmits<{
const versionSelectModal = ref();
const isSecondPhase = ref(false);
const hardReset = ref(false);
const backupServer = ref(false);
const isLoading = ref(false);
const isBackingUp = ref(false);
const loadingServerCheck = ref(false);
const serverCheckError = ref("");
@@ -413,69 +400,12 @@ const canInstall = computed(() => {
return conds || !selectedLoaderVersion.value;
});
const performBackup = async (): Promise<boolean> => {
try {
const date = new Date();
const format = date.toLocaleString(navigator.language || "en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZoneName: "short",
});
const backupName = `Reinstallation - ${format}`;
isLoading.value = true;
const backupId = await props.server.backups?.create(backupName);
isBackingUp.value = true;
let attempts = 0;
while (true) {
attempts++;
if (attempts > 100) {
addNotification({
group: "server",
title: "Backup Failed",
text: "An unexpected error occurred while backing up. Please try again later.",
});
return false;
}
await props.server.refresh(["backups"]);
const backups = await props.server.backups?.data;
const backup = backupId ? backups?.find((x) => x.id === backupId) : undefined;
if (backup && !backup.ongoing) {
isBackingUp.value = false;
break;
}
await new Promise((resolve) => setTimeout(resolve, 5000));
}
return true;
} catch {
addNotification({
group: "server",
title: "Backup Failed",
text: "An unexpected error occurred while backing up. Please try again later.",
});
return false;
}
};
const handleReinstall = async () => {
if (hardReset.value && !isSecondPhase.value) {
isSecondPhase.value = true;
return;
}
if (backupServer.value) {
isBackingUp.value = true;
if (!(await performBackup())) {
isBackingUp.value = false;
isLoading.value = false;
return;
}
isBackingUp.value = false;
}
isLoading.value = true;
try {
@@ -522,7 +452,6 @@ const onShow = () => {
const onHide = () => {
hardReset.value = false;
backupServer.value = false;
isSecondPhase.value = false;
serverCheckError.value = "";
loadingServerCheck.value = false;

View File

@@ -21,7 +21,12 @@
</div>
<div class="h-full w-full">
<NuxtPage :route="props.route" :server="props.server" @reinstall="onReinstall" />
<NuxtPage
:route="route"
:server="server"
:backup-in-progress="backupInProgress"
@reinstall="onReinstall"
/>
</div>
</div>
</template>
@@ -30,13 +35,15 @@
import { RightArrowIcon } from "@modrinth/assets";
import type { RouteLocationNormalized } from "vue-router";
import type { Server } from "~/composables/pyroServers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
const emit = defineEmits(["reinstall"]);
const props = defineProps<{
defineProps<{
navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
route: RouteLocationNormalized;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
backupInProgress?: BackupInProgressReason;
}>();
const onReinstall = (...args: any[]) => {