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

View File

@@ -1,86 +1,45 @@
<template> <template>
<NewModal ref="modal" danger header="Deleting backup"> <ConfirmModal
<div class="flex flex-col gap-4"> ref="modal"
<div class="relative flex w-full flex-col gap-2 rounded-2xl bg-[#0e0e0ea4] p-6"> danger
<div class="text-2xl font-extrabold text-contrast"> title="Are you sure you want to delete this backup?"
{{ backupName }} proceed-label="Delete backup"
</div> :confirmation-text="currentBackup?.name ?? 'null'"
<div class="flex gap-2 font-semibold text-contrast"> has-to-type
<CalendarIcon /> @proceed="emit('delete', currentBackup)"
{{ formattedDate }} >
</div> <BackupItem
</div> v-if="currentBackup"
</div> :backup="currentBackup"
<div class="mb-1 mt-4 flex justify-end gap-4"> preview
<ButtonStyled color="red"> class="border-px border-solid border-button-border"
<button :disabled="isDeleting" @click="deleteBackup"> />
<TrashIcon /> </ConfirmModal>
Delete backup
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button @click="hideModal">Cancel</button>
</ButtonStyled>
</div>
</NewModal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
import { ButtonStyled, NewModal } from "@modrinth/ui"; import { ConfirmModal } from "@modrinth/ui";
import { TrashIcon, CalendarIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers"; import type { Server } from "~/composables/pyroServers";
import BackupItem from "~/components/ui/servers/BackupItem.vue";
const props = defineProps<{ defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>; server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
backupId: string;
backupName: string;
backupCreatedAt: string;
}>(); }>();
const emit = defineEmits(["backupDeleted"]); const emit = defineEmits<{
(e: "delete", backup: Backup | undefined): void;
}>();
const modal = ref<InstanceType<typeof NewModal>>(); const modal = ref<InstanceType<typeof ConfirmModal>>();
const isDeleting = ref(false); const currentBackup = ref<Backup | undefined>(undefined);
const backupError = ref<string | null>(null);
const formattedDate = computed(() => { function show(backup: Backup) {
return new Date(props.backupCreatedAt).toLocaleString("en-US", { currentBackup.value = backup;
month: "numeric", modal.value?.show();
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;
}
};
defineExpose({ defineExpose({
show: () => modal.value?.show(), show,
hide: hideModal,
}); });
</script> </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> <template>
<NewModal ref="modal" header="Renaming backup" @show="focusInput"> <NewModal ref="modal" header="Renaming backup" @show="focusInput">
<div class="flex flex-col gap-2 md:w-[600px]"> <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 <input
id="backup-name-input"
ref="input" ref="input"
v-model="backupName" v-model="backupName"
type="text" type="text"
class="bg-bg-input w-full rounded-lg p-4" 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>
<div class="mb-1 mt-4 flex justify-start gap-4"> <div class="mt-2 flex justify-start gap-2">
<ButtonStyled color="brand"> <ButtonStyled color="brand">
<button :disabled="isRenaming" @click="renameBackup"> <button :disabled="isRenaming || nameExists" @click="renameBackup">
<SaveIcon /> <template v-if="isRenaming">
Rename backup <SpinnerIcon class="animate-spin" />
Renaming...
</template>
<template v-else>
<SaveIcon />
Save changes
</template>
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled> <ButtonStyled>
<button @click="hideModal"> <button @click="hide">
<XIcon /> <XIcon />
Cancel Cancel
</button> </button>
@@ -28,23 +45,38 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, nextTick } from "vue"; import { ref, nextTick, computed } from "vue";
import { ButtonStyled, NewModal } from "@modrinth/ui"; 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"; import type { Server } from "~/composables/pyroServers";
const props = defineProps<{ const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>; server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
currentBackupId: string;
}>(); }>();
const emit = defineEmits(["backupRenamed"]);
const modal = ref<InstanceType<typeof NewModal>>(); const modal = ref<InstanceType<typeof NewModal>>();
const input = ref<HTMLInputElement>(); const input = ref<HTMLInputElement>();
const backupName = ref(""); const backupName = ref("");
const originalName = ref("");
const isRenaming = ref(false); 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 = () => { const focusInput = () => {
nextTick(() => { nextTick(() => {
@@ -54,33 +86,55 @@ const focusInput = () => {
}); });
}; };
const hideModal = () => { function show(backup: Backup) {
backupName.value = ""; currentBackup.value = backup;
backupName.value = backup.name;
originalName.value = backup.name;
isRenaming.value = false;
modal.value?.show();
}
function hide() {
modal.value?.hide(); modal.value?.hide();
}; }
const renameBackup = async () => { const renameBackup = async () => {
if (!backupName.value.trim() || !props.currentBackupId) { if (!currentBackup.value) {
emit("backupRenamed", { success: false, message: "Backup name cannot be empty" }); addNotification({
type: "error",
title: "Error renaming backup",
text: "Current backup is null",
});
return;
}
if (trimmedName.value === originalName.value) {
hide();
return; return;
} }
isRenaming.value = true; isRenaming.value = true;
try { 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(); await props.server.refresh();
hideModal();
emit("backupRenamed", { success: true, message: "Backup renamed successfully" });
} catch (error) { } catch (error) {
backupError.value = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
emit("backupRenamed", { success: false, message: backupError.value }); addNotification({ type: "error", title: "Error renaming backup", text: message });
} finally { } finally {
hide();
isRenaming.value = false; isRenaming.value = false;
} }
}; };
defineExpose({ defineExpose({
show: () => modal.value?.show(), show,
hide: hideModal, hide,
}); });
</script> </script>

View File

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

View File

@@ -42,6 +42,9 @@
:column="true" :column="true"
class="mb-6 flex flex-col gap-2" 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"> <ButtonStyled type="standard" color="brand" @click="closeDetailsModal">
<button class="w-full">Close</button> <button class="w-full">Close</button>
</ButtonStyled> </ButtonStyled>

View File

@@ -67,37 +67,27 @@
Removes all data on your server, including your worlds, mods, and configuration files, Removes all data on your server, including your worlds, mods, and configuration files,
then reinstalls it with the selected version. then reinstalls it with the selected version.
</div> </div>
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
</div> </div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4"> <BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
<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>
</div> </div>
<div class="mt-4 flex justify-start gap-4"> <div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'"> <ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button :disabled="canInstall" @click="handleReinstall"> <button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="canInstall || backupInProgress"
@click="handleReinstall"
>
<RightArrowIcon /> <RightArrowIcon />
{{ {{
isBackingUp isMrpackModalSecondPhase
? "Backing up..." ? "Erase and install"
: isMrpackModalSecondPhase : loadingServerCheck
? "Erase and install" ? "Loading..."
: loadingServerCheck : isDangerous
? "Loading..." ? "Erase and install"
: isDangerous : "Install"
? "Erase and install"
: "Install"
}} }}
</button> </button>
</ButtonStyled> </ButtonStyled>
@@ -124,12 +114,14 @@
</template> </template>
<script setup lang="ts"> <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 { UploadIcon, RightArrowIcon, XIcon, ServerIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers"; import type { Server } from "~/composables/pyroServers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
const props = defineProps<{ const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>; server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
backupInProgress?: BackupInProgressReason;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -139,9 +131,7 @@ const emit = defineEmits<{
const mrpackModal = ref(); const mrpackModal = ref();
const isMrpackModalSecondPhase = ref(false); const isMrpackModalSecondPhase = ref(false);
const hardReset = ref(false); const hardReset = ref(false);
const backupServer = ref(false);
const isLoading = ref(false); const isLoading = ref(false);
const isBackingUp = ref(false);
const loadingServerCheck = ref(false); const loadingServerCheck = ref(false);
const mrpackFile = ref<File | null>(null); const mrpackFile = ref<File | null>(null);
@@ -156,64 +146,12 @@ const uploadMrpack = (event: Event) => {
mrpackFile.value = target.files[0]; 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 () => { const handleReinstall = async () => {
if (hardReset.value && !backupServer.value && !isMrpackModalSecondPhase.value) { if (hardReset.value && !isMrpackModalSecondPhase.value) {
isMrpackModalSecondPhase.value = true; isMrpackModalSecondPhase.value = true;
return; return;
} }
if (backupServer.value && !(await performBackup())) {
isLoading.value = false;
return;
}
isLoading.value = true; isLoading.value = true;
try { try {
@@ -259,7 +197,6 @@ const handleReinstall = async () => {
const onShow = () => { const onShow = () => {
hardReset.value = false; hardReset.value = false;
backupServer.value = false;
isMrpackModalSecondPhase.value = false; isMrpackModalSecondPhase.value = false;
loadingServerCheck.value = false; loadingServerCheck.value = false;
isLoading.value = false; isLoading.value = false;

View File

@@ -20,9 +20,7 @@
}" }"
> >
{{ {{
backupServer "This will reinstall your server and erase all data. Are you sure you want to continue?"
? "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?"
}} }}
</p> </p>
<div v-if="!isSecondPhase" class="flex flex-col gap-4"> <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, Removes all data on your server, including your worlds, mods, and configuration files,
then reinstalls it with the selected version. then reinstalls it with the selected version.
</div> </div>
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
</div> </div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4"> <BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
<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>
</div> </div>
<div class="mt-4 flex justify-start gap-4"> <div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'"> <ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button :disabled="canInstall" @click="handleReinstall"> <button
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:disabled="canInstall || !!backupInProgress"
@click="handleReinstall"
>
<RightArrowIcon /> <RightArrowIcon />
{{ {{
isBackingUp isLoading
? "Backing up..." ? "Installing..."
: isLoading : isSecondPhase
? "Installing..." ? "Erase and install"
: isSecondPhase : hardReset
? "Erase and install" ? "Continue"
: hardReset : "Install"
? "Continue"
: "Install"
}} }}
</button> </button>
</ButtonStyled> </ButtonStyled>
@@ -192,10 +177,13 @@
</template> </template>
<script setup lang="ts"> <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 { RightArrowIcon, XIcon, ServerIcon, DropdownIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers"; import type { Server } from "~/composables/pyroServers";
import type { Loaders } from "~/types/servers"; import type { Loaders } from "~/types/servers";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
const { formatMessage } = useVIntl();
interface LoaderVersion { interface LoaderVersion {
id: string; id: string;
@@ -213,6 +201,7 @@ type VersionCache = Record<string, any>;
const props = defineProps<{ const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>; server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
currentLoader: Loaders | undefined; currentLoader: Loaders | undefined;
backupInProgress?: BackupInProgressReason;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -222,9 +211,7 @@ const emit = defineEmits<{
const versionSelectModal = ref(); const versionSelectModal = ref();
const isSecondPhase = ref(false); const isSecondPhase = ref(false);
const hardReset = ref(false); const hardReset = ref(false);
const backupServer = ref(false);
const isLoading = ref(false); const isLoading = ref(false);
const isBackingUp = ref(false);
const loadingServerCheck = ref(false); const loadingServerCheck = ref(false);
const serverCheckError = ref(""); const serverCheckError = ref("");
@@ -413,69 +400,12 @@ const canInstall = computed(() => {
return conds || !selectedLoaderVersion.value; 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 () => { const handleReinstall = async () => {
if (hardReset.value && !isSecondPhase.value) { if (hardReset.value && !isSecondPhase.value) {
isSecondPhase.value = true; isSecondPhase.value = true;
return; return;
} }
if (backupServer.value) {
isBackingUp.value = true;
if (!(await performBackup())) {
isBackingUp.value = false;
isLoading.value = false;
return;
}
isBackingUp.value = false;
}
isLoading.value = true; isLoading.value = true;
try { try {
@@ -522,7 +452,6 @@ const onShow = () => {
const onHide = () => { const onHide = () => {
hardReset.value = false; hardReset.value = false;
backupServer.value = false;
isSecondPhase.value = false; isSecondPhase.value = false;
serverCheckError.value = ""; serverCheckError.value = "";
loadingServerCheck.value = false; loadingServerCheck.value = false;

View File

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

View File

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

View File

@@ -959,6 +959,57 @@
"search.filter.locked.server.sync": { "search.filter.locked.server.sync": {
"message": "Sync with server" "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": { "servers.notice.actions": {
"message": "Actions" "message": "Actions"
}, },

View File

@@ -1,401 +1,391 @@
<template> <template>
<div class="contents"> <div
<div v-if="filteredNotices.length > 0"
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"
class="experimental-styles-within relative mx-auto flex w-full min-w-0 max-w-[1280px] flex-col gap-3 px-6" >
> <ServerNotice
<ServerNotice v-for="notice in filteredNotices"
v-for="notice in filteredNotices" :key="`notice-${notice.id}`"
:key="`notice-${notice.id}`" :level="notice.level"
:level="notice.level" :message="notice.message"
:message="notice.message" :dismissable="notice.dismissable"
:dismissable="notice.dismissable" :title="notice.title"
:title="notice.title" class="w-full"
class="w-full" @dismiss="() => dismissNotice(notice.id)"
@dismiss="() => dismissNotice(notice.id)" />
/> </div>
</div> <div
<div v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'upgrading'"
v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'upgrading'" class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
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 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 text-center"> <div class="flex flex-col items-center gap-4">
<div class="flex flex-col items-center gap-4"> <div class="grid place-content-center rounded-full bg-bg-blue p-4">
<div class="grid place-content-center rounded-full bg-bg-blue p-4"> <TransferIcon class="size-12 text-blue" />
<TransferIcon class="size-12 text-blue" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server upgrading</h1>
</div> </div>
<p class="text-lg text-secondary"> <h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server upgrading</h1>
Your server's hardware is currently being upgraded and will be back online shortly!
</p>
</div> </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> </div>
<div </div>
v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'support'" <div
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast" 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 max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col items-center text-center">
<div class="grid place-content-center rounded-full bg-bg-blue p-4"> <div class="flex flex-col items-center gap-4">
<TransferIcon class="size-12 text-blue" /> <div class="grid place-content-center rounded-full bg-bg-blue p-4">
</div> <TransferIcon class="size-12 text-blue" />
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">We're working on your server</h1>
</div> </div>
<p class="text-lg text-secondary"> <h1 class="m-0 mb-2 w-fit text-4xl font-bold">We're working on your server</h1>
You recently contacted Modrinth Support, and we're actively working on your server. It
will be back online shortly.
</p>
</div> </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> </div>
<div </div>
v-else-if="serverData?.status === 'suspended' && serverData.suspension_reason !== 'upgrading'" <div
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast" 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 max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col items-center text-center">
<div class="grid place-content-center rounded-full bg-bg-orange p-4"> <div class="flex flex-col items-center gap-4">
<LockIcon class="size-12 text-orange" /> <div class="grid place-content-center rounded-full bg-bg-orange p-4">
</div> <LockIcon class="size-12 text-orange" />
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server suspended</h1>
</div> </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" formattedTime == "00" ? "Reconnecting..." : `Retrying in ${formattedTime} seconds...`
? "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> </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> </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')"> <UiServersServerInfoLabels
<button class="mt-6 !w-full">Go back to all servers</button> :server-data="serverData"
</ButtonStyled> :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> </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 <div
v-else-if="server.general?.error" data-pyro-navigation
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast" 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"> <UiNavTabs :links="navLinks" />
<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>
</div> </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 <div data-pyro-mount class="h-full w-full flex-1">
:server-data="serverData" <div
:show-game-label="showGameLabel" v-if="error"
:show-loader-label="showLoaderLabel" 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"
:uptime-seconds="uptimeSeconds" >
:linked="true" <div class="flex flex-row gap-4">
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" <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> </div>
<div <div
data-pyro-navigation v-if="!isConnected && !isReconnecting && !isLoading"
class="isolate flex w-full select-none flex-col justify-between gap-4 overflow-auto md:flex-row md:items-center" 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>
<div data-pyro-mount class="h-full w-full flex-1"> <div
<div v-if="isReconnecting"
v-if="error" data-pyro-server-ws-reconnecting
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" class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
> >
<div class="flex flex-row gap-4"> <UiServersPanelSpinner />
<IssuesIcon class="hidden h-8 w-8 shrink-0 text-red sm:block" /> Hang on, we're reconnecting to your server.
<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> </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>
</div> </div>
</template> </template>
@@ -416,8 +406,10 @@ import DOMPurify from "dompurify";
import { ButtonStyled, ServerNotice } from "@modrinth/ui"; import { ButtonStyled, ServerNotice } from "@modrinth/ui";
import { Intercom, shutdown } from "@intercom/messenger-js-sdk"; import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
import { reloadNuxtApp, navigateTo } from "#app"; import { reloadNuxtApp, navigateTo } from "#app";
import type { MessageDescriptor } from "@vintl/vintl";
import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers"; import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers";
import { usePyroConsole } from "~/store/console.ts"; import { usePyroConsole } from "~/store/console.ts";
import { type Backup } from "~/composables/pyroServers.ts";
import { usePyroFetch } from "~/composables/pyroFetch.ts"; import { usePyroFetch } from "~/composables/pyroFetch.ts";
const app = useNuxtApp() as unknown as { $notify: any }; const app = useNuxtApp() as unknown as { $notify: any };
@@ -770,6 +762,31 @@ const handleWebSocketMessage = (data: WSEvent) => {
uptimeSeconds.value = data.uptime; uptimeSeconds.value = data.uptime;
startUptimeUpdates(); startUptimeUpdates();
break; 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: default:
console.warn("Unhandled WebSocket event:", data); console.warn("Unhandled WebSocket event:", data);
} }
@@ -968,6 +985,41 @@ const formattedTime = computed(() => {
return `${seconds.toString().padStart(2, "0")}`; 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 = () => { const stopPolling = () => {
if (intervalId) { if (intervalId) {
clearInterval(intervalId); clearInterval(intervalId);

View File

@@ -1,248 +1,147 @@
<template> <template>
<div class="contents"> <div
<div v-if="server.backups?.error"
v-if="server.backups?.error" class="flex w-full flex-col items-center justify-center gap-4 p-4"
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 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 text-center"> <div class="flex flex-col items-center gap-4">
<div class="flex flex-col items-center gap-4"> <div class="grid place-content-center rounded-full bg-bg-orange p-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4"> <IssuesIcon class="size-12 text-orange" />
<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> </div>
<p class="text-lg text-secondary"> <h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load backups</h1>
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>
<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> </div>
<div v-else-if="data" class="contents"> </div>
<LazyUiServersBackupCreateModal <div v-else-if="data" class="contents">
ref="createBackupModal" <BackupCreateModal ref="createBackupModal" :server="server" />
:server="server" <BackupRenameModal ref="renameBackupModal" :server="server" />
@backup-created="handleBackupCreated" <BackupRestoreModal ref="restoreBackupModal" :server="server" />
/> <BackupDeleteModal ref="deleteBackupModal" :server="server" @delete="deleteBackup" />
<LazyUiServersBackupRenameModal <BackupSettingsModal ref="backupSettingsModal" :server="server" />
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"
/>
<LazyUiServersBackupSettingsModal ref="backupSettingsModal" :server="server" /> <div class="mb-6 flex flex-col items-center justify-between gap-4 sm:flex-row">
<div class="flex flex-col gap-2">
<ul class="m-0 flex list-none flex-col gap-4 p-0"> <div class="flex items-center gap-2">
<div class="relative w-full overflow-hidden rounded-2xl bg-bg-raised p-6 shadow-md"> <h1 class="m-0 text-2xl font-extrabold text-contrast">Backups</h1>
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row sm:gap-0"> <TagItem
<div class="flex flex-col items-baseline gap-2"> v-tooltip="`${data.backup_quota - data.used_backup_quota} backup slots remaining`"
<div class="text-2xl font-bold text-contrast"> class="cursor-help"
{{ :style="{
data.used_backup_quota === 0 '--_color':
? "No backups" data.backup_quota <= data.used_backup_quota
: `You've created ${data.used_backup_quota} backup${data.used_backup_quota === 1 ? "" : "s"}` ? 'var(--color-red)'
}} : data.backup_quota - data.used_backup_quota <= 3
</div> ? 'var(--color-orange)'
<div> : undefined,
{{ '--_bg-color':
data.backup_quota - data.used_backup_quota === 0 data.backup_quota <= data.used_backup_quota
? "You have reached your backup limit. Consider removing old backups to create new ones." ? 'var(--color-red-bg)'
: `You can create ${data.backup_quota - data.used_backup_quota} more backups for your server.` : data.backup_quota - data.used_backup_quota <= 3
}} ? 'var(--color-orange-bg)'
</div> : undefined,
</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="flex flex-col gap-4"> {{ data.used_backup_quota }} / {{ data.backup_quota }}
<div class="flex items-center justify-between"> </TagItem>
<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>
</div> </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 <div
class="over-the-top-download-animation" class="grid w-full grid-cols-[repeat(auto-fit,_minmax(180px,1fr))] gap-2 sm:flex sm:w-fit sm:flex-row"
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
> >
<div> <ButtonStyled type="standard">
<div <button
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40" v-tooltip="
></div> 'Auto backups are currently unavailable; we apologize for the inconvenience.'
<div "
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60" :disabled="true || server.general?.status === 'installing'"
></div> @click="showbackupSettingsModal"
<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" /> <SettingsIcon class="h-5 w-5" />
</div> 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> </div>
</div> </div>
@@ -250,25 +149,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ButtonStyled, NewModal } from "@modrinth/ui"; import { ButtonStyled, TagItem } from "@modrinth/ui";
import { useStorage } from "@vueuse/core"; import { useStorage } from "@vueuse/core";
import { import { SpinnerIcon, PlusIcon, DownloadIcon, SettingsIcon, IssuesIcon } from "@modrinth/assets";
PlusIcon,
CheckIcon,
CalendarIcon,
MoreHorizontalIcon,
EditIcon,
ClipboardCopyIcon,
DownloadIcon,
TrashIcon,
SettingsIcon,
BoxIcon,
LockIcon,
LockOpenIcon,
IssuesIcon,
} from "@modrinth/assets";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import type { Server } from "~/composables/pyroServers"; 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<{ const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>; server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
@@ -299,19 +190,30 @@ useHead({
const overTheTopDownloadAnimation = ref(); const overTheTopDownloadAnimation = ref();
const createBackupModal = ref<typeof NewModal>(); const createBackupModal = ref<InstanceType<typeof BackupCreateModal>>();
const renameBackupModal = ref<typeof NewModal>(); const renameBackupModal = ref<InstanceType<typeof BackupRenameModal>>();
const restoreBackupModal = ref<typeof NewModal>(); const restoreBackupModal = ref<InstanceType<typeof BackupRestoreModal>>();
const deleteBackupModal = ref<typeof NewModal>(); const deleteBackupModal = ref<InstanceType<typeof BackupDeleteModal>>();
const backupSettingsModal = ref<typeof NewModal>(); const backupSettingsModal = ref<InstanceType<typeof BackupSettingsModal>>();
const renameBackupName = ref(""); const backupCreationDisabled = computed(() => {
const currentBackup = ref(""); if (props.isServerRunning && !userPreferences.value.backupWhileRunning) {
return "Cannot create backup while server is running";
const refreshInterval = ref<ReturnType<typeof setInterval>>(); }
if (
const currentBackupDetails = computed(() => { data.value?.used_backup_quota !== undefined &&
return backups.value.find((backup) => backup.id === currentBackup.value); 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 = () => { const showCreateModel = () => {
@@ -322,69 +224,17 @@ const showbackupSettingsModal = () => {
backupSettingsModal.value?.show(); 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() { function triggerDownloadAnimation() {
overTheTopDownloadAnimation.value = true; overTheTopDownloadAnimation.value = true;
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500); setTimeout(() => (overTheTopDownloadAnimation.value = false), 500);
} }
const initiateDownload = async (backupId: string) => { const prepareDownload = async (backupId: string) => {
triggerDownloadAnimation();
try { try {
const downloadurl: any = await props.server.backups?.download(backupId); await props.server.backups?.prepare(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();
} catch (error) { } 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(() => { const retryBackup = async (backupId: string) => {
watchEffect(() => { try {
const hasOngoingBackups = backups.value.some((backup) => backup.ongoing); await props.server.backups?.retry(backupId);
await props.server.refresh(["backups"]);
if (refreshInterval.value) { } catch (error) {
clearInterval(refreshInterval.value); console.error("Failed to retry backup:", error);
refreshInterval.value = undefined;
}
if (hasOngoingBackups) {
refreshInterval.value = setInterval(async () => {
await props.server.refresh(["backups"]);
}, 10000);
}
});
});
onUnmounted(() => {
if (refreshInterval.value) {
clearInterval(refreshInterval.value);
} }
}); };
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> </script>
<style scoped> <style scoped>

View File

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

View File

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

View File

@@ -212,6 +212,18 @@ export interface WSNewModEvent {
event: "new-mod"; 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 = export type WSEvent =
| WSLogEvent | WSLogEvent
| WSStatsEvent | WSStatsEvent
@@ -221,7 +233,8 @@ export type WSEvent =
| WSInstallationResultEvent | WSInstallationResultEvent
| WSAuthOkEvent | WSAuthOkEvent
| WSUptimeEvent | WSUptimeEvent
| WSNewModEvent; | WSNewModEvent
| WSBackupProgressEvent;
export interface Servers { export interface Servers {
servers: Server[]; 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 _BookIcon from './icons/book.svg?component'
import _BookTextIcon from './icons/book-text.svg?component' import _BookTextIcon from './icons/book-text.svg?component'
import _BookmarkIcon from './icons/bookmark.svg?component' import _BookmarkIcon from './icons/bookmark.svg?component'
import _BotIcon from './icons/bot.svg?component'
import _BoxIcon from './icons/box.svg?component' import _BoxIcon from './icons/box.svg?component'
import _BoxImportIcon from './icons/box-import.svg?component' import _BoxImportIcon from './icons/box-import.svg?component'
import _BracesIcon from './icons/braces.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 _FileTextIcon from './icons/file-text.svg?component'
import _FilterIcon from './icons/filter.svg?component' import _FilterIcon from './icons/filter.svg?component'
import _FilterXIcon from './icons/filter-x.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 _FolderOpenIcon from './icons/folder-open.svg?component'
import _FolderSearchIcon from './icons/folder-search.svg?component' import _FolderSearchIcon from './icons/folder-search.svg?component'
import _GapIcon from './icons/gap.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 _ReportIcon from './icons/report.svg?component'
import _RestoreIcon from './icons/restore.svg?component' import _RestoreIcon from './icons/restore.svg?component'
import _RightArrowIcon from './icons/right-arrow.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 _SaveIcon from './icons/save.svg?component'
import _ScaleIcon from './icons/scale.svg?component' import _ScaleIcon from './icons/scale.svg?component'
import _ScanEyeIcon from './icons/scan-eye.svg?component' import _ScanEyeIcon from './icons/scan-eye.svg?component'
@@ -238,6 +242,7 @@ export const BellRingIcon = _BellRingIcon
export const BookIcon = _BookIcon export const BookIcon = _BookIcon
export const BookTextIcon = _BookTextIcon export const BookTextIcon = _BookTextIcon
export const BookmarkIcon = _BookmarkIcon export const BookmarkIcon = _BookmarkIcon
export const BotIcon = _BotIcon
export const BoxIcon = _BoxIcon export const BoxIcon = _BoxIcon
export const BoxImportIcon = _BoxImportIcon export const BoxImportIcon = _BoxImportIcon
export const BracesIcon = _BracesIcon export const BracesIcon = _BracesIcon
@@ -274,6 +279,7 @@ export const FileIcon = _FileIcon
export const FileTextIcon = _FileTextIcon export const FileTextIcon = _FileTextIcon
export const FilterIcon = _FilterIcon export const FilterIcon = _FilterIcon
export const FilterXIcon = _FilterXIcon export const FilterXIcon = _FilterXIcon
export const FolderArchiveIcon = _FolderArchiveIcon
export const FolderOpenIcon = _FolderOpenIcon export const FolderOpenIcon = _FolderOpenIcon
export const FolderSearchIcon = _FolderSearchIcon export const FolderSearchIcon = _FolderSearchIcon
export const GapIcon = _GapIcon export const GapIcon = _GapIcon
@@ -335,6 +341,8 @@ export const ReplyIcon = _ReplyIcon
export const ReportIcon = _ReportIcon export const ReportIcon = _ReportIcon
export const RestoreIcon = _RestoreIcon export const RestoreIcon = _RestoreIcon
export const RightArrowIcon = _RightArrowIcon export const RightArrowIcon = _RightArrowIcon
export const RotateCounterClockwiseIcon = _RotateCounterClockwiseIcon
export const RotateClockwiseIcon = _RotateClockwiseIcon
export const SaveIcon = _SaveIcon export const SaveIcon = _SaveIcon
export const ScaleIcon = _ScaleIcon export const ScaleIcon = _ScaleIcon
export const ScanEyeIcon = _ScanEyeIcon export const ScanEyeIcon = _ScanEyeIcon

View File

@@ -195,7 +195,7 @@ const colorVariables = computed(() => {
> *:first-child > *:first-child
> *:first-child > *:first-child
> :is(button, a, .button-like):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: transition:
scale 0.125s ease-in-out, scale 0.125s ease-in-out,
background-color 0.25s ease-in-out, background-color 0.25s ease-in-out,
@@ -204,6 +204,7 @@ const colorVariables = computed(() => {
svg:first-child { svg:first-child {
color: var(--_icon, var(--_text)); color: var(--_icon, var(--_text));
transition: color 0.25s ease-in-out; transition: color 0.25s ease-in-out;
flex-shrink: 0;
} }
&[disabled], &[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 Pagination } from './base/Pagination.vue'
export { default as PopoutMenu } from './base/PopoutMenu.vue' export { default as PopoutMenu } from './base/PopoutMenu.vue'
export { default as PreviewSelectButton } from './base/PreviewSelectButton.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 ProjectCard } from './base/ProjectCard.vue'
export { default as RadialHeader } from './base/RadialHeader.vue' export { default as RadialHeader } from './base/RadialHeader.vue'
export { default as RadioButtons } from './base/RadioButtons.vue' export { default as RadioButtons } from './base/RadioButtons.vue'
@@ -98,3 +99,6 @@ export { default as VersionSummary } from './version/VersionSummary.vue'
// Settings // Settings
export { default as ThemeSelector } from './settings/ThemeSelector.vue' 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> <span class="font-extrabold text-contrast text-lg">{{ title }}</span>
</slot> </slot>
</template> </template>
<div> <div class="flex flex-col gap-4">
<div class="markdown-body max-w-[35rem]" v-html="renderString(description)" /> <div
<label v-if="hasToType" for="confirmation" class="confirmation-label"> v-if="description"
class="markdown-body max-w-[35rem]"
v-html="renderString(description)"
/>
<slot />
<label v-if="hasToType" for="confirmation">
<span> <span>
<strong>To verify, type</strong> To confirm you want to proceed, type
<em class="confirmation-text"> {{ confirmationText }} </em> <span class="italic font-bold">{{ confirmationText }}</span> below:
<strong>below:</strong>
</span> </span>
</label> </label>
<div class="confirmation-input"> <input
<input v-if="hasToType"
v-if="hasToType" id="confirmation"
id="confirmation" v-model="confirmation_typed"
v-model="confirmation_typed" type="text"
type="text" placeholder="Type here..."
placeholder="Type here..." class="max-w-[20rem]"
@input="type" />
/> <div class="flex gap-2">
</div>
<div class="flex gap-2 mt-6">
<ButtonStyled :color="danger ? 'red' : 'brand'"> <ButtonStyled :color="danger ? 'red' : 'brand'">
<button :disabled="action_disabled" @click="proceed"> <button :disabled="action_disabled" @click="proceed">
<component :is="proceedIcon" /> <component :is="proceedIcon" />
@@ -65,8 +67,8 @@ const props = defineProps({
}, },
description: { description: {
type: String, type: String,
default: 'No description defined', default: undefined,
required: true, required: false,
}, },
proceedIcon: { proceedIcon: {
type: Object, type: Object,
@@ -95,21 +97,20 @@ const props = defineProps({
const emit = defineEmits(['proceed']) const emit = defineEmits(['proceed'])
const modal = ref(null) const modal = ref(null)
const action_disabled = ref(props.hasToType)
const confirmation_typed = ref('') const confirmation_typed = ref('')
const action_disabled = computed(
() =>
props.hasToType &&
confirmation_typed.value.toLowerCase() !== props.confirmationText.toLowerCase(),
)
function proceed() { function proceed() {
modal.value.hide() modal.value.hide()
confirmation_typed.value = ''
emit('proceed') emit('proceed')
} }
function type() {
if (props.hasToType) {
action_disabled.value =
confirmation_typed.value.toLowerCase() !== props.confirmationText.toLowerCase()
}
}
function show() { function show() {
modal.value.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": { "button.create-a-project": {
"defaultMessage": "Create a project" "defaultMessage": "Create a project"
}, },
"button.download": {
"defaultMessage": "Download"
},
"button.downloading": {
"defaultMessage": "Downloading"
},
"button.edit": { "button.edit": {
"defaultMessage": "Edit" "defaultMessage": "Edit"
}, },
@@ -422,6 +428,9 @@
"servers.notice.level.info.name": { "servers.notice.level.info.name": {
"defaultMessage": "Info" "defaultMessage": "Info"
}, },
"servers.notice.level.survey.name": {
"defaultMessage": "Survey"
},
"servers.notice.level.warn.name": { "servers.notice.level.warn.name": {
"defaultMessage": "Warning" "defaultMessage": "Warning"
}, },

View File

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

View File

@@ -10,6 +10,18 @@ export type VersionEntry = {
} }
const VERSIONS: 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`, date: `2025-04-15T16:35:00-07:00`,
product: 'servers', product: 'servers',