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[]) => {

View File

@@ -30,6 +30,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
newProjectCards: false,
projectBackground: false,
searchBackground: false,
advancedDebugInfo: false,
// advancedRendering: true,
// externalLinksNewTab: true,
// notUsingBlockers: false,

View File

@@ -1,6 +1,7 @@
// usePyroServer is a composable that interfaces with the REDACTED API to get data and control the users server
import { $fetch, FetchError } from "ofetch";
import type { ServerNotice } from "@modrinth/utils";
import type { WSBackupState, WSBackupTask } from "~/types/servers.ts";
interface PyroFetchOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
@@ -193,7 +194,7 @@ async function PyroFetch<T>(
throw lastError || new Error("Maximum retry attempts reached");
}
const internalServerRefrence = ref<any>(null);
const internalServerReference = ref<any>(null);
interface License {
id: string;
@@ -291,6 +292,10 @@ interface General {
sftp_host: string;
datacenter?: string;
notices?: ServerNotice[];
node: {
token: string;
instance: string;
};
}
interface Allocation {
@@ -317,12 +322,20 @@ export interface Mod {
installing: boolean;
}
interface Backup {
export interface Backup {
id: string;
name: string;
created_at: string;
ongoing: boolean;
locked: boolean;
automated: boolean;
interrupted: boolean;
ongoing: boolean;
task: {
[K in WSBackupTask]?: {
progress: number;
state: WSBackupState;
};
};
}
interface AutoBackupSettings {
@@ -370,7 +383,7 @@ const constructServerProperties = (properties: any): string => {
const processImage = async (iconUrl: string | undefined) => {
const sharedImage = useState<string | undefined>(
`server-icon-${internalServerRefrence.value.serverId}`,
`server-icon-${internalServerReference.value.serverId}`,
);
if (sharedImage.value) {
@@ -378,7 +391,7 @@ const processImage = async (iconUrl: string | undefined) => {
}
try {
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerRefrence.value.serverId}/fs`);
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerReference.value.serverId}/fs`);
try {
const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, {
override: auth,
@@ -465,13 +478,13 @@ const processImage = async (iconUrl: string | undefined) => {
const sendPowerAction = async (action: string) => {
try {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/power`, {
await PyroFetch(`servers/${internalServerReference.value.serverId}/power`, {
method: "POST",
body: { action },
});
await new Promise((resolve) => setTimeout(resolve, 1000));
await internalServerRefrence.value.refresh();
await internalServerReference.value.refresh();
} catch (error) {
console.error("Error changing power state:", error);
throw error;
@@ -480,7 +493,7 @@ const sendPowerAction = async (action: string) => {
const updateName = async (newName: string) => {
try {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/name`, {
await PyroFetch(`servers/${internalServerReference.value.serverId}/name`, {
method: "POST",
body: { name: newName },
});
@@ -524,7 +537,7 @@ const reinstallFromMrpack = async (mrpack: File, hardReset: boolean = false) =>
const hardResetParam = hardReset ? "true" : "false";
try {
const auth = await PyroFetch<JWTAuth>(
`servers/${internalServerRefrence.value.serverId}/reinstallFromMrpack`,
`servers/${internalServerReference.value.serverId}/reinstallFromMrpack`,
);
const formData = new FormData();
@@ -553,7 +566,7 @@ const reinstallFromMrpack = async (mrpack: File, hardReset: boolean = false) =>
const suspendServer = async (status: boolean) => {
try {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/suspend`, {
await PyroFetch(`servers/${internalServerReference.value.serverId}/suspend`, {
method: "POST",
body: { suspended: status },
});
@@ -565,7 +578,7 @@ const suspendServer = async (status: boolean) => {
const fetchConfigFile = async (fileName: string) => {
try {
return await PyroFetch(`servers/${internalServerRefrence.value.serverId}/config/${fileName}`);
return await PyroFetch(`servers/${internalServerReference.value.serverId}/config/${fileName}`);
} catch (error) {
console.error("Error fetching config file:", error);
throw error;
@@ -596,7 +609,7 @@ const setMotd = async (motd: string) => {
const newProps = constructServerProperties(props);
const octetStream = new Blob([newProps], { type: "application/octet-stream" });
const auth = await await PyroFetch<JWTAuth>(
`servers/${internalServerRefrence.value.serverId}/fs`,
`servers/${internalServerReference.value.serverId}/fs`,
);
return await PyroFetch(`/update?path=/server.properties`, {
@@ -615,7 +628,7 @@ const setMotd = async (motd: string) => {
const installContent = async (contentType: ContentType, projectId: string, versionId: string) => {
try {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods`, {
await PyroFetch(`servers/${internalServerReference.value.serverId}/mods`, {
method: "POST",
body: {
rinth_ids: { project_id: projectId, version_id: versionId },
@@ -630,7 +643,7 @@ const installContent = async (contentType: ContentType, projectId: string, versi
const removeContent = async (path: string) => {
try {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/deleteMod`, {
await PyroFetch(`servers/${internalServerReference.value.serverId}/deleteMod`, {
method: "POST",
body: {
path,
@@ -644,7 +657,7 @@ const removeContent = async (path: string) => {
const reinstallContent = async (replace: string, projectId: string, versionId: string) => {
try {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods/update`, {
await PyroFetch(`servers/${internalServerReference.value.serverId}/mods/update`, {
method: "POST",
body: { replace, project_id: projectId, version_id: versionId },
});
@@ -659,13 +672,13 @@ const reinstallContent = async (replace: string, projectId: string, versionId: s
const createBackup = async (backupName: string) => {
try {
const response = await PyroFetch<{ id: string }>(
`servers/${internalServerRefrence.value.serverId}/backups`,
`servers/${internalServerReference.value.serverId}/backups`,
{
method: "POST",
body: { name: backupName },
},
);
await internalServerRefrence.value.refresh(["backups"]);
await internalServerReference.value.refresh(["backups"]);
return response.id;
} catch (error) {
console.error("Error creating backup:", error);
@@ -675,11 +688,14 @@ const createBackup = async (backupName: string) => {
const renameBackup = async (backupId: string, newName: string) => {
try {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/rename`, {
method: "POST",
body: { name: newName },
});
await internalServerRefrence.value.refresh(["backups"]);
await PyroFetch(
`servers/${internalServerReference.value.serverId}/backups/${backupId}/rename`,
{
method: "POST",
body: { name: newName },
},
);
await internalServerReference.value.refresh(["backups"]);
} catch (error) {
console.error("Error renaming backup:", error);
throw error;
@@ -688,10 +704,10 @@ const renameBackup = async (backupId: string, newName: string) => {
const deleteBackup = async (backupId: string) => {
try {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}`, {
await PyroFetch(`servers/${internalServerReference.value.serverId}/backups/${backupId}`, {
method: "DELETE",
});
await internalServerRefrence.value.refresh(["backups"]);
await internalServerReference.value.refresh(["backups"]);
} catch (error) {
console.error("Error deleting backup:", error);
throw error;
@@ -701,30 +717,35 @@ const deleteBackup = async (backupId: string) => {
const restoreBackup = async (backupId: string) => {
try {
await PyroFetch(
`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/restore`,
`servers/${internalServerReference.value.serverId}/backups/${backupId}/restore`,
{
method: "POST",
},
);
await internalServerRefrence.value.refresh(["backups"]);
await internalServerReference.value.refresh(["backups"]);
} catch (error) {
console.error("Error restoring backup:", error);
throw error;
}
};
const downloadBackup = async (backupId: string) => {
const prepareBackup = async (backupId: string) => {
try {
return await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}`);
await PyroFetch(
`servers/${internalServerReference.value.serverId}/backups/${backupId}/prepare-download`,
{
method: "POST",
},
);
} catch (error) {
console.error("Error downloading backup:", error);
console.error("Error preparing backup:", error);
throw error;
}
};
const updateAutoBackup = async (autoBackup: "enable" | "disable", interval: number) => {
try {
return await PyroFetch(`servers/${internalServerRefrence.value.serverId}/autobackup`, {
return await PyroFetch(`servers/${internalServerReference.value.serverId}/autobackup`, {
method: "POST",
body: { set: autoBackup, interval },
});
@@ -736,7 +757,7 @@ const updateAutoBackup = async (autoBackup: "enable" | "disable", interval: numb
const getAutoBackup = async () => {
try {
return await PyroFetch(`servers/${internalServerRefrence.value.serverId}/autobackup`);
return await PyroFetch(`servers/${internalServerReference.value.serverId}/autobackup`);
} catch (error) {
console.error("Error getting auto backup settings:", error);
throw error;
@@ -745,10 +766,10 @@ const getAutoBackup = async () => {
const lockBackup = async (backupId: string) => {
try {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`, {
await PyroFetch(`servers/${internalServerReference.value.serverId}/backups/${backupId}/lock`, {
method: "POST",
});
await internalServerRefrence.value.refresh(["backups"]);
await internalServerReference.value.refresh(["backups"]);
} catch (error) {
console.error("Error locking backup:", error);
throw error;
@@ -757,22 +778,36 @@ const lockBackup = async (backupId: string) => {
const unlockBackup = async (backupId: string) => {
try {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`, {
method: "POST",
});
await internalServerRefrence.value.refresh(["backups"]);
await PyroFetch(
`servers/${internalServerReference.value.serverId}/backups/${backupId}/unlock`,
{
method: "POST",
},
);
await internalServerReference.value.refresh(["backups"]);
} catch (error) {
console.error("Error unlocking backup:", error);
throw error;
}
};
const retryBackup = async (backupId: string) => {
try {
await PyroFetch(`servers/${internalServerReference.value.serverId}/backups/${backupId}/retry`, {
method: "POST",
});
} catch (error) {
console.error("Error retrying backup:", error);
throw error;
}
};
// ------------------ NETWORK ------------------ //
const reserveAllocation = async (name: string): Promise<Allocation> => {
try {
return await PyroFetch<Allocation>(
`servers/${internalServerRefrence.value.serverId}/allocations?name=${name}`,
`servers/${internalServerReference.value.serverId}/allocations?name=${name}`,
{
method: "POST",
},
@@ -786,7 +821,7 @@ const reserveAllocation = async (name: string): Promise<Allocation> => {
const updateAllocation = async (port: number, name: string) => {
try {
await PyroFetch(
`servers/${internalServerRefrence.value.serverId}/allocations/${port}?name=${name}`,
`servers/${internalServerReference.value.serverId}/allocations/${port}?name=${name}`,
{
method: "PUT",
},
@@ -799,7 +834,7 @@ const updateAllocation = async (port: number, name: string) => {
const deleteAllocation = async (port: number) => {
try {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/allocations/${port}`, {
await PyroFetch(`servers/${internalServerReference.value.serverId}/allocations/${port}`, {
method: "DELETE",
});
} catch (error) {
@@ -819,7 +854,7 @@ const checkSubdomainAvailability = async (subdomain: string): Promise<{ availabl
const changeSubdomain = async (subdomain: string) => {
try {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/subdomain`, {
await PyroFetch(`servers/${internalServerReference.value.serverId}/subdomain`, {
method: "POST",
body: { subdomain },
});
@@ -837,7 +872,7 @@ const updateStartupSettings = async (
jdkBuild: "corretto" | "temurin" | "graal",
) => {
try {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/startup`, {
await PyroFetch(`servers/${internalServerReference.value.serverId}/startup`, {
method: "POST",
body: {
invocation: invocation || null,
@@ -858,7 +893,7 @@ const retryWithAuth = async (requestFn: () => Promise<any>) => {
return await requestFn();
} catch (error) {
if (error instanceof PyroServersFetchError && error.statusCode === 401) {
await internalServerRefrence.value.refresh(["fs"]);
await internalServerReference.value.refresh(["fs"]);
return await requestFn();
}
@@ -870,7 +905,7 @@ const listDirContents = (path: string, page: number, pageSize: number) => {
return retryWithAuth(async () => {
const encodedPath = encodeURIComponent(path);
return await PyroFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, {
override: internalServerRefrence.value.fs.auth,
override: internalServerReference.value.fs.auth,
retry: false,
});
});
@@ -882,7 +917,7 @@ const createFileOrFolder = (path: string, type: "file" | "directory") => {
return await PyroFetch(`/create?path=${encodedPath}&type=${type}`, {
method: "POST",
contentType: "application/octet-stream",
override: internalServerRefrence.value.fs.auth,
override: internalServerReference.value.fs.auth,
});
});
};
@@ -925,9 +960,12 @@ const uploadFile = (path: string, file: File) => {
xhr.open(
"POST",
`https://${internalServerRefrence.value.fs.auth.url}/create?path=${encodedPath}&type=file`,
`https://${internalServerReference.value.fs.auth.url}/create?path=${encodedPath}&type=file`,
);
xhr.setRequestHeader(
"Authorization",
`Bearer ${internalServerReference.value.fs.auth.token}`,
);
xhr.setRequestHeader("Authorization", `Bearer ${internalServerRefrence.value.fs.auth.token}`);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.send(file);
@@ -957,7 +995,7 @@ const renameFileOrFolder = (path: string, name: string) => {
return retryWithAuth(async () => {
await PyroFetch(`/move`, {
method: "POST",
override: internalServerRefrence.value.fs.auth,
override: internalServerReference.value.fs.auth,
body: {
source: path,
destination: pathName,
@@ -974,7 +1012,7 @@ const updateFile = (path: string, content: string) => {
method: "PUT",
contentType: "application/octet-stream",
body: octetStream,
override: internalServerRefrence.value.fs.auth,
override: internalServerReference.value.fs.auth,
});
});
};
@@ -1004,7 +1042,7 @@ const moveFileOrFolder = (path: string, newPath: string) => {
return await PyroFetch(`/move`, {
method: "POST",
override: internalServerRefrence.value.fs.auth,
override: internalServerReference.value.fs.auth,
body: {
source: path,
destination: newPath,
@@ -1018,7 +1056,7 @@ const deleteFileOrFolder = (path: string, recursive: boolean) => {
return retryWithAuth(async () => {
return await PyroFetch(`/delete?path=${encodedPath}&recursive=${recursive}`, {
method: "DELETE",
override: internalServerRefrence.value.fs.auth,
override: internalServerReference.value.fs.auth,
});
});
};
@@ -1027,7 +1065,7 @@ const downloadFile = (path: string, raw?: boolean) => {
return retryWithAuth(async () => {
const encodedPath = encodeURIComponent(path);
const fileData = await PyroFetch(`/download?path=${encodedPath}`, {
override: internalServerRefrence.value.fs.auth,
override: internalServerReference.value.fs.auth,
});
if (fileData instanceof Blob) {
@@ -1139,11 +1177,12 @@ const modules: any = {
rename: renameBackup,
delete: deleteBackup,
restore: restoreBackup,
download: downloadBackup,
prepare: prepareBackup,
updateAutoBackup,
getAutoBackup,
lock: lockBackup,
unlock: unlockBackup,
retry: retryBackup,
},
network: {
get: async (serverId: string) => {
@@ -1384,6 +1423,12 @@ type BackupFunctions = {
*/
download: (backupId: string) => Promise<void>;
/**
* Prepare a backup for the server.
* @param backupId - The ID of the backup.
*/
prepare: (backupId: string) => Promise<void>;
/**
* Updates the auto backup settings of the server.
* @param autoBackup - Whether to enable auto backup.
@@ -1407,6 +1452,12 @@ type BackupFunctions = {
* @param backupId - The ID of the backup.
*/
unlock: (backupId: string) => Promise<void>;
/**
* Retries a failed backup for the server.
* @param backupId - The ID of the backup.
*/
retry: (backupId: string) => Promise<void>;
};
type NetworkFunctions = {
@@ -1704,7 +1755,7 @@ export const usePyroServer = async (serverId: string, includedModules: avaliable
server[module] = modules[module];
});
internalServerRefrence.value = server;
internalServerReference.value = server;
await server.refresh(initialModules);
if (deferredModules.length > 0) {

View File

@@ -959,6 +959,57 @@
"search.filter.locked.server.sync": {
"message": "Sync with server"
},
"servers.backup.create.in-progress.tooltip": {
"message": "Backup creation in progress"
},
"servers.backup.restore.in-progress.tooltip": {
"message": "Backup restore in progress"
},
"servers.backups.item.already-preparing": {
"message": "Already preparing backup for download"
},
"servers.backups.item.automated": {
"message": "Automated"
},
"servers.backups.item.creating-backup": {
"message": "Creating backup..."
},
"servers.backups.item.failed-to-create-backup": {
"message": "Failed to create backup"
},
"servers.backups.item.failed-to-restore-backup": {
"message": "Failed to restore from backup"
},
"servers.backups.item.lock": {
"message": "Lock"
},
"servers.backups.item.locked": {
"message": "Locked"
},
"servers.backups.item.prepare-download": {
"message": "Prepare download"
},
"servers.backups.item.preparing-download": {
"message": "Preparing download..."
},
"servers.backups.item.queued-for-backup": {
"message": "Queued for backup"
},
"servers.backups.item.rename": {
"message": "Rename"
},
"servers.backups.item.restore": {
"message": "Restore"
},
"servers.backups.item.restoring-backup": {
"message": "Restoring from backup..."
},
"servers.backups.item.retry": {
"message": "Retry"
},
"servers.backups.item.unlock": {
"message": "Unlock"
},
"servers.notice.actions": {
"message": "Actions"
},

View File

@@ -1,401 +1,391 @@
<template>
<div class="contents">
<div
v-if="filteredNotices.length > 0"
class="experimental-styles-within relative mx-auto flex w-full min-w-0 max-w-[1280px] flex-col gap-3 px-6"
>
<ServerNotice
v-for="notice in filteredNotices"
:key="`notice-${notice.id}`"
:level="notice.level"
:message="notice.message"
:dismissable="notice.dismissable"
:title="notice.title"
class="w-full"
@dismiss="() => dismissNotice(notice.id)"
/>
</div>
<div
v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'upgrading'"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
<TransferIcon class="size-12 text-blue" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server upgrading</h1>
<div
v-if="filteredNotices.length > 0"
class="experimental-styles-within relative mx-auto flex w-full min-w-0 max-w-[1280px] flex-col gap-3 px-6"
>
<ServerNotice
v-for="notice in filteredNotices"
:key="`notice-${notice.id}`"
:level="notice.level"
:message="notice.message"
:dismissable="notice.dismissable"
:title="notice.title"
class="w-full"
@dismiss="() => dismissNotice(notice.id)"
/>
</div>
<div
v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'upgrading'"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
<TransferIcon class="size-12 text-blue" />
</div>
<p class="text-lg text-secondary">
Your server's hardware is currently being upgraded and will be back online shortly!
</p>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server upgrading</h1>
</div>
<p class="text-lg text-secondary">
Your server's hardware is currently being upgraded and will be back online shortly!
</p>
</div>
</div>
<div
v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'support'"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
<TransferIcon class="size-12 text-blue" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">We're working on your server</h1>
</div>
<div
v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'support'"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
<TransferIcon class="size-12 text-blue" />
</div>
<p class="text-lg text-secondary">
You recently contacted Modrinth Support, and we're actively working on your server. It
will be back online shortly.
</p>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">We're working on your server</h1>
</div>
<p class="text-lg text-secondary">
You recently contacted Modrinth Support, and we're actively working on your server. It
will be back online shortly.
</p>
</div>
</div>
<div
v-else-if="serverData?.status === 'suspended' && serverData.suspension_reason !== 'upgrading'"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<LockIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server suspended</h1>
</div>
<div
v-else-if="serverData?.status === 'suspended' && serverData.suspension_reason !== 'upgrading'"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<LockIcon class="size-12 text-orange" />
</div>
<p class="text-lg text-secondary">
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server suspended</h1>
</div>
<p class="text-lg text-secondary">
{{
serverData.suspension_reason === "cancelled"
? "Your subscription has been cancelled."
: serverData.suspension_reason
? `Your server has been suspended: ${serverData.suspension_reason}`
: "Your server has been suspended."
}}
<br />
Contact Modrinth support if you believe this is an error.
</p>
</div>
<ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')">
<button class="mt-6 !w-full">Go to billing settings</button>
</ButtonStyled>
</div>
</div>
<div
v-else-if="
server.general?.error?.error.statusCode === 403 ||
server.general?.error?.error.statusCode === 404
"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<TransferIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server not found</h1>
</div>
<p class="text-lg text-secondary">
You don't have permission to view this server or it no longer exists. If you believe this
is an error, please contact Modrinth support.
</p>
</div>
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
<ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')">
<button class="mt-6 !w-full">Go back to all servers</button>
</ButtonStyled>
</div>
</div>
<div
v-else-if="server.general?.error?.error.statusCode === 503"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-red p-4">
<UiServersIconsPanelErrorIcon class="size-12 text-red" />
</div>
<h1 class="m-0 mb-4 w-fit text-4xl font-bold">Server Node Unavailable</h1>
</div>
<p class="m-0 mb-4 leading-[170%] text-secondary">
Your server's node, where your Modrinth Server is physically hosted, is experiencing
issues. We are working with our datacenter to resolve the issue as quickly as possible.
</p>
<p class="m-0 mb-4 leading-[170%] text-secondary">
Your data is safe and will not be lost, and your server will be back online as soon as the
issue is resolved.
</p>
<p class="m-0 mb-4 leading-[170%] text-secondary">
For updates, please join the Modrinth Discord or contact Modrinth Support via the chat
bubble in the bottom right corner and we'll be happy to help.
</p>
<div class="flex flex-col gap-2">
<UiCopyCode :text="'Server ID: ' + server.serverId" />
<UiCopyCode :text="'Node: ' + server.general?.datacenter" />
</div>
</div>
<ButtonStyled
size="large"
color="standard"
@click="
() =>
navigateTo('https://discord.modrinth.com', {
external: true,
})
"
>
<button class="mt-6 !w-full">Join Modrinth Discord</button>
</ButtonStyled>
<ButtonStyled
:disabled="formattedTime !== '00'"
size="large"
color="standard"
@click="() => reloadNuxtApp()"
>
<button class="mt-3 !w-full">Reload</button>
</ButtonStyled>
</div>
</div>
<div
v-else-if="server.general?.error"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<TransferIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Connection lost</h1>
<div class="text-center text-secondary">
{{
serverData.suspension_reason === "cancelled"
? "Your subscription has been cancelled."
: serverData.suspension_reason
? `Your server has been suspended: ${serverData.suspension_reason}`
: "Your server has been suspended."
formattedTime == "00" ? "Reconnecting..." : `Retrying in ${formattedTime} seconds...`
}}
<br />
Contact Modrinth support if you believe this is an error.
</p>
</div>
<ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')">
<button class="mt-6 !w-full">Go to billing settings</button>
</ButtonStyled>
</div>
</div>
<div
v-else-if="
server.general?.error?.error.statusCode === 403 ||
server.general?.error?.error.statusCode === 404
"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<TransferIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server not found</h1>
</div>
<p class="text-lg text-secondary">
You don't have permission to view this server or it no longer exists. If you believe
this is an error, please contact Modrinth support.
</p>
</div>
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
<p class="text-lg text-secondary">
Something went wrong, and we couldn't connect to your server. This is likely due to a
temporary network issue. You'll be reconnected automatically.
</p>
</div>
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
<ButtonStyled
:disabled="formattedTime !== '00'"
size="large"
color="brand"
@click="() => reloadNuxtApp()"
>
<button class="mt-6 !w-full">Reload</button>
</ButtonStyled>
</div>
</div>
<!-- SERVER START -->
<div
v-else-if="serverData"
data-pyro-server-manager-root
class="experimental-styles-within mobile-blurred-servericon relative mx-auto mb-6 box-border flex min-h-screen w-full min-w-0 max-w-[1280px] flex-col gap-6 px-6 transition-all duration-300"
:style="{
'--server-bg-image': serverData.image
? `url(${serverData.image})`
: `linear-gradient(180deg, rgba(153,153,153,1) 0%, rgba(87,87,87,1) 100%)`,
}"
>
<div class="flex w-full min-w-0 select-none flex-col items-center gap-6 pt-4 sm:flex-row">
<UiServersServerIcon :image="serverData.image" class="drop-shadow-lg sm:drop-shadow-none" />
<div
class="flex min-w-0 flex-1 flex-col-reverse items-center gap-2 sm:flex-col sm:items-start"
>
<div class="hidden shrink-0 flex-row items-center gap-1 sm:flex">
<NuxtLink to="/servers/manage" class="breadcrumb goto-link flex w-fit items-center">
<LeftArrowIcon />
All servers
</NuxtLink>
</div>
<div class="flex w-full flex-col items-center gap-4 sm:flex-row">
<h1
class="m-0 w-screen flex-shrink gap-3 truncate px-3 text-center text-4xl font-bold text-contrast sm:w-full sm:p-0 sm:text-left"
>
{{ serverData.name }}
</h1>
<div
v-if="isConnected"
data-pyro-server-action-buttons
class="server-action-buttons-anim flex w-fit flex-shrink-0"
>
<UiServersPanelServerActionButton
class="flex-shrink-0"
:is-online="isServerRunning"
:is-actioning="isActioning"
:is-installing="serverData.status === 'installing'"
:disabled="isActioning || !!error"
:server-name="serverData.name"
:server-data="serverData"
:uptime-seconds="uptimeSeconds"
@action="sendPowerAction"
/>
</div>
</div>
<ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')">
<button class="mt-6 !w-full">Go back to all servers</button>
</ButtonStyled>
<UiServersServerInfoLabels
:server-data="serverData"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
:uptime-seconds="uptimeSeconds"
:linked="true"
class="server-action-buttons-anim flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
/>
</div>
</div>
<div
v-else-if="server.general?.error?.error.statusCode === 503"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-red p-4">
<UiServersIconsPanelErrorIcon class="size-12 text-red" />
</div>
<h1 class="m-0 mb-4 w-fit text-4xl font-bold">Server Node Unavailable</h1>
</div>
<p class="m-0 mb-4 leading-[170%] text-secondary">
Your server's node, where your Modrinth Server is physically hosted, is experiencing
issues. We are working with our datacenter to resolve the issue as quickly as possible.
</p>
<p class="m-0 mb-4 leading-[170%] text-secondary">
Your data is safe and will not be lost, and your server will be back online as soon as
the issue is resolved.
</p>
<p class="m-0 mb-4 leading-[170%] text-secondary">
For updates, please join the Modrinth Discord or contact Modrinth Support via the chat
bubble in the bottom right corner and we'll be happy to help.
</p>
<div class="flex flex-col gap-2">
<UiCopyCode :text="'Server ID: ' + server.serverId" />
<UiCopyCode :text="'Node: ' + server.general?.datacenter" />
</div>
</div>
<ButtonStyled
size="large"
color="standard"
@click="
() =>
navigateTo('https://discord.modrinth.com', {
external: true,
})
"
>
<button class="mt-6 !w-full">Join Modrinth Discord</button>
</ButtonStyled>
<ButtonStyled
:disabled="formattedTime !== '00'"
size="large"
color="standard"
@click="() => reloadNuxtApp()"
>
<button class="mt-3 !w-full">Reload</button>
</ButtonStyled>
</div>
</div>
<div
v-else-if="server.general?.error"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
data-pyro-navigation
class="isolate flex w-full select-none flex-col justify-between gap-4 overflow-auto md:flex-row md:items-center"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<TransferIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Connection lost</h1>
<div class="text-center text-secondary">
{{
formattedTime == "00"
? "Reconnecting..."
: `Retrying in ${formattedTime} seconds...`
}}
</div>
</div>
<p class="text-lg text-secondary">
Something went wrong, and we couldn't connect to your server. This is likely due to a
temporary network issue. You'll be reconnected automatically.
</p>
</div>
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
<ButtonStyled
:disabled="formattedTime !== '00'"
size="large"
color="brand"
@click="() => reloadNuxtApp()"
>
<button class="mt-6 !w-full">Reload</button>
</ButtonStyled>
</div>
<UiNavTabs :links="navLinks" />
</div>
<!-- SERVER START -->
<div
v-else-if="serverData"
data-pyro-server-manager-root
class="experimental-styles-within mobile-blurred-servericon relative mx-auto box-border flex min-h-screen w-full min-w-0 max-w-[1280px] flex-col gap-6 px-6 transition-all duration-300"
:style="{
'--server-bg-image': serverData.image
? `url(${serverData.image})`
: `linear-gradient(180deg, rgba(153,153,153,1) 0%, rgba(87,87,87,1) 100%)`,
}"
>
<div class="flex w-full min-w-0 select-none flex-col items-center gap-6 pt-4 sm:flex-row">
<UiServersServerIcon :image="serverData.image" class="drop-shadow-lg sm:drop-shadow-none" />
<div
class="flex min-w-0 flex-1 flex-col-reverse items-center gap-2 sm:flex-col sm:items-start"
>
<div class="hidden shrink-0 flex-row items-center gap-1 sm:flex">
<NuxtLink to="/servers/manage" class="breadcrumb goto-link flex w-fit items-center">
<LeftArrowIcon />
All servers
</NuxtLink>
</div>
<div class="flex w-full flex-col items-center gap-4 sm:flex-row">
<h1
class="m-0 w-screen flex-shrink gap-3 truncate px-3 text-center text-4xl font-bold text-contrast sm:w-full sm:p-0 sm:text-left"
>
{{ serverData.name }}
</h1>
<div
v-if="isConnected"
data-pyro-server-action-buttons
class="server-action-buttons-anim flex w-fit flex-shrink-0"
>
<UiServersPanelServerActionButton
class="flex-shrink-0"
:is-online="isServerRunning"
:is-actioning="isActioning"
:is-installing="serverData.status === 'installing'"
:disabled="isActioning || !!error"
:server-name="serverData.name"
:server-data="serverData"
:uptime-seconds="uptimeSeconds"
@action="sendPowerAction"
/>
</div>
</div>
<UiServersServerInfoLabels
:server-data="serverData"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
:uptime-seconds="uptimeSeconds"
:linked="true"
class="server-action-buttons-anim flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
/>
<div data-pyro-mount class="h-full w-full flex-1">
<div
v-if="error"
class="mx-auto mb-4 flex justify-between gap-2 rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
>
<div class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 shrink-0 text-red sm:block" />
<div class="flex flex-col gap-2 leading-[150%]">
<div class="flex items-center gap-3">
<IssuesIcon class="flex h-8 w-8 shrink-0 text-red sm:hidden" />
<div class="flex gap-2 text-2xl font-bold">{{ errorTitle }}</div>
</div>
<div v-if="errorTitle.toLocaleLowerCase() === 'installation error'" class="font-normal">
<div
v-if="errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'"
>
An invalid loader or Minecraft version was specified and could not be installed.
<ul class="m-0 mt-4 p-0 pl-4">
<li>
If this version of Minecraft was released recently, please check if Modrinth
Servers supports it.
</li>
<li>
If you've installed a modpack, it may have been packaged incorrectly or may not
be compatible with the loader.
</li>
<li>
Your server may need to be reinstalled with a valid mod loader and version. You
can change the loader by clicking the "Change Loader" button.
</li>
<li>
If you're stuck, please contact Modrinth support with the information below:
</li>
</ul>
<ButtonStyled>
<button class="mt-2" @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</button>
</ButtonStyled>
</div>
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
An internal error occurred while installing your server. Don't fret try
reinstalling your server, and if the problem persists, please contact Modrinth
support with your server's debug information.
</div>
<div v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'">
An error occurred while installing your server because Modrinth Servers does not
support the version of Minecraft or the loader you specified. Try reinstalling your
server with a different version or loader, and if the problem persists, please
contact Modrinth support with your server's debug information.
</div>
<div
v-if="errorTitle === 'Installation error'"
class="mt-2 flex flex-col gap-4 sm:flex-row"
>
<ButtonStyled v-if="errorLog">
<button @click="openInstallLog"><FileIcon />Open Installation Log</button>
</ButtonStyled>
<ButtonStyled>
<button @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</button>
</ButtonStyled>
<ButtonStyled color="red" type="standard">
<NuxtLink
class="whitespace-pre"
:to="`/servers/manage/${serverId}/options/loader`"
>
<RightArrowIcon />
Change Loader
</NuxtLink>
</ButtonStyled>
</div>
</div>
</div>
</div>
</div>
<div
data-pyro-navigation
class="isolate flex w-full select-none flex-col justify-between gap-4 overflow-auto md:flex-row md:items-center"
v-if="!isConnected && !isReconnecting && !isLoading"
data-pyro-server-ws-error
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-red p-4 text-contrast"
>
<UiNavTabs :links="navLinks" />
<IssuesIcon class="size-5 text-red" />
Something went wrong...
</div>
<div data-pyro-mount class="h-full w-full flex-1">
<div
v-if="error"
class="mx-auto mb-4 flex justify-between gap-2 rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
>
<div class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 shrink-0 text-red sm:block" />
<div class="flex flex-col gap-2 leading-[150%]">
<div class="flex items-center gap-3">
<IssuesIcon class="flex h-8 w-8 shrink-0 text-red sm:hidden" />
<div class="flex gap-2 text-2xl font-bold">{{ errorTitle }}</div>
</div>
<div
v-if="errorTitle.toLocaleLowerCase() === 'installation error'"
class="font-normal"
>
<div
v-if="
errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'
"
>
An invalid loader or Minecraft version was specified and could not be installed.
<ul class="m-0 mt-4 p-0 pl-4">
<li>
If this version of Minecraft was released recently, please check if Modrinth
Servers supports it.
</li>
<li>
If you've installed a modpack, it may have been packaged incorrectly or may
not be compatible with the loader.
</li>
<li>
Your server may need to be reinstalled with a valid mod loader and version.
You can change the loader by clicking the "Change Loader" button.
</li>
<li>
If you're stuck, please contact Modrinth support with the information below:
</li>
</ul>
<ButtonStyled>
<button class="mt-2" @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</button>
</ButtonStyled>
</div>
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
An internal error occurred while installing your server. Don't fret — try
reinstalling your server, and if the problem persists, please contact Modrinth
support with your server's debug information.
</div>
<div
v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'"
>
An error occurred while installing your server because Modrinth Servers does not
support the version of Minecraft or the loader you specified. Try reinstalling
your server with a different version or loader, and if the problem persists,
please contact Modrinth support with your server's debug information.
</div>
<div
v-if="errorTitle === 'Installation error'"
class="mt-2 flex flex-col gap-4 sm:flex-row"
>
<ButtonStyled v-if="errorLog">
<button @click="openInstallLog"><FileIcon />Open Installation Log</button>
</ButtonStyled>
<ButtonStyled>
<button @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</button>
</ButtonStyled>
<ButtonStyled color="red" type="standard">
<NuxtLink
class="whitespace-pre"
:to="`/servers/manage/${serverId}/options/loader`"
>
<RightArrowIcon />
Change Loader
</NuxtLink>
</ButtonStyled>
</div>
</div>
</div>
</div>
</div>
<div
v-if="!isConnected && !isReconnecting && !isLoading"
data-pyro-server-ws-error
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-red p-4 text-contrast"
>
<IssuesIcon class="size-5 text-red" />
Something went wrong...
</div>
<div
v-if="isReconnecting"
data-pyro-server-ws-reconnecting
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
>
<UiServersPanelSpinner />
Hang on, we're reconnecting to your server.
</div>
<div
v-if="serverData.status === 'installing'"
data-pyro-server-installing
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
>
<UiServersServerIcon :image="serverData.image" class="!h-10 !w-10" />
<div class="flex flex-col gap-1">
<span class="text-lg font-bold"> We're preparing your server! </span>
<div class="flex flex-row items-center gap-2">
<UiServersPanelSpinner class="!h-3 !w-3" /> <LazyUiServersInstallingTicker />
</div>
</div>
</div>
<NuxtPage
:route="route"
:is-connected="isConnected"
:is-ws-auth-incorrect="isWSAuthIncorrect"
:is-server-running="isServerRunning"
:stats="stats"
:server-power-state="serverPowerState"
:power-state-details="powerStateDetails"
:socket="socket"
:server="server"
@reinstall="onReinstall"
/>
<div
v-if="isReconnecting"
data-pyro-server-ws-reconnecting
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
>
<UiServersPanelSpinner />
Hang on, we're reconnecting to your server.
</div>
<div
v-if="serverData.status === 'installing'"
data-pyro-server-installing
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
>
<UiServersServerIcon :image="serverData.image" class="!h-10 !w-10" />
<div class="flex flex-col gap-1">
<span class="text-lg font-bold"> We're preparing your server! </span>
<div class="flex flex-row items-center gap-2">
<UiServersPanelSpinner class="!h-3 !w-3" /> <LazyUiServersInstallingTicker />
</div>
</div>
</div>
<NuxtPage
:route="route"
:is-connected="isConnected"
:is-ws-auth-incorrect="isWSAuthIncorrect"
:is-server-running="isServerRunning"
:stats="stats"
:server-power-state="serverPowerState"
:power-state-details="powerStateDetails"
:socket="socket"
:server="server"
:backup-in-progress="backupInProgress"
@reinstall="onReinstall"
/>
</div>
</div>
</template>
@@ -416,8 +406,10 @@ import DOMPurify from "dompurify";
import { ButtonStyled, ServerNotice } from "@modrinth/ui";
import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
import { reloadNuxtApp, navigateTo } from "#app";
import type { MessageDescriptor } from "@vintl/vintl";
import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers";
import { usePyroConsole } from "~/store/console.ts";
import { type Backup } from "~/composables/pyroServers.ts";
import { usePyroFetch } from "~/composables/pyroFetch.ts";
const app = useNuxtApp() as unknown as { $notify: any };
@@ -770,6 +762,31 @@ const handleWebSocketMessage = (data: WSEvent) => {
uptimeSeconds.value = data.uptime;
startUptimeUpdates();
break;
case "backup-progress": {
// Update a backup's state
const curBackup = server.backups?.data.find((backup) => backup.id === data.id);
if (!curBackup) {
console.log(`Ignoring backup-progress event for unknown backup: ${data.id}`);
} else {
console.log(
`Handling backup progress for ${curBackup.name} (${data.id}) task: ${data.task} state: ${data.state} progress: ${data.progress}`,
);
if (!curBackup.task) {
curBackup.task = {};
}
curBackup.task[data.task] = {
progress: data.progress,
state: data.state,
};
curBackup.ongoing = data.task === "create" && data.state === "ongoing";
}
break;
}
default:
console.warn("Unhandled WebSocket event:", data);
}
@@ -968,6 +985,41 @@ const formattedTime = computed(() => {
return `${seconds.toString().padStart(2, "0")}`;
});
export type BackupInProgressReason = {
type: string;
tooltip: MessageDescriptor;
};
const RestoreInProgressReason = {
type: "restore",
tooltip: defineMessage({
id: "servers.backup.restore.in-progress.tooltip",
defaultMessage: "Backup restore in progress",
}),
} satisfies BackupInProgressReason;
const CreateInProgressReason = {
type: "create",
tooltip: defineMessage({
id: "servers.backup.create.in-progress.tooltip",
defaultMessage: "Backup creation in progress",
}),
} satisfies BackupInProgressReason;
const backupInProgress = computed(() => {
const backups = server.backups?.data;
if (!backups) {
return undefined;
}
if (backups.find((backup: Backup) => backup?.task?.create?.state === "ongoing")) {
return CreateInProgressReason;
}
if (backups.find((backup: Backup) => backup?.task?.restore?.state === "ongoing")) {
return RestoreInProgressReason;
}
return undefined;
});
const stopPolling = () => {
if (intervalId) {
clearInterval(intervalId);

View File

@@ -1,248 +1,147 @@
<template>
<div class="contents">
<div
v-if="server.backups?.error"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load backups</h1>
<div
v-if="server.backups?.error"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<p class="text-lg text-secondary">
We couldn't load your server's backups. Here's what went wrong:
</p>
<p>
<span class="break-all font-mono">{{ JSON.stringify(server.backups.error) }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['backups'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load backups</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's backups. Here's what went wrong:
</p>
<p>
<span class="break-all font-mono">{{ JSON.stringify(server.backups.error) }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['backups'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
<div v-else-if="data" class="contents">
<LazyUiServersBackupCreateModal
ref="createBackupModal"
:server="server"
@backup-created="handleBackupCreated"
/>
<LazyUiServersBackupRenameModal
ref="renameBackupModal"
:server="server"
:current-backup-id="currentBackup"
:backup-name="renameBackupName"
@backup-renamed="handleBackupRenamed"
/>
<LazyUiServersBackupRestoreModal
ref="restoreBackupModal"
:server="server"
:backup-id="currentBackup"
:backup-name="currentBackupDetails?.name ?? ''"
:backup-created-at="currentBackupDetails?.created_at ?? ''"
@backup-restored="handleBackupRestored"
/>
<LazyUiServersBackupDeleteModal
ref="deleteBackupModal"
:server="server"
:backup-id="currentBackup"
:backup-name="currentBackupDetails?.name ?? ''"
:backup-created-at="currentBackupDetails?.created_at ?? ''"
@backup-deleted="handleBackupDeleted"
/>
</div>
<div v-else-if="data" class="contents">
<BackupCreateModal ref="createBackupModal" :server="server" />
<BackupRenameModal ref="renameBackupModal" :server="server" />
<BackupRestoreModal ref="restoreBackupModal" :server="server" />
<BackupDeleteModal ref="deleteBackupModal" :server="server" @delete="deleteBackup" />
<BackupSettingsModal ref="backupSettingsModal" :server="server" />
<LazyUiServersBackupSettingsModal ref="backupSettingsModal" :server="server" />
<ul class="m-0 flex list-none flex-col gap-4 p-0">
<div class="relative w-full overflow-hidden rounded-2xl bg-bg-raised p-6 shadow-md">
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row sm:gap-0">
<div class="flex flex-col items-baseline gap-2">
<div class="text-2xl font-bold text-contrast">
{{
data.used_backup_quota === 0
? "No backups"
: `You've created ${data.used_backup_quota} backup${data.used_backup_quota === 1 ? "" : "s"}`
}}
</div>
<div>
{{
data.backup_quota - data.used_backup_quota === 0
? "You have reached your backup limit. Consider removing old backups to create new ones."
: `You can create ${data.backup_quota - data.used_backup_quota} more backups for your server.`
}}
</div>
</div>
<div class="flex w-full flex-col gap-2 sm:w-fit sm:flex-row">
<ButtonStyled type="standard">
<button
:disabled="server.general?.status === 'installing'"
@click="showbackupSettingsModal"
>
<SettingsIcon class="h-5 w-5" />
Auto backups
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="brand">
<button
v-tooltip="
isServerRunning && !userPreferences.backupWhileRunning
? 'Cannot create backup while server is running. You can disable this from your server Options > Preferences.'
: server.general?.status === 'installing'
? 'Cannot create backups while server is being installed'
: ''
"
class="w-full sm:w-fit"
:disabled="
(isServerRunning && !userPreferences.backupWhileRunning) ||
data.used_backup_quota >= data.backup_quota ||
backups.some((backup) => backup.ongoing) ||
server.general?.status === 'installing'
"
@click="showCreateModel"
>
<PlusIcon class="h-5 w-5" />
Create backup
</button>
</ButtonStyled>
</div>
</div>
</div>
<div
v-if="backups.some((backup) => backup.ongoing)"
data-pyro-server-backup-ongoing
class="flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-contrast"
>
A backup is currently being created. This may take a few minutes. This page will
automatically refresh when the backup is complete.
</div>
<div class="flex w-full flex-col gap-2">
<li
v-for="(backup, index) in backups"
:key="backup.id"
class="relative m-0 w-full list-none rounded-2xl bg-bg-raised p-2 shadow-md"
<div class="mb-6 flex flex-col items-center justify-between gap-4 sm:flex-row">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<h1 class="m-0 text-2xl font-extrabold text-contrast">Backups</h1>
<TagItem
v-tooltip="`${data.backup_quota - data.used_backup_quota} backup slots remaining`"
class="cursor-help"
:style="{
'--_color':
data.backup_quota <= data.used_backup_quota
? 'var(--color-red)'
: data.backup_quota - data.used_backup_quota <= 3
? 'var(--color-orange)'
: undefined,
'--_bg-color':
data.backup_quota <= data.used_backup_quota
? 'var(--color-red-bg)'
: data.backup_quota - data.used_backup_quota <= 3
? 'var(--color-orange-bg)'
: undefined,
}"
>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div class="flex min-w-0 flex-row items-center gap-4">
<div
class="grid size-14 shrink-0 place-content-center overflow-hidden rounded-xl border-[1px] border-solid border-button-border shadow-sm"
:class="backup.ongoing ? 'text-green [&&]:bg-bg-green' : 'bg-button-bg'"
>
<UiServersIconsLoadingIcon
v-if="backup.ongoing"
v-tooltip="'Backup in progress'"
class="size-6 animate-spin"
/>
<LockIcon v-else-if="backup.locked" class="size-8" />
<BoxIcon v-else class="size-8" />
</div>
<div class="flex min-w-0 flex-col gap-2">
<div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
<div class="max-w-full truncate font-bold text-contrast">
{{ backup.name }}
</div>
<div
v-if="index == 0"
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
>
<CheckIcon class="size-4" /> Latest
</div>
</div>
<div class="flex items-center gap-1 text-xs">
<CalendarIcon class="size-4" />
{{
new Date(backup.created_at).toLocaleString("en-US", {
month: "numeric",
day: "numeric",
year: "2-digit",
hour: "numeric",
minute: "numeric",
hour12: true,
})
}}
</div>
</div>
</div>
<ButtonStyled v-if="!backup.ongoing" circular type="transparent">
<UiServersTeleportOverflowMenu
direction="left"
position="bottom"
class="bg-transparent"
:disabled="backups.some((b) => b.ongoing)"
:options="[
{
id: 'rename',
action: () => {
renameBackupName = backup.name;
currentBackup = backup.id;
renameBackupModal?.show();
},
},
{
id: 'restore',
action: () => {
currentBackup = backup.id;
restoreBackupModal?.show();
},
},
{ id: 'download', action: () => initiateDownload(backup.id) },
{
id: 'lock',
action: () => {
if (backup.locked) {
unlockBackup(backup.id);
} else {
lockBackup(backup.id);
}
},
},
{
id: 'delete',
action: () => {
currentBackup = backup.id;
deleteBackupModal?.show();
},
color: 'red',
},
]"
>
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
<template #rename> <EditIcon /> Rename </template>
<template #restore> <ClipboardCopyIcon /> Restore </template>
<template v-if="backup.locked" #lock> <LockOpenIcon /> Unlock </template>
<template v-else #lock> <LockIcon /> Lock </template>
<template #download> <DownloadIcon /> Download </template>
<template #delete> <TrashIcon /> Delete </template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div>
</div>
</li>
{{ data.used_backup_quota }} / {{ data.backup_quota }}
</TagItem>
</div>
</ul>
<p class="m-0">
You can have up to {{ data.backup_quota }} backups at once, securely off-site with
Backblaze.
</p>
</div>
<div
class="over-the-top-download-animation"
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
class="grid w-full grid-cols-[repeat(auto-fit,_minmax(180px,1fr))] gap-2 sm:flex sm:w-fit sm:flex-row"
>
<div>
<div
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
></div>
<div
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
></div>
<div
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
<ButtonStyled type="standard">
<button
v-tooltip="
'Auto backups are currently unavailable; we apologize for the inconvenience.'
"
:disabled="true || server.general?.status === 'installing'"
@click="showbackupSettingsModal"
>
<DownloadIcon class="h-20 w-20 text-contrast" />
</div>
<SettingsIcon class="h-5 w-5" />
Auto backups
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="brand">
<button
v-tooltip="backupCreationDisabled"
class="w-full sm:w-fit"
:disabled="!!backupCreationDisabled"
@click="showCreateModel"
>
<PlusIcon class="h-5 w-5" />
Create backup
</button>
</ButtonStyled>
</div>
</div>
<div class="flex w-full flex-col gap-2">
<div
v-if="backups.length === 0"
class="mt-6 flex items-center justify-center gap-2 text-center text-secondary"
>
<template v-if="data.used_backup_quota">
<SpinnerIcon class="animate-spin" />
Loading backups...
</template>
<template v-else> You don't have any backups yet. </template>
</div>
<BackupItem
v-for="backup in backups"
:key="`backup-${backup.id}`"
:backup="backup"
:kyros-url="props.server.general?.node.instance"
:jwt="props.server.general?.node.token"
@prepare="() => prepareDownload(backup.id)"
@download="() => triggerDownloadAnimation()"
@rename="() => renameBackupModal?.show(backup)"
@restore="() => restoreBackupModal?.show(backup)"
@lock="
() => {
if (backup.locked) {
unlockBackup(backup.id);
} else {
lockBackup(backup.id);
}
}
"
@delete="
(skipConfirmation?: boolean) =>
!skipConfirmation ? deleteBackup(backup) : deleteBackupModal?.show(backup)
"
@retry="() => retryBackup(backup.id)"
/>
</div>
<div
class="over-the-top-download-animation"
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
>
<div>
<div
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
></div>
<div
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
></div>
<div
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
>
<DownloadIcon class="h-20 w-20 text-contrast" />
</div>
</div>
</div>
@@ -250,25 +149,17 @@
</template>
<script setup lang="ts">
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { ButtonStyled, TagItem } from "@modrinth/ui";
import { useStorage } from "@vueuse/core";
import {
PlusIcon,
CheckIcon,
CalendarIcon,
MoreHorizontalIcon,
EditIcon,
ClipboardCopyIcon,
DownloadIcon,
TrashIcon,
SettingsIcon,
BoxIcon,
LockIcon,
LockOpenIcon,
IssuesIcon,
} from "@modrinth/assets";
import { SpinnerIcon, PlusIcon, DownloadIcon, SettingsIcon, IssuesIcon } from "@modrinth/assets";
import { ref, computed } from "vue";
import type { Server } from "~/composables/pyroServers";
import BackupItem from "~/components/ui/servers/BackupItem.vue";
import BackupRenameModal from "~/components/ui/servers/BackupRenameModal.vue";
import BackupCreateModal from "~/components/ui/servers/BackupCreateModal.vue";
import BackupRestoreModal from "~/components/ui/servers/BackupRestoreModal.vue";
import BackupDeleteModal from "~/components/ui/servers/BackupDeleteModal.vue";
import BackupSettingsModal from "~/components/ui/servers/BackupSettingsModal.vue";
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
@@ -299,19 +190,30 @@ useHead({
const overTheTopDownloadAnimation = ref();
const createBackupModal = ref<typeof NewModal>();
const renameBackupModal = ref<typeof NewModal>();
const restoreBackupModal = ref<typeof NewModal>();
const deleteBackupModal = ref<typeof NewModal>();
const backupSettingsModal = ref<typeof NewModal>();
const createBackupModal = ref<InstanceType<typeof BackupCreateModal>>();
const renameBackupModal = ref<InstanceType<typeof BackupRenameModal>>();
const restoreBackupModal = ref<InstanceType<typeof BackupRestoreModal>>();
const deleteBackupModal = ref<InstanceType<typeof BackupDeleteModal>>();
const backupSettingsModal = ref<InstanceType<typeof BackupSettingsModal>>();
const renameBackupName = ref("");
const currentBackup = ref("");
const refreshInterval = ref<ReturnType<typeof setInterval>>();
const currentBackupDetails = computed(() => {
return backups.value.find((backup) => backup.id === currentBackup.value);
const backupCreationDisabled = computed(() => {
if (props.isServerRunning && !userPreferences.value.backupWhileRunning) {
return "Cannot create backup while server is running";
}
if (
data.value?.used_backup_quota !== undefined &&
data.value?.backup_quota !== undefined &&
data.value?.used_backup_quota >= data.value?.backup_quota
) {
return `All ${data.value.backup_quota} of your backup slots are in use`;
}
if (backups.value.some((backup) => backup.task?.create?.state === "ongoing")) {
return "A backup is already in progress";
}
if (props.server.general?.status === "installing") {
return "Cannot create backup while server is installing";
}
return undefined;
});
const showCreateModel = () => {
@@ -322,69 +224,17 @@ const showbackupSettingsModal = () => {
backupSettingsModal.value?.show();
};
const handleBackupCreated = async (payload: { success: boolean; message: string }) => {
if (payload.success) {
addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else {
addNotification({ type: "error", text: payload.message });
}
};
const handleBackupRenamed = async (payload: { success: boolean; message: string }) => {
if (payload.success) {
addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else {
addNotification({ type: "error", text: payload.message });
}
};
const handleBackupRestored = async (payload: { success: boolean; message: string }) => {
if (payload.success) {
addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else {
addNotification({ type: "error", text: payload.message });
}
};
const handleBackupDeleted = async (payload: { success: boolean; message: string }) => {
if (payload.success) {
addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else {
addNotification({ type: "error", text: payload.message });
}
};
function triggerDownloadAnimation() {
overTheTopDownloadAnimation.value = true;
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500);
}
const initiateDownload = async (backupId: string) => {
triggerDownloadAnimation();
const prepareDownload = async (backupId: string) => {
try {
const downloadurl: any = await props.server.backups?.download(backupId);
if (!downloadurl || !downloadurl.download_url) {
throw new Error("Invalid download URL.");
}
let finalDownloadUrl: string = downloadurl.download_url;
if (!/^https?:\/\//i.test(finalDownloadUrl)) {
finalDownloadUrl = `https://${finalDownloadUrl.startsWith("/") ? finalDownloadUrl.substring(1) : finalDownloadUrl}`;
}
const a = document.createElement("a");
a.href = finalDownloadUrl;
a.setAttribute("download", "");
a.click();
a.remove();
await props.server.backups?.prepare(backupId);
} catch (error) {
console.error("Download failed:", error);
console.error("Failed to prepare download:", error);
addNotification({ type: "error", title: "Failed to prepare backup for download", text: error });
}
};
@@ -406,28 +256,37 @@ const unlockBackup = async (backupId: string) => {
}
};
onMounted(() => {
watchEffect(() => {
const hasOngoingBackups = backups.value.some((backup) => backup.ongoing);
if (refreshInterval.value) {
clearInterval(refreshInterval.value);
refreshInterval.value = undefined;
}
if (hasOngoingBackups) {
refreshInterval.value = setInterval(async () => {
await props.server.refresh(["backups"]);
}, 10000);
}
});
});
onUnmounted(() => {
if (refreshInterval.value) {
clearInterval(refreshInterval.value);
const retryBackup = async (backupId: string) => {
try {
await props.server.backups?.retry(backupId);
await props.server.refresh(["backups"]);
} catch (error) {
console.error("Failed to retry backup:", error);
}
});
};
async function deleteBackup(backup?: Backup) {
if (!backup) {
addNotification({
type: "error",
title: "Error deleting backup",
text: "Backup is null",
});
return;
}
try {
await props.server.backups?.delete(backup.id);
await props.server.refresh();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
addNotification({
type: "error",
title: "Error deleting backup",
text: message,
});
}
}
</script>
<style scoped>

View File

@@ -1,7 +1,11 @@
<template>
<UiServersServerSidebar :route="route" :nav-links="navLinks" :server="props.server" />
<UiServersServerSidebar
:route="route"
:nav-links="navLinks"
:server="server"
:backup-in-progress="backupInProgress"
/>
</template>
<script setup lang="ts">
import {
InfoIcon,
@@ -14,12 +18,14 @@ import {
WrenchIcon,
} from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
const route = useRoute();
const serverId = route.params.id as string;
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
backupInProgress?: BackupInProgressReason;
}>();
useHead({

View File

@@ -3,6 +3,7 @@
ref="versionSelectModal"
:server="props.server"
:current-loader="data?.loader as Loaders"
:backup-in-progress="backupInProgress"
@reinstall="emit('reinstall', $event)"
/>
@@ -93,13 +94,23 @@
</div>
<div v-else class="flex w-full flex-col items-center gap-2 sm:w-fit sm:flex-row">
<ButtonStyled>
<nuxt-link class="!w-full sm:!w-auto" :to="`/modpacks?sid=${props.server.serverId}`">
<nuxt-link
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:class="{ disabled: backupInProgress }"
class="!w-full sm:!w-auto"
:to="`/modpacks?sid=${props.server.serverId}`"
>
<CompassIcon class="size-4" /> Find a modpack
</nuxt-link>
</ButtonStyled>
<span class="hidden sm:block">or</span>
<ButtonStyled>
<button class="!w-full sm:!w-auto" @click="mrpackModal.show()">
<button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="!!backupInProgress"
class="!w-full sm:!w-auto"
@click="mrpackModal.show()"
>
<UploadIcon class="size-4" /> Upload .mrpack file
</button>
</ButtonStyled>
@@ -143,9 +154,13 @@ import { ButtonStyled, NewProjectCard } from "@modrinth/ui";
import { TransferIcon, UploadIcon, InfoIcon, CompassIcon, SettingsIcon } 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();
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
backupInProgress?: BackupInProgressReason;
}>();
const emit = defineEmits<{

View File

@@ -212,6 +212,18 @@ export interface WSNewModEvent {
event: "new-mod";
}
export type WSBackupTask = "file" | "create" | "restore";
export type WSBackupState = "ongoing" | "done" | "failed" | "cancelled" | "unchanged";
export interface WSBackupProgressEvent {
event: "backup-progress";
task: WSBackupTask;
id: string;
progress: number; // percentage
state: WSBackupState;
ready: boolean;
}
export type WSEvent =
| WSLogEvent
| WSStatsEvent
@@ -221,7 +233,8 @@ export type WSEvent =
| WSInstallationResultEvent
| WSAuthOkEvent
| WSUptimeEvent
| WSNewModEvent;
| WSNewModEvent
| WSBackupProgressEvent;
export interface Servers {
servers: Server[];

View File

@@ -0,0 +1 @@
<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-bot-icon lucide-bot"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>

After

Width:  |  Height:  |  Size: 377 B

View File

@@ -0,0 +1 @@
<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-folder-archive-icon lucide-folder-archive"><circle cx="15" cy="19" r="2"/><path d="M20.9 19.8A2 2 0 0 0 22 18V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h5.1"/><path d="M15 11v-1"/><path d="M15 17v-2"/></svg>

After

Width:  |  Height:  |  Size: 463 B

View File

@@ -0,0 +1 @@
<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-rotate-ccw-icon lucide-rotate-ccw"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>

After

Width:  |  Height:  |  Size: 324 B

View File

@@ -0,0 +1 @@
<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-rotate-cw-icon lucide-rotate-cw"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>

After

Width:  |  Height:  |  Size: 324 B

View File

@@ -40,6 +40,7 @@ import _BellRingIcon from './icons/bell-ring.svg?component'
import _BookIcon from './icons/book.svg?component'
import _BookTextIcon from './icons/book-text.svg?component'
import _BookmarkIcon from './icons/bookmark.svg?component'
import _BotIcon from './icons/bot.svg?component'
import _BoxIcon from './icons/box.svg?component'
import _BoxImportIcon from './icons/box-import.svg?component'
import _BracesIcon from './icons/braces.svg?component'
@@ -76,6 +77,7 @@ import _FileIcon from './icons/file.svg?component'
import _FileTextIcon from './icons/file-text.svg?component'
import _FilterIcon from './icons/filter.svg?component'
import _FilterXIcon from './icons/filter-x.svg?component'
import _FolderArchiveIcon from './icons/folder-archive.svg?component'
import _FolderOpenIcon from './icons/folder-open.svg?component'
import _FolderSearchIcon from './icons/folder-search.svg?component'
import _GapIcon from './icons/gap.svg?component'
@@ -137,6 +139,8 @@ import _ReplyIcon from './icons/reply.svg?component'
import _ReportIcon from './icons/report.svg?component'
import _RestoreIcon from './icons/restore.svg?component'
import _RightArrowIcon from './icons/right-arrow.svg?component'
import _RotateCounterClockwiseIcon from './icons/rotate-ccw.svg?component'
import _RotateClockwiseIcon from './icons/rotate-cw.svg?component'
import _SaveIcon from './icons/save.svg?component'
import _ScaleIcon from './icons/scale.svg?component'
import _ScanEyeIcon from './icons/scan-eye.svg?component'
@@ -238,6 +242,7 @@ export const BellRingIcon = _BellRingIcon
export const BookIcon = _BookIcon
export const BookTextIcon = _BookTextIcon
export const BookmarkIcon = _BookmarkIcon
export const BotIcon = _BotIcon
export const BoxIcon = _BoxIcon
export const BoxImportIcon = _BoxImportIcon
export const BracesIcon = _BracesIcon
@@ -274,6 +279,7 @@ export const FileIcon = _FileIcon
export const FileTextIcon = _FileTextIcon
export const FilterIcon = _FilterIcon
export const FilterXIcon = _FilterXIcon
export const FolderArchiveIcon = _FolderArchiveIcon
export const FolderOpenIcon = _FolderOpenIcon
export const FolderSearchIcon = _FolderSearchIcon
export const GapIcon = _GapIcon
@@ -335,6 +341,8 @@ export const ReplyIcon = _ReplyIcon
export const ReportIcon = _ReportIcon
export const RestoreIcon = _RestoreIcon
export const RightArrowIcon = _RightArrowIcon
export const RotateCounterClockwiseIcon = _RotateCounterClockwiseIcon
export const RotateClockwiseIcon = _RotateClockwiseIcon
export const SaveIcon = _SaveIcon
export const ScaleIcon = _ScaleIcon
export const ScanEyeIcon = _ScanEyeIcon

View File

@@ -195,7 +195,7 @@ const colorVariables = computed(() => {
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child {
@apply flex cursor-pointer flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight];
@apply flex cursor-pointer flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight] whitespace-nowrap;
transition:
scale 0.125s ease-in-out,
background-color 0.25s ease-in-out,
@@ -204,6 +204,7 @@ const colorVariables = computed(() => {
svg:first-child {
color: var(--_icon, var(--_text));
transition: color 0.25s ease-in-out;
flex-shrink: 0;
}
&[disabled],

View File

@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
progress: number
max?: number
color?: 'brand' | 'green' | 'red' | 'orange' | 'blue' | 'purple' | 'gray'
waiting?: boolean
}>(),
{
max: 1,
color: 'brand',
waiting: false,
},
)
const colors = {
brand: {
fg: 'bg-brand',
bg: 'bg-brand-highlight',
},
green: {
fg: 'bg-green',
bg: 'bg-bg-green',
},
red: {
fg: 'bg-red',
bg: 'bg-bg-red',
},
orange: {
fg: 'bg-orange',
bg: 'bg-bg-orange',
},
blue: {
fg: 'bg-blue',
bg: 'bg-bg-blue',
},
purple: {
fg: 'bg-purple',
bg: 'bg-bg-purple',
},
gray: {
fg: 'bg-gray',
bg: 'bg-bg-gray',
},
}
const percent = computed(() => props.progress / props.max)
</script>
<template>
<div class="flex w-[15rem] h-1 rounded-full overflow-hidden" :class="colors[props.color].bg">
<div
class="rounded-full progress-bar"
:class="[colors[props.color].fg, { 'progress-bar--waiting': waiting }]"
:style="!waiting ? { width: `${percent * 100}%` } : {}"
></div>
</div>
</template>
<style scoped lang="scss">
.progress-bar {
transition: width 0.2s ease-in-out;
}
.progress-bar--waiting {
animation: progress-bar-waiting 1s linear infinite;
position: relative;
}
@keyframes progress-bar-waiting {
0% {
left: -50%;
width: 20%;
}
50% {
width: 60%;
}
100% {
left: 100%;
width: 20%;
}
}
</style>

View File

@@ -26,6 +26,7 @@ export { default as Page } from './base/Page.vue'
export { default as Pagination } from './base/Pagination.vue'
export { default as PopoutMenu } from './base/PopoutMenu.vue'
export { default as PreviewSelectButton } from './base/PreviewSelectButton.vue'
export { default as ProgressBar } from './base/ProgressBar.vue'
export { default as ProjectCard } from './base/ProjectCard.vue'
export { default as RadialHeader } from './base/RadialHeader.vue'
export { default as RadioButtons } from './base/RadioButtons.vue'
@@ -98,3 +99,6 @@ export { default as VersionSummary } from './version/VersionSummary.vue'
// Settings
export { default as ThemeSelector } from './settings/ThemeSelector.vue'
// Servers
export { default as BackupWarning } from './servers/backups/BackupWarning.vue'

View File

@@ -5,26 +5,28 @@
<span class="font-extrabold text-contrast text-lg">{{ title }}</span>
</slot>
</template>
<div>
<div class="markdown-body max-w-[35rem]" v-html="renderString(description)" />
<label v-if="hasToType" for="confirmation" class="confirmation-label">
<div class="flex flex-col gap-4">
<div
v-if="description"
class="markdown-body max-w-[35rem]"
v-html="renderString(description)"
/>
<slot />
<label v-if="hasToType" for="confirmation">
<span>
<strong>To verify, type</strong>
<em class="confirmation-text"> {{ confirmationText }} </em>
<strong>below:</strong>
To confirm you want to proceed, type
<span class="italic font-bold">{{ confirmationText }}</span> below:
</span>
</label>
<div class="confirmation-input">
<input
v-if="hasToType"
id="confirmation"
v-model="confirmation_typed"
type="text"
placeholder="Type here..."
@input="type"
/>
</div>
<div class="flex gap-2 mt-6">
<input
v-if="hasToType"
id="confirmation"
v-model="confirmation_typed"
type="text"
placeholder="Type here..."
class="max-w-[20rem]"
/>
<div class="flex gap-2">
<ButtonStyled :color="danger ? 'red' : 'brand'">
<button :disabled="action_disabled" @click="proceed">
<component :is="proceedIcon" />
@@ -65,8 +67,8 @@ const props = defineProps({
},
description: {
type: String,
default: 'No description defined',
required: true,
default: undefined,
required: false,
},
proceedIcon: {
type: Object,
@@ -95,21 +97,20 @@ const props = defineProps({
const emit = defineEmits(['proceed'])
const modal = ref(null)
const action_disabled = ref(props.hasToType)
const confirmation_typed = ref('')
const action_disabled = computed(
() =>
props.hasToType &&
confirmation_typed.value.toLowerCase() !== props.confirmationText.toLowerCase(),
)
function proceed() {
modal.value.hide()
confirmation_typed.value = ''
emit('proceed')
}
function type() {
if (props.hasToType) {
action_disabled.value =
confirmation_typed.value.toLowerCase() !== props.confirmationText.toLowerCase()
}
}
function show() {
modal.value.show()
}

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { IssuesIcon } from '@modrinth/assets'
import AutoLink from '../../base/AutoLink.vue'
defineProps<{
backupLink: string
}>()
</script>
<template>
<div
class="flex gap-3 rounded-2xl border-2 border-solid border-orange bg-bg-orange px-4 py-3 font-medium text-contrast"
>
<IssuesIcon class="mt-1 h-5 w-5 shrink-0 text-orange" />
<span class="leading-normal">
You may want to
<AutoLink
:to="backupLink"
class="font-semibold text-orange hover:underline active:brightness-125"
>create a backup</AutoLink
>
before proceeding, as this process is irreversible and may permanently alter your world or the
files on your server.
</span>
</div>
</template>

View File

@@ -11,6 +11,12 @@
"button.create-a-project": {
"defaultMessage": "Create a project"
},
"button.download": {
"defaultMessage": "Download"
},
"button.downloading": {
"defaultMessage": "Downloading"
},
"button.edit": {
"defaultMessage": "Edit"
},
@@ -422,6 +428,9 @@
"servers.notice.level.info.name": {
"defaultMessage": "Info"
},
"servers.notice.level.survey.name": {
"defaultMessage": "Survey"
},
"servers.notice.level.warn.name": {
"defaultMessage": "Warning"
},

View File

@@ -49,6 +49,14 @@ export const commonMessages = defineMessages({
id: 'label.description',
defaultMessage: 'Description',
},
downloadButton: {
id: 'button.download',
defaultMessage: 'Download',
},
downloadingButton: {
id: 'button.downloading',
defaultMessage: 'Downloading',
},
editButton: {
id: 'button.edit',
defaultMessage: 'Edit',

View File

@@ -10,6 +10,18 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2025-04-17T01:30:00-07:00`,
product: 'servers',
body: `### Improvements
- Completely overhauled the Backups interface and fixed them being non-functional.
- Backups will now show progress when creating and restoring.
- Backups now have a "Prepare download" phase, which will prepare a backup file for downloading.
- You can now cancel a backup in progress and retry a failed backup.
- When a backup is in progress, you will no longer be allowed to modify the modpack or loader.
- Removed the ability to create backups on install automatically, and replaced with a notice that you may want to create a backup before installing a new modpack or loader. This is because the previous implementation of backup on install was unreliable and buggy. We are working on a better implementation for this feature and plan for it to return in the future.
- Temporarily disabled auto backups button, since they are currently not working.`,
},
{
date: `2025-04-15T16:35:00-07:00`,
product: 'servers',