forked from didirus/AstralRinth
Merge commit 'daf699911104207a477751916b36a371ee8f7e38' into feature-clean
This commit is contained in:
@@ -183,7 +183,7 @@ async function createProject() {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -430,7 +430,7 @@ async function performAction(notification, actionIndex) {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ async function createOrganization() {
|
||||
addNotification({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatProjectType } from "~/plugins/shorthands.js";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
CheckIcon,
|
||||
@@ -117,7 +116,9 @@ import {
|
||||
LightBulbIcon,
|
||||
SendIcon,
|
||||
ScaleIcon,
|
||||
DropdownIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { formatProjectType } from "~/plugins/shorthands.js";
|
||||
import { acceptTeamInvite, removeTeamMember } from "~/helpers/teams.js";
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Creating backup" @show="focusInput">
|
||||
<div class="flex flex-col gap-2 md:w-[600px]">
|
||||
<div class="font-semibold text-contrast">Name</div>
|
||||
<label for="backup-name-input">
|
||||
<span class="text-lg font-semibold text-contrast"> Name </span>
|
||||
</label>
|
||||
<input
|
||||
id="backup-name-input"
|
||||
ref="input"
|
||||
v-model="backupName"
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
placeholder="e.g. Before 1.21"
|
||||
maxlength="64"
|
||||
:placeholder="`Backup #${newBackupAmount}`"
|
||||
maxlength="48"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<InfoIcon class="hidden sm:block" />
|
||||
<span class="text-sm text-secondary">
|
||||
If left empty, the backup name will default to
|
||||
<span class="font-semibold"> Backup #{{ newBackupAmount }}</span>
|
||||
<div v-if="nameExists && !isCreating" class="flex items-center gap-1">
|
||||
<IssuesIcon class="hidden text-orange sm:block" />
|
||||
<span class="text-sm text-orange">
|
||||
You already have a backup named '<span class="font-semibold">{{ trimmedName }}</span
|
||||
>'
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="isRateLimited" class="mt-2 text-sm text-red">
|
||||
You're creating backups too fast. Please wait a moment before trying again.
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-1 mt-4 flex justify-start gap-4">
|
||||
<div class="mt-2 flex justify-start gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="isCreating" @click="createBackup">
|
||||
<button :disabled="isCreating || nameExists" @click="createBackup">
|
||||
<PlusIcon />
|
||||
Create backup
|
||||
</button>
|
||||
@@ -41,24 +44,30 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, computed } from "vue";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { PlusIcon, XIcon, InfoIcon } from "@modrinth/assets";
|
||||
import { IssuesIcon, PlusIcon, XIcon } from "@modrinth/assets";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["backupCreated"]);
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
const input = ref<HTMLInputElement>();
|
||||
const isCreating = ref(false);
|
||||
const isRateLimited = ref(false);
|
||||
const backupError = ref<string | null>(null);
|
||||
const backupName = ref("");
|
||||
const newBackupAmount = computed(() =>
|
||||
props.server.backups?.data?.length === undefined ? 1 : props.server.backups?.data?.length + 1,
|
||||
);
|
||||
|
||||
const trimmedName = computed(() => backupName.value.trim());
|
||||
|
||||
const nameExists = computed(() => {
|
||||
if (!props.server.backups?.data) return false;
|
||||
return props.server.backups.data.some(
|
||||
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
|
||||
);
|
||||
});
|
||||
|
||||
const focusInput = () => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
@@ -67,30 +76,38 @@ const focusInput = () => {
|
||||
});
|
||||
};
|
||||
|
||||
function show() {
|
||||
backupName.value = "";
|
||||
isCreating.value = false;
|
||||
modal.value?.show();
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
modal.value?.hide();
|
||||
backupName.value = "";
|
||||
};
|
||||
|
||||
const createBackup = async () => {
|
||||
if (!backupName.value.trim()) {
|
||||
if (backupName.value.trim().length === 0) {
|
||||
backupName.value = `Backup #${newBackupAmount.value}`;
|
||||
}
|
||||
|
||||
isCreating.value = true;
|
||||
isRateLimited.value = false;
|
||||
try {
|
||||
await props.server.backups?.create(backupName.value);
|
||||
await props.server.refresh();
|
||||
await props.server.backups?.create(trimmedName.value);
|
||||
hideModal();
|
||||
emit("backupCreated", { success: true, message: "Backup created successfully" });
|
||||
await props.server.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof PyroFetchError && error.statusCode === 429) {
|
||||
isRateLimited.value = true;
|
||||
backupError.value = "You're creating backups too fast.";
|
||||
addNotification({
|
||||
type: "error",
|
||||
title: "Error creating backup",
|
||||
text: "You're creating backups too fast.",
|
||||
});
|
||||
} else {
|
||||
backupError.value = error instanceof Error ? error.message : String(error);
|
||||
emit("backupCreated", { success: false, message: backupError.value });
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
addNotification({ type: "error", title: "Error creating backup", text: message });
|
||||
}
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
@@ -98,7 +115,7 @@ const createBackup = async () => {
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
show: () => modal.value?.show(),
|
||||
show,
|
||||
hide: hideModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,86 +1,45 @@
|
||||
<template>
|
||||
<NewModal ref="modal" danger header="Deleting backup">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="relative flex w-full flex-col gap-2 rounded-2xl bg-[#0e0e0ea4] p-6">
|
||||
<div class="text-2xl font-extrabold text-contrast">
|
||||
{{ backupName }}
|
||||
</div>
|
||||
<div class="flex gap-2 font-semibold text-contrast">
|
||||
<CalendarIcon />
|
||||
{{ formattedDate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-1 mt-4 flex justify-end gap-4">
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="isDeleting" @click="deleteBackup">
|
||||
<TrashIcon />
|
||||
Delete backup
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="hideModal">Cancel</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</NewModal>
|
||||
<ConfirmModal
|
||||
ref="modal"
|
||||
danger
|
||||
title="Are you sure you want to delete this backup?"
|
||||
proceed-label="Delete backup"
|
||||
:confirmation-text="currentBackup?.name ?? 'null'"
|
||||
has-to-type
|
||||
@proceed="emit('delete', currentBackup)"
|
||||
>
|
||||
<BackupItem
|
||||
v-if="currentBackup"
|
||||
:backup="currentBackup"
|
||||
preview
|
||||
class="border-px border-solid border-button-border"
|
||||
/>
|
||||
</ConfirmModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { TrashIcon, CalendarIcon } from "@modrinth/assets";
|
||||
import { ConfirmModal } from "@modrinth/ui";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import BackupItem from "~/components/ui/servers/BackupItem.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
backupId: string;
|
||||
backupName: string;
|
||||
backupCreatedAt: string;
|
||||
defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["backupDeleted"]);
|
||||
const emit = defineEmits<{
|
||||
(e: "delete", backup: Backup | undefined): void;
|
||||
}>();
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
const isDeleting = ref(false);
|
||||
const backupError = ref<string | null>(null);
|
||||
const modal = ref<InstanceType<typeof ConfirmModal>>();
|
||||
const currentBackup = ref<Backup | undefined>(undefined);
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
return new Date(props.backupCreatedAt).toLocaleString("en-US", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "2-digit",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
});
|
||||
});
|
||||
|
||||
const hideModal = () => {
|
||||
modal.value?.hide();
|
||||
};
|
||||
|
||||
const deleteBackup = async () => {
|
||||
if (!props.backupId) {
|
||||
emit("backupDeleted", { success: false, message: "No backup selected" });
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
try {
|
||||
await props.server.backups?.delete(props.backupId);
|
||||
await props.server.refresh();
|
||||
hideModal();
|
||||
emit("backupDeleted", { success: true, message: "Backup deleted successfully" });
|
||||
} catch (error) {
|
||||
backupError.value = error instanceof Error ? error.message : String(error);
|
||||
emit("backupDeleted", { success: false, message: backupError.value });
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
};
|
||||
function show(backup: Backup) {
|
||||
currentBackup.value = backup;
|
||||
modal.value?.show();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show: () => modal.value?.show(),
|
||||
hide: hideModal,
|
||||
show,
|
||||
});
|
||||
</script>
|
||||
|
||||
340
apps/frontend/src/components/ui/servers/BackupItem.vue
Normal file
340
apps/frontend/src/components/ui/servers/BackupItem.vue
Normal file
@@ -0,0 +1,340 @@
|
||||
<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 (
|
||||
(!task && initiatedPrepare.value) ||
|
||||
(task && task.progress < 1 && !inactiveStates.includes(task.state))
|
||||
);
|
||||
});
|
||||
|
||||
const failedToRestore = computed(() => props.backup.task?.restore?.state === "failed");
|
||||
const failedToPrepareFile = computed(() => props.backup.task?.file?.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",
|
||||
},
|
||||
prepareDownloadAgain: {
|
||||
id: "servers.backups.item.prepare-download-again",
|
||||
defaultMessage: "Try preparing again",
|
||||
},
|
||||
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",
|
||||
},
|
||||
failedToPrepareFile: {
|
||||
id: "servers.backups.item.failed-to-prepare-backup",
|
||||
defaultMessage: "Failed to prepare download",
|
||||
},
|
||||
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 flex-wrap 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 || failedToPrepareFile"
|
||||
class="flex items-center gap-1 text-sm text-red"
|
||||
>
|
||||
<XIcon />
|
||||
{{
|
||||
formatMessage(
|
||||
failedToCreate
|
||||
? messages.failedToCreateBackup
|
||||
: failedToRestore
|
||||
? messages.failedToRestoreBackup
|
||||
: messages.failedToPrepareFile,
|
||||
)
|
||||
}}
|
||||
</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
|
||||
: failedToPrepareFile
|
||||
? messages.prepareDownloadAgain
|
||||
: 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>
|
||||
@@ -1,24 +1,41 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Renaming backup" @show="focusInput">
|
||||
<div class="flex flex-col gap-2 md:w-[600px]">
|
||||
<div class="font-semibold text-contrast">Name</div>
|
||||
<label for="backup-name-input">
|
||||
<span class="text-lg font-semibold text-contrast"> Name </span>
|
||||
</label>
|
||||
<input
|
||||
id="backup-name-input"
|
||||
ref="input"
|
||||
v-model="backupName"
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
placeholder="e.g. Before 1.21"
|
||||
:placeholder="`Backup #${backupNumber}`"
|
||||
maxlength="48"
|
||||
/>
|
||||
<div v-if="nameExists" class="flex items-center gap-1">
|
||||
<IssuesIcon class="hidden text-orange sm:block" />
|
||||
<span class="text-sm text-orange">
|
||||
You already have a backup named '<span class="font-semibold">{{ trimmedName }}</span
|
||||
>'
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-1 mt-4 flex justify-start gap-4">
|
||||
<div class="mt-2 flex justify-start gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="isRenaming" @click="renameBackup">
|
||||
<SaveIcon />
|
||||
Rename backup
|
||||
<button :disabled="isRenaming || nameExists" @click="renameBackup">
|
||||
<template v-if="isRenaming">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
Renaming...
|
||||
</template>
|
||||
<template v-else>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="hideModal">
|
||||
<button @click="hide">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
@@ -28,23 +45,38 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from "vue";
|
||||
import { ref, nextTick, computed } from "vue";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { SaveIcon, XIcon } from "@modrinth/assets";
|
||||
import { SpinnerIcon, SaveIcon, XIcon, IssuesIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
currentBackupId: string;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["backupRenamed"]);
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
const input = ref<HTMLInputElement>();
|
||||
const backupName = ref("");
|
||||
const originalName = ref("");
|
||||
const isRenaming = ref(false);
|
||||
const backupError = ref<string | null>(null);
|
||||
|
||||
const currentBackup = ref<Backup | null>(null);
|
||||
|
||||
const trimmedName = computed(() => backupName.value.trim());
|
||||
|
||||
const nameExists = computed(() => {
|
||||
if (!props.server.backups?.data || trimmedName.value === originalName.value || isRenaming.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return props.server.backups.data.some(
|
||||
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
|
||||
);
|
||||
});
|
||||
|
||||
const backupNumber = computed(
|
||||
() => (props.server.backups?.data?.findIndex((b) => b.id === currentBackup.value?.id) ?? 0) + 1,
|
||||
);
|
||||
|
||||
const focusInput = () => {
|
||||
nextTick(() => {
|
||||
@@ -54,33 +86,55 @@ const focusInput = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const hideModal = () => {
|
||||
backupName.value = "";
|
||||
function show(backup: Backup) {
|
||||
currentBackup.value = backup;
|
||||
backupName.value = backup.name;
|
||||
originalName.value = backup.name;
|
||||
isRenaming.value = false;
|
||||
modal.value?.show();
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide();
|
||||
};
|
||||
}
|
||||
|
||||
const renameBackup = async () => {
|
||||
if (!backupName.value.trim() || !props.currentBackupId) {
|
||||
emit("backupRenamed", { success: false, message: "Backup name cannot be empty" });
|
||||
if (!currentBackup.value) {
|
||||
addNotification({
|
||||
type: "error",
|
||||
title: "Error renaming backup",
|
||||
text: "Current backup is null",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedName.value === originalName.value) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
isRenaming.value = true;
|
||||
try {
|
||||
await props.server.backups?.rename(props.currentBackupId, backupName.value);
|
||||
let newName = trimmedName.value;
|
||||
|
||||
if (newName.length === 0) {
|
||||
newName = `Backup #${backupNumber.value}`;
|
||||
}
|
||||
|
||||
await props.server.backups?.rename(currentBackup.value.id, newName);
|
||||
hide();
|
||||
await props.server.refresh();
|
||||
hideModal();
|
||||
emit("backupRenamed", { success: true, message: "Backup renamed successfully" });
|
||||
} catch (error) {
|
||||
backupError.value = error instanceof Error ? error.message : String(error);
|
||||
emit("backupRenamed", { success: false, message: backupError.value });
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
addNotification({ type: "error", title: "Error renaming backup", text: message });
|
||||
} finally {
|
||||
hide();
|
||||
isRenaming.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
show: () => modal.value?.show(),
|
||||
hide: hideModal,
|
||||
show,
|
||||
hide,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,82 +1,58 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Restoring backup">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="relative flex w-full flex-col gap-2 rounded-2xl bg-bg p-6">
|
||||
<div class="text-2xl font-extrabold text-contrast">
|
||||
{{ backupName }}
|
||||
</div>
|
||||
<div class="flex gap-2 font-semibold text-contrast">
|
||||
<CalendarIcon />
|
||||
{{ formattedDate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-1 mt-4 flex justify-end gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="isRestoring" @click="restoreBackup">Restore backup</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="hideModal">Cancel</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</NewModal>
|
||||
<ConfirmModal
|
||||
ref="modal"
|
||||
danger
|
||||
title="Are you sure you want to restore from this backup?"
|
||||
proceed-label="Restore from backup"
|
||||
description="This will **overwrite all files on your server** and replace them with the files from the backup."
|
||||
@proceed="restoreBackup"
|
||||
>
|
||||
<BackupItem
|
||||
v-if="currentBackup"
|
||||
:backup="currentBackup"
|
||||
preview
|
||||
class="border-px border-solid border-button-border"
|
||||
/>
|
||||
</ConfirmModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { CalendarIcon } from "@modrinth/assets";
|
||||
import { ConfirmModal, NewModal } from "@modrinth/ui";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import BackupItem from "~/components/ui/servers/BackupItem.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
backupId: string;
|
||||
backupName: string;
|
||||
backupCreatedAt: string;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["backupRestored"]);
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
const isRestoring = ref(false);
|
||||
const backupError = ref<string | null>(null);
|
||||
const currentBackup = ref<Backup | null>(null);
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
return new Date(props.backupCreatedAt).toLocaleString("en-US", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "2-digit",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
});
|
||||
});
|
||||
|
||||
const hideModal = () => {
|
||||
modal.value?.hide();
|
||||
};
|
||||
function show(backup: Backup) {
|
||||
currentBackup.value = backup;
|
||||
modal.value?.show();
|
||||
}
|
||||
|
||||
const restoreBackup = async () => {
|
||||
if (!props.backupId) {
|
||||
emit("backupRestored", { success: false, message: "No backup selected" });
|
||||
if (!currentBackup.value) {
|
||||
addNotification({
|
||||
type: "error",
|
||||
title: "Failed to restore backup",
|
||||
text: "Current backup is null",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isRestoring.value = true;
|
||||
try {
|
||||
await props.server.backups?.restore(props.backupId);
|
||||
hideModal();
|
||||
emit("backupRestored", { success: true, message: "Backup restored successfully" });
|
||||
await props.server.backups?.restore(currentBackup.value.id);
|
||||
} catch (error) {
|
||||
backupError.value = error instanceof Error ? error.message : String(error);
|
||||
emit("backupRestored", { success: false, message: backupError.value });
|
||||
} finally {
|
||||
isRestoring.value = false;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
addNotification({ type: "error", title: "Failed to restore backup", text: message });
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
show: () => modal.value?.show(),
|
||||
hide: hideModal,
|
||||
show,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
:column="true"
|
||||
class="mb-6 flex flex-col gap-2"
|
||||
/>
|
||||
<div v-if="flags.advancedDebugInfo" class="markdown-body">
|
||||
<pre>{{ serverData }}</pre>
|
||||
</div>
|
||||
<ButtonStyled type="standard" color="brand" @click="closeDetailsModal">
|
||||
<button class="w-full">Close</button>
|
||||
</ButtonStyled>
|
||||
@@ -89,6 +92,10 @@
|
||||
<InfoIcon class="h-5 w-5" />
|
||||
<span>Details</span>
|
||||
</template>
|
||||
<template #copy-id>
|
||||
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
|
||||
<span>Copy ID</span>
|
||||
</template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
@@ -108,6 +115,7 @@ import {
|
||||
ServerIcon,
|
||||
InfoIcon,
|
||||
MoreVerticalIcon,
|
||||
ClipboardCopyIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { useRouter } from "vue-router";
|
||||
@@ -116,6 +124,8 @@ import { useStorage } from "@vueuse/core";
|
||||
type ServerAction = "start" | "stop" | "restart" | "kill";
|
||||
type ServerState = "stopped" | "starting" | "running" | "stopping" | "restarting";
|
||||
|
||||
const flags = useFeatureFlags();
|
||||
|
||||
interface PowerAction {
|
||||
action: ServerAction;
|
||||
nextState: ServerState;
|
||||
@@ -198,8 +208,19 @@ const menuOptions = computed(() => [
|
||||
icon: InfoIcon,
|
||||
action: () => detailsModal.value?.show(),
|
||||
},
|
||||
{
|
||||
id: "copy-id",
|
||||
label: "Copy ID",
|
||||
icon: ClipboardCopyIcon,
|
||||
action: () => copyId(),
|
||||
shown: flags.value.developerMode,
|
||||
},
|
||||
]);
|
||||
|
||||
async function copyId() {
|
||||
await navigator.clipboard.writeText(serverId as string);
|
||||
}
|
||||
|
||||
function initiateAction(action: ServerAction) {
|
||||
if (!canTakeAction.value) return;
|
||||
|
||||
|
||||
@@ -67,37 +67,27 @@
|
||||
Removes all data on your server, including your worlds, mods, and configuration files,
|
||||
then reinstalls it with the selected version.
|
||||
</div>
|
||||
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="flex w-full flex-row items-center justify-between">
|
||||
<label class="w-full text-lg font-bold text-contrast" for="backup-server-mrpack">
|
||||
Backup server
|
||||
</label>
|
||||
<input
|
||||
id="backup-server-mrpack"
|
||||
v-model="backupServer"
|
||||
class="switch stylized-toggle shrink-0"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div>Creates a backup of your server before proceeding.</div>
|
||||
</div>
|
||||
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
|
||||
</div>
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
||||
<button :disabled="canInstall" @click="handleReinstall">
|
||||
<button
|
||||
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||
:disabled="canInstall || backupInProgress"
|
||||
@click="handleReinstall"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
{{
|
||||
isBackingUp
|
||||
? "Backing up..."
|
||||
: isMrpackModalSecondPhase
|
||||
? "Erase and install"
|
||||
: loadingServerCheck
|
||||
? "Loading..."
|
||||
: isDangerous
|
||||
? "Erase and install"
|
||||
: "Install"
|
||||
isMrpackModalSecondPhase
|
||||
? "Erase and install"
|
||||
: loadingServerCheck
|
||||
? "Loading..."
|
||||
: isDangerous
|
||||
? "Erase and install"
|
||||
: "Install"
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -124,12 +114,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { UploadIcon, RightArrowIcon, XIcon, ServerIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
backupInProgress?: BackupInProgressReason;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -139,9 +131,7 @@ const emit = defineEmits<{
|
||||
const mrpackModal = ref();
|
||||
const isMrpackModalSecondPhase = ref(false);
|
||||
const hardReset = ref(false);
|
||||
const backupServer = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const isBackingUp = ref(false);
|
||||
const loadingServerCheck = ref(false);
|
||||
const mrpackFile = ref<File | null>(null);
|
||||
|
||||
@@ -156,64 +146,12 @@ const uploadMrpack = (event: Event) => {
|
||||
mrpackFile.value = target.files[0];
|
||||
};
|
||||
|
||||
const performBackup = async (): Promise<boolean> => {
|
||||
try {
|
||||
const date = new Date();
|
||||
const format = date.toLocaleString(navigator.language || "en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
const backupName = `Reinstallation - ${format}`;
|
||||
isLoading.value = true;
|
||||
const backupId = await props.server.backups?.create(backupName);
|
||||
isBackingUp.value = true;
|
||||
let attempts = 0;
|
||||
while (true) {
|
||||
attempts++;
|
||||
if (attempts > 100) {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Backup Failed",
|
||||
text: "An unexpected error occurred while backing up. Please try again later.",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
await props.server.refresh(["backups"]);
|
||||
const backups = await props.server.backups?.data;
|
||||
const backup = backupId ? backups?.find((x) => x.id === backupId) : undefined;
|
||||
if (backup && !backup.ongoing) {
|
||||
isBackingUp.value = false;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Backup Failed",
|
||||
text: "An unexpected error occurred while backing up. Please try again later.",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReinstall = async () => {
|
||||
if (hardReset.value && !backupServer.value && !isMrpackModalSecondPhase.value) {
|
||||
if (hardReset.value && !isMrpackModalSecondPhase.value) {
|
||||
isMrpackModalSecondPhase.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (backupServer.value && !(await performBackup())) {
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
@@ -259,7 +197,6 @@ const handleReinstall = async () => {
|
||||
|
||||
const onShow = () => {
|
||||
hardReset.value = false;
|
||||
backupServer.value = false;
|
||||
isMrpackModalSecondPhase.value = false;
|
||||
loadingServerCheck.value = false;
|
||||
isLoading.value = false;
|
||||
|
||||
@@ -20,9 +20,7 @@
|
||||
}"
|
||||
>
|
||||
{{
|
||||
backupServer
|
||||
? "A backup will be created before proceeding with the reinstallation, then all data will be erased from your server. Are you sure you want to continue?"
|
||||
: "This will reinstall your server and erase all data. Are you sure you want to continue?"
|
||||
"This will reinstall your server and erase all data. Are you sure you want to continue?"
|
||||
}}
|
||||
</p>
|
||||
<div v-if="!isSecondPhase" class="flex flex-col gap-4">
|
||||
@@ -131,41 +129,28 @@
|
||||
Removes all data on your server, including your worlds, mods, and configuration files,
|
||||
then reinstalls it with the selected version.
|
||||
</div>
|
||||
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="flex w-full flex-row items-center justify-between">
|
||||
<label class="w-full text-lg font-bold text-contrast" for="backup-server">
|
||||
Backup server
|
||||
</label>
|
||||
<input
|
||||
id="backup-server"
|
||||
v-model="backupServer"
|
||||
class="switch stylized-toggle shrink-0"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
Creates a backup of your server before proceeding with the installation or
|
||||
reinstallation.
|
||||
</div>
|
||||
</div>
|
||||
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
||||
<button :disabled="canInstall" @click="handleReinstall">
|
||||
<button
|
||||
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||
:disabled="canInstall || !!backupInProgress"
|
||||
@click="handleReinstall"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
{{
|
||||
isBackingUp
|
||||
? "Backing up..."
|
||||
: isLoading
|
||||
? "Installing..."
|
||||
: isSecondPhase
|
||||
? "Erase and install"
|
||||
: hardReset
|
||||
? "Continue"
|
||||
: "Install"
|
||||
isLoading
|
||||
? "Installing..."
|
||||
: isSecondPhase
|
||||
? "Erase and install"
|
||||
: hardReset
|
||||
? "Continue"
|
||||
: "Install"
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -192,10 +177,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { RightArrowIcon, XIcon, ServerIcon, DropdownIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import type { Loaders } from "~/types/servers";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
interface LoaderVersion {
|
||||
id: string;
|
||||
@@ -213,6 +201,7 @@ type VersionCache = Record<string, any>;
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
currentLoader: Loaders | undefined;
|
||||
backupInProgress?: BackupInProgressReason;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -222,9 +211,7 @@ const emit = defineEmits<{
|
||||
const versionSelectModal = ref();
|
||||
const isSecondPhase = ref(false);
|
||||
const hardReset = ref(false);
|
||||
const backupServer = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const isBackingUp = ref(false);
|
||||
const loadingServerCheck = ref(false);
|
||||
const serverCheckError = ref("");
|
||||
|
||||
@@ -413,69 +400,12 @@ const canInstall = computed(() => {
|
||||
return conds || !selectedLoaderVersion.value;
|
||||
});
|
||||
|
||||
const performBackup = async (): Promise<boolean> => {
|
||||
try {
|
||||
const date = new Date();
|
||||
const format = date.toLocaleString(navigator.language || "en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
timeZoneName: "short",
|
||||
});
|
||||
const backupName = `Reinstallation - ${format}`;
|
||||
isLoading.value = true;
|
||||
const backupId = await props.server.backups?.create(backupName);
|
||||
isBackingUp.value = true;
|
||||
let attempts = 0;
|
||||
while (true) {
|
||||
attempts++;
|
||||
if (attempts > 100) {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Backup Failed",
|
||||
text: "An unexpected error occurred while backing up. Please try again later.",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
await props.server.refresh(["backups"]);
|
||||
const backups = await props.server.backups?.data;
|
||||
const backup = backupId ? backups?.find((x) => x.id === backupId) : undefined;
|
||||
if (backup && !backup.ongoing) {
|
||||
isBackingUp.value = false;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Backup Failed",
|
||||
text: "An unexpected error occurred while backing up. Please try again later.",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReinstall = async () => {
|
||||
if (hardReset.value && !isSecondPhase.value) {
|
||||
isSecondPhase.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (backupServer.value) {
|
||||
isBackingUp.value = true;
|
||||
if (!(await performBackup())) {
|
||||
isBackingUp.value = false;
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
isBackingUp.value = false;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
@@ -522,7 +452,6 @@ const onShow = () => {
|
||||
|
||||
const onHide = () => {
|
||||
hardReset.value = false;
|
||||
backupServer.value = false;
|
||||
isSecondPhase.value = false;
|
||||
serverCheckError.value = "";
|
||||
loadingServerCheck.value = false;
|
||||
|
||||
@@ -21,7 +21,12 @@
|
||||
</div>
|
||||
|
||||
<div class="h-full w-full">
|
||||
<NuxtPage :route="props.route" :server="props.server" @reinstall="onReinstall" />
|
||||
<NuxtPage
|
||||
:route="route"
|
||||
:server="server"
|
||||
:backup-in-progress="backupInProgress"
|
||||
@reinstall="onReinstall"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -30,13 +35,15 @@
|
||||
import { RightArrowIcon } from "@modrinth/assets";
|
||||
import type { RouteLocationNormalized } from "vue-router";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
|
||||
const emit = defineEmits(["reinstall"]);
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
|
||||
route: RouteLocationNormalized;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
backupInProgress?: BackupInProgressReason;
|
||||
}>();
|
||||
|
||||
const onReinstall = (...args: any[]) => {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { RightArrowIcon, SparklesIcon, UnknownIcon } from "@modrinth/assets";
|
||||
import type { MessageDescriptor } from "@vintl/vintl";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "select" | "scroll-to-faq"): void;
|
||||
}>();
|
||||
|
||||
type Plan = "small" | "medium" | "large";
|
||||
|
||||
const plans: Record<
|
||||
Plan,
|
||||
{
|
||||
buttonColor: "blue" | "green" | "purple";
|
||||
accentText: string;
|
||||
accentBg: string;
|
||||
name: MessageDescriptor;
|
||||
symbol: MessageDescriptor;
|
||||
description: MessageDescriptor;
|
||||
}
|
||||
> = {
|
||||
small: {
|
||||
buttonColor: "blue",
|
||||
accentText: "text-blue",
|
||||
accentBg: "bg-bg-blue",
|
||||
name: defineMessage({
|
||||
id: "servers.plan.small.name",
|
||||
defaultMessage: "Small",
|
||||
}),
|
||||
symbol: defineMessage({
|
||||
id: "servers.plan.small.symbol",
|
||||
defaultMessage: "S",
|
||||
}),
|
||||
description: defineMessage({
|
||||
id: "servers.plan.small.description",
|
||||
defaultMessage:
|
||||
"Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding.",
|
||||
}),
|
||||
},
|
||||
medium: {
|
||||
buttonColor: "green",
|
||||
accentText: "text-green",
|
||||
accentBg: "bg-bg-green",
|
||||
name: defineMessage({
|
||||
id: "servers.plan.medium.name",
|
||||
defaultMessage: "Medium",
|
||||
}),
|
||||
symbol: defineMessage({
|
||||
id: "servers.plan.medium.symbol",
|
||||
defaultMessage: "M",
|
||||
}),
|
||||
description: defineMessage({
|
||||
id: "servers.plan.medium.description",
|
||||
defaultMessage: "Great for modded multiplayer and small communities.",
|
||||
}),
|
||||
},
|
||||
large: {
|
||||
buttonColor: "purple",
|
||||
accentText: "text-purple",
|
||||
accentBg: "bg-bg-purple",
|
||||
name: defineMessage({
|
||||
id: "servers.plan.large.name",
|
||||
defaultMessage: "Large",
|
||||
}),
|
||||
symbol: defineMessage({
|
||||
id: "servers.plan.large.symbol",
|
||||
defaultMessage: "L",
|
||||
}),
|
||||
description: defineMessage({
|
||||
id: "servers.plan.large.description",
|
||||
defaultMessage: "Ideal for larger communities, modpacks, and heavy modding.",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
capacity?: number;
|
||||
plan: Plan;
|
||||
ram: number;
|
||||
storage: number;
|
||||
cpus: number;
|
||||
price: number;
|
||||
}>();
|
||||
|
||||
const outOfStock = computed(() => {
|
||||
return !props.capacity || props.capacity === 0;
|
||||
});
|
||||
|
||||
const lowStock = computed(() => {
|
||||
return !props.capacity || props.capacity < 8;
|
||||
});
|
||||
|
||||
const formattedRam = computed(() => {
|
||||
return props.ram / 1024;
|
||||
});
|
||||
|
||||
const formattedStorage = computed(() => {
|
||||
return props.storage / 1024;
|
||||
});
|
||||
|
||||
const sharedCpus = computed(() => {
|
||||
return props.cpus / 2;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
|
||||
<div
|
||||
v-if="lowStock"
|
||||
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl p-4 text-center font-bold"
|
||||
:class="outOfStock ? 'bg-bg-red' : 'bg-bg-orange'"
|
||||
>
|
||||
<template v-if="outOfStock"> Out of stock! </template>
|
||||
<template v-else> Only {{ capacity }} left in stock! </template>
|
||||
</div>
|
||||
<div
|
||||
:style="
|
||||
plan === 'medium'
|
||||
? {
|
||||
background: `radial-gradient(
|
||||
86.12% 101.64% at 95.97% 94.07%,
|
||||
rgba(27, 217, 106, 0.23) 0%,
|
||||
rgba(14, 115, 56, 0.2) 100%
|
||||
)`,
|
||||
border: `1px solid rgba(12, 107, 52, 0.55)`,
|
||||
'box-shadow': `0px 12px 38.1px rgba(27, 217, 106, 0.13)`,
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
class="flex w-full flex-col justify-between gap-4 rounded-2xl bg-bg p-8 text-left"
|
||||
:class="{ '!rounded-t-none': lowStock }"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<h1 class="m-0">{{ formatMessage(plans[plan].name) }}</h1>
|
||||
<div
|
||||
class="grid size-8 place-content-center rounded-full text-xs font-bold"
|
||||
:class="`${plans[plan].accentBg} ${plans[plan].accentText}`"
|
||||
>
|
||||
{{ formatMessage(plans[plan].symbol) }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0">{{ formatMessage(plans[plan].description) }}</p>
|
||||
<div
|
||||
class="flex flex-row flex-wrap items-center gap-2 text-nowrap text-secondary xl:justify-between"
|
||||
>
|
||||
<p class="m-0">{{ formattedRam }} GB RAM</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">{{ formattedStorage }} GB SSD</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">{{ sharedCpus }} Shared CPUs</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-secondary">
|
||||
<SparklesIcon /> Bursts up to {{ cpus }} CPUs
|
||||
<nuxt-link
|
||||
v-tooltip="
|
||||
`CPU bursting allows your server to temporarily use additional threads to help mitigate TPS spikes. Click for more info.`
|
||||
"
|
||||
to="/servers#cpu-burst"
|
||||
@click="() => emit('scroll-to-faq')"
|
||||
>
|
||||
<UnknownIcon class="h-4 w-4 text-secondary opacity-80" />
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<span class="m-0 text-2xl font-bold text-contrast">
|
||||
${{ price / 100 }}<span class="text-lg font-semibold text-secondary">/month</span>
|
||||
</span>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
:color="plans[plan].buttonColor"
|
||||
:type="plan === 'medium' ? 'standard' : 'highlight-colored-text'"
|
||||
size="large"
|
||||
>
|
||||
<span v-if="outOfStock" class="button-like disabled"> Out of Stock </span>
|
||||
<button v-else @click="() => emit('select')">
|
||||
Get Started
|
||||
<RightArrowIcon class="shrink-0" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,202 @@
|
||||
<script setup lang="ts">
|
||||
import { Accordion, ButtonStyled, NewModal, ServerNotice, TagItem } from "@modrinth/ui";
|
||||
import { PlusIcon, XIcon } from "@modrinth/assets";
|
||||
import type { ServerNotice as ServerNoticeType } from "@modrinth/utils";
|
||||
import { ref } from "vue";
|
||||
import { usePyroFetch } from "~/composables/pyroFetch.ts";
|
||||
|
||||
const app = useNuxtApp() as unknown as { $notify: any };
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
}>();
|
||||
|
||||
const notice = ref<ServerNoticeType>();
|
||||
|
||||
const assigned = ref<ServerNoticeType["assigned"]>([]);
|
||||
|
||||
const assignedServers = computed(() => assigned.value.filter((n) => n.kind === "server") ?? []);
|
||||
const assignedNodes = computed(() => assigned.value.filter((n) => n.kind === "node") ?? []);
|
||||
|
||||
const inputField = ref("");
|
||||
|
||||
async function refresh() {
|
||||
await usePyroFetch("notices").then((res) => {
|
||||
const notices = res as ServerNoticeType[];
|
||||
assigned.value = notices.find((n) => n.id === notice.value?.id)?.assigned ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
async function assign(server: boolean = true) {
|
||||
const input = inputField.value.trim();
|
||||
|
||||
if (input !== "" && notice.value) {
|
||||
await usePyroFetch(`notices/${notice.value.id}/assign?${server ? "server" : "node"}=${input}`, {
|
||||
method: "PUT",
|
||||
}).catch((err) => {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "Error assigning notice",
|
||||
text: err,
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
} else {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "Error assigning notice",
|
||||
text: "No server or node specified",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function unassignDetect() {
|
||||
const input = inputField.value.trim();
|
||||
|
||||
const server = assignedServers.value.some((assigned) => assigned.id === input);
|
||||
const node = assignedNodes.value.some((assigned) => assigned.id === input);
|
||||
|
||||
if (!server && !node) {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "Error unassigning notice",
|
||||
text: "ID is not an assigned server or node",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await unassign(input, server);
|
||||
}
|
||||
|
||||
async function unassign(id: string, server: boolean = true) {
|
||||
if (notice.value) {
|
||||
await usePyroFetch(`notices/${notice.value.id}/unassign?${server ? "server" : "node"}=${id}`, {
|
||||
method: "PUT",
|
||||
}).catch((err) => {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "Error unassigning notice",
|
||||
text: err,
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function show(currentNotice: ServerNoticeType) {
|
||||
notice.value = currentNotice;
|
||||
assigned.value = currentNotice?.assigned ?? [];
|
||||
modal.value?.show();
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide();
|
||||
}
|
||||
|
||||
defineExpose({ show, hide });
|
||||
</script>
|
||||
<template>
|
||||
<NewModal ref="modal" :on-hide="() => emit('close')">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">
|
||||
Editing assignments of notice #{{ notice?.id }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<ServerNotice
|
||||
v-if="notice"
|
||||
:level="notice.level"
|
||||
:message="notice.message"
|
||||
:dismissable="notice.dismissable"
|
||||
:title="notice.title"
|
||||
preview
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="server-assign-field" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Assigned servers </span>
|
||||
</label>
|
||||
<Accordion
|
||||
v-if="assignedServers.length > 0"
|
||||
class="mb-2"
|
||||
open-by-default
|
||||
button-class="text-primary m-0 p-0 border-none bg-transparent active:scale-95"
|
||||
>
|
||||
<template #title> {{ assignedServers.length }} servers </template>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<TagItem
|
||||
v-for="server in assignedServers"
|
||||
:key="`server-${server.id}`"
|
||||
:action="() => unassign(server.id, true)"
|
||||
>
|
||||
<XIcon />
|
||||
{{ server.id }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</Accordion>
|
||||
<span v-else class="mb-2"> No servers assigned yet </span>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="server-assign-field" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Assigned nodes </span>
|
||||
</label>
|
||||
<Accordion
|
||||
v-if="assignedNodes.length > 0"
|
||||
class="mb-2"
|
||||
open-by-default
|
||||
button-class="text-primary m-0 p-0 border-none bg-transparent active:scale-95"
|
||||
>
|
||||
<template #title> {{ assignedNodes.length }} nodes </template>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<TagItem
|
||||
v-for="node in assignedNodes"
|
||||
:key="`node-${node.id}`"
|
||||
:action="
|
||||
() => {
|
||||
unassign(node.id, false);
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
{{ node.id }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</Accordion>
|
||||
<span v-else class="mb-2"> No nodes assigned yet </span>
|
||||
</div>
|
||||
<div class="flex w-[45rem] items-center gap-2">
|
||||
<input
|
||||
id="server-assign-field"
|
||||
v-model="inputField"
|
||||
class="w-full"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<ButtonStyled color="green" color-fill="text">
|
||||
<button class="shrink-0" @click="() => assign(true)">
|
||||
<PlusIcon />
|
||||
Add server
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="blue" color-fill="text">
|
||||
<button class="shrink-0" @click="() => assign(false)">
|
||||
<PlusIcon />
|
||||
Add node
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red" color-fill="text">
|
||||
<button class="shrink-0" @click="() => unassignDetect()">
|
||||
<XIcon />
|
||||
Remove
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from "dayjs";
|
||||
import { ButtonStyled, commonMessages, CopyCode, ServerNotice, TagItem } from "@modrinth/ui";
|
||||
import { EditIcon, SettingsIcon, TrashIcon } from "@modrinth/assets";
|
||||
import { ServerNotice as ServerNoticeType } from "@modrinth/utils";
|
||||
import {
|
||||
DISMISSABLE,
|
||||
getDismissableMetadata,
|
||||
NOTICE_LEVELS,
|
||||
} from "@modrinth/ui/src/utils/notices.ts";
|
||||
import { useVIntl } from "@vintl/vintl";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
const props = defineProps<{
|
||||
notice: ServerNoticeType;
|
||||
}>();
|
||||
</script>
|
||||
<template>
|
||||
<div class="col-span-full grid grid-cols-subgrid gap-4 rounded-2xl bg-bg-raised p-4">
|
||||
<div class="col-span-full grid grid-cols-subgrid items-center gap-4">
|
||||
<div>
|
||||
<CopyCode :text="`${notice.id}`" />
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span v-if="notice.announce_at">
|
||||
{{ dayjs(notice.announce_at).format("MMM D, YYYY [at] h:mm A") }} ({{
|
||||
dayjs(notice.announce_at).fromNow()
|
||||
}})
|
||||
</span>
|
||||
<template v-else> Never begins </template>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span
|
||||
v-if="notice.expires"
|
||||
v-tooltip="dayjs(notice.expires).format('MMMM D, YYYY [at] h:mm A')"
|
||||
>
|
||||
{{ dayjs(notice.expires).fromNow() }}
|
||||
</span>
|
||||
<template v-else> Never expires </template>
|
||||
</div>
|
||||
<div
|
||||
:style="
|
||||
NOTICE_LEVELS[notice.level]
|
||||
? {
|
||||
'--_color': NOTICE_LEVELS[notice.level].colors.text,
|
||||
'--_bg-color': NOTICE_LEVELS[notice.level].colors.bg,
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<TagItem>
|
||||
{{
|
||||
NOTICE_LEVELS[notice.level]
|
||||
? formatMessage(NOTICE_LEVELS[notice.level].name)
|
||||
: notice.level
|
||||
}}
|
||||
</TagItem>
|
||||
</div>
|
||||
<div
|
||||
:style="{
|
||||
'--_color': getDismissableMetadata(notice.dismissable).colors.text,
|
||||
'--_bg-color': getDismissableMetadata(notice.dismissable).colors.bg,
|
||||
}"
|
||||
>
|
||||
<TagItem>
|
||||
{{ formatMessage(getDismissableMetadata(notice.dismissable).name) }}
|
||||
</TagItem>
|
||||
</div>
|
||||
<div class="col-span-2 flex gap-2 md:col-span-1">
|
||||
<ButtonStyled>
|
||||
<button @click="() => startEditing(notice)">
|
||||
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="() => deleteNotice(notice)">
|
||||
<TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-full grid">
|
||||
<ServerNotice
|
||||
:level="notice.level"
|
||||
:message="notice.message"
|
||||
:dismissable="notice.dismissable"
|
||||
:title="notice.title"
|
||||
preview
|
||||
/>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<span v-if="!notice.assigned || notice.assigned.length === 0"
|
||||
>Not assigned to any servers</span
|
||||
>
|
||||
<span v-else-if="!notice.assigned.some((n) => n.kind === 'server')">
|
||||
Assigned to
|
||||
{{ notice.assigned.filter((n) => n.kind === "node").length }} nodes
|
||||
</span>
|
||||
<span v-else-if="!notice.assigned.some((n) => n.kind === 'node')">
|
||||
Assigned to
|
||||
{{ notice.assigned.filter((n) => n.kind === "server").length }} servers
|
||||
</span>
|
||||
<span v-else>
|
||||
Assigned to
|
||||
{{ notice.assigned.filter((n) => n.kind === "server").length }} servers and
|
||||
{{ notice.assigned.filter((n) => n.kind === "node").length }} nodes
|
||||
</span>
|
||||
•
|
||||
<button
|
||||
class="m-0 flex items-center gap-1 border-none bg-transparent p-0 text-blue hover:underline hover:brightness-125 active:scale-95 active:brightness-150"
|
||||
@click="() => startEditing(notice, true)"
|
||||
>
|
||||
<SettingsIcon />
|
||||
Edit assignments
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -30,6 +30,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
newProjectCards: false,
|
||||
projectBackground: false,
|
||||
searchBackground: false,
|
||||
advancedDebugInfo: false,
|
||||
// advancedRendering: true,
|
||||
// externalLinksNewTab: true,
|
||||
// notUsingBlockers: false,
|
||||
|
||||
@@ -10,6 +10,7 @@ interface PyroFetchOptions {
|
||||
token?: string;
|
||||
};
|
||||
retry?: boolean;
|
||||
bypassAuth?: boolean;
|
||||
}
|
||||
|
||||
export class PyroFetchError extends Error {
|
||||
@@ -28,7 +29,7 @@ export async function usePyroFetch<T>(path: string, options: PyroFetchOptions =
|
||||
const auth = await useAuth();
|
||||
const authToken = auth.value?.token;
|
||||
|
||||
if (!authToken) {
|
||||
if (!authToken && !options.bypassAuth) {
|
||||
throw new PyroFetchError("Cannot pyrofetch without auth", 10000);
|
||||
}
|
||||
|
||||
@@ -52,9 +53,15 @@ export async function usePyroFetch<T>(path: string, options: PyroFetchOptions =
|
||||
|
||||
type HeadersRecord = Record<string, string>;
|
||||
|
||||
const authHeader: HeadersRecord = options.bypassAuth
|
||||
? {}
|
||||
: {
|
||||
Authorization: `Bearer ${override?.token ?? authToken}`,
|
||||
"Access-Control-Allow-Headers": "Authorization",
|
||||
};
|
||||
|
||||
const headers: HeadersRecord = {
|
||||
Authorization: `Bearer ${override?.token ?? authToken}`,
|
||||
"Access-Control-Allow-Headers": "Authorization",
|
||||
...authHeader,
|
||||
"User-Agent": "Pyro/1.0 (https://pyro.host)",
|
||||
Vary: "Accept, Origin",
|
||||
"Content-Type": contentType,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// usePyroServer is a composable that interfaces with the REDACTED API to get data and control the users server
|
||||
import { $fetch, FetchError } from "ofetch";
|
||||
import type { ServerNotice } from "@modrinth/utils";
|
||||
import type { WSBackupState, WSBackupTask } from "~/types/servers.ts";
|
||||
|
||||
interface PyroFetchOptions {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
@@ -192,7 +194,7 @@ async function PyroFetch<T>(
|
||||
throw lastError || new Error("Maximum retry attempts reached");
|
||||
}
|
||||
|
||||
const internalServerRefrence = ref<any>(null);
|
||||
const internalServerReference = ref<any>(null);
|
||||
|
||||
interface License {
|
||||
id: string;
|
||||
@@ -289,6 +291,11 @@ interface General {
|
||||
sftp_password: string;
|
||||
sftp_host: string;
|
||||
datacenter?: string;
|
||||
notices?: ServerNotice[];
|
||||
node: {
|
||||
token: string;
|
||||
instance: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Allocation {
|
||||
@@ -315,12 +322,20 @@ export interface Mod {
|
||||
installing: boolean;
|
||||
}
|
||||
|
||||
interface Backup {
|
||||
export interface Backup {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
ongoing: boolean;
|
||||
locked: boolean;
|
||||
automated: boolean;
|
||||
interrupted: boolean;
|
||||
ongoing: boolean;
|
||||
task: {
|
||||
[K in WSBackupTask]?: {
|
||||
progress: number;
|
||||
state: WSBackupState;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface AutoBackupSettings {
|
||||
@@ -368,7 +383,7 @@ const constructServerProperties = (properties: any): string => {
|
||||
|
||||
const processImage = async (iconUrl: string | undefined) => {
|
||||
const sharedImage = useState<string | undefined>(
|
||||
`server-icon-${internalServerRefrence.value.serverId}`,
|
||||
`server-icon-${internalServerReference.value.serverId}`,
|
||||
);
|
||||
|
||||
if (sharedImage.value) {
|
||||
@@ -376,7 +391,7 @@ const processImage = async (iconUrl: string | undefined) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerRefrence.value.serverId}/fs`);
|
||||
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerReference.value.serverId}/fs`);
|
||||
try {
|
||||
const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, {
|
||||
override: auth,
|
||||
@@ -463,13 +478,13 @@ const processImage = async (iconUrl: string | undefined) => {
|
||||
|
||||
const sendPowerAction = async (action: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/power`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/power`, {
|
||||
method: "POST",
|
||||
body: { action },
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await internalServerRefrence.value.refresh();
|
||||
await internalServerReference.value.refresh();
|
||||
} catch (error) {
|
||||
console.error("Error changing power state:", error);
|
||||
throw error;
|
||||
@@ -478,7 +493,7 @@ const sendPowerAction = async (action: string) => {
|
||||
|
||||
const updateName = async (newName: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/name`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/name`, {
|
||||
method: "POST",
|
||||
body: { name: newName },
|
||||
});
|
||||
@@ -522,7 +537,7 @@ const reinstallFromMrpack = async (mrpack: File, hardReset: boolean = false) =>
|
||||
const hardResetParam = hardReset ? "true" : "false";
|
||||
try {
|
||||
const auth = await PyroFetch<JWTAuth>(
|
||||
`servers/${internalServerRefrence.value.serverId}/reinstallFromMrpack`,
|
||||
`servers/${internalServerReference.value.serverId}/reinstallFromMrpack`,
|
||||
);
|
||||
|
||||
const formData = new FormData();
|
||||
@@ -551,7 +566,7 @@ const reinstallFromMrpack = async (mrpack: File, hardReset: boolean = false) =>
|
||||
|
||||
const suspendServer = async (status: boolean) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/suspend`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/suspend`, {
|
||||
method: "POST",
|
||||
body: { suspended: status },
|
||||
});
|
||||
@@ -563,7 +578,7 @@ const suspendServer = async (status: boolean) => {
|
||||
|
||||
const fetchConfigFile = async (fileName: string) => {
|
||||
try {
|
||||
return await PyroFetch(`servers/${internalServerRefrence.value.serverId}/config/${fileName}`);
|
||||
return await PyroFetch(`servers/${internalServerReference.value.serverId}/config/${fileName}`);
|
||||
} catch (error) {
|
||||
console.error("Error fetching config file:", error);
|
||||
throw error;
|
||||
@@ -594,7 +609,7 @@ const setMotd = async (motd: string) => {
|
||||
const newProps = constructServerProperties(props);
|
||||
const octetStream = new Blob([newProps], { type: "application/octet-stream" });
|
||||
const auth = await await PyroFetch<JWTAuth>(
|
||||
`servers/${internalServerRefrence.value.serverId}/fs`,
|
||||
`servers/${internalServerReference.value.serverId}/fs`,
|
||||
);
|
||||
|
||||
return await PyroFetch(`/update?path=/server.properties`, {
|
||||
@@ -613,7 +628,7 @@ const setMotd = async (motd: string) => {
|
||||
|
||||
const installContent = async (contentType: ContentType, projectId: string, versionId: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/mods`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
rinth_ids: { project_id: projectId, version_id: versionId },
|
||||
@@ -628,7 +643,7 @@ const installContent = async (contentType: ContentType, projectId: string, versi
|
||||
|
||||
const removeContent = async (path: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/deleteMod`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/deleteMod`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
path,
|
||||
@@ -642,7 +657,7 @@ const removeContent = async (path: string) => {
|
||||
|
||||
const reinstallContent = async (replace: string, projectId: string, versionId: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods/update`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/mods/update`, {
|
||||
method: "POST",
|
||||
body: { replace, project_id: projectId, version_id: versionId },
|
||||
});
|
||||
@@ -657,13 +672,13 @@ const reinstallContent = async (replace: string, projectId: string, versionId: s
|
||||
const createBackup = async (backupName: string) => {
|
||||
try {
|
||||
const response = await PyroFetch<{ id: string }>(
|
||||
`servers/${internalServerRefrence.value.serverId}/backups`,
|
||||
`servers/${internalServerReference.value.serverId}/backups`,
|
||||
{
|
||||
method: "POST",
|
||||
body: { name: backupName },
|
||||
},
|
||||
);
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
await internalServerReference.value.refresh(["backups"]);
|
||||
return response.id;
|
||||
} catch (error) {
|
||||
console.error("Error creating backup:", error);
|
||||
@@ -673,11 +688,14 @@ const createBackup = async (backupName: string) => {
|
||||
|
||||
const renameBackup = async (backupId: string, newName: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/rename`, {
|
||||
method: "POST",
|
||||
body: { name: newName },
|
||||
});
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
await PyroFetch(
|
||||
`servers/${internalServerReference.value.serverId}/backups/${backupId}/rename`,
|
||||
{
|
||||
method: "POST",
|
||||
body: { name: newName },
|
||||
},
|
||||
);
|
||||
await internalServerReference.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error renaming backup:", error);
|
||||
throw error;
|
||||
@@ -686,10 +704,10 @@ const renameBackup = async (backupId: string, newName: string) => {
|
||||
|
||||
const deleteBackup = async (backupId: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/backups/${backupId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
await internalServerReference.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error deleting backup:", error);
|
||||
throw error;
|
||||
@@ -699,30 +717,35 @@ const deleteBackup = async (backupId: string) => {
|
||||
const restoreBackup = async (backupId: string) => {
|
||||
try {
|
||||
await PyroFetch(
|
||||
`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/restore`,
|
||||
`servers/${internalServerReference.value.serverId}/backups/${backupId}/restore`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
await internalServerReference.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error restoring backup:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadBackup = async (backupId: string) => {
|
||||
const prepareBackup = async (backupId: string) => {
|
||||
try {
|
||||
return await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}`);
|
||||
await PyroFetch(
|
||||
`servers/${internalServerReference.value.serverId}/backups/${backupId}/prepare-download`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error downloading backup:", error);
|
||||
console.error("Error preparing backup:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const updateAutoBackup = async (autoBackup: "enable" | "disable", interval: number) => {
|
||||
try {
|
||||
return await PyroFetch(`servers/${internalServerRefrence.value.serverId}/autobackup`, {
|
||||
return await PyroFetch(`servers/${internalServerReference.value.serverId}/autobackup`, {
|
||||
method: "POST",
|
||||
body: { set: autoBackup, interval },
|
||||
});
|
||||
@@ -734,7 +757,7 @@ const updateAutoBackup = async (autoBackup: "enable" | "disable", interval: numb
|
||||
|
||||
const getAutoBackup = async () => {
|
||||
try {
|
||||
return await PyroFetch(`servers/${internalServerRefrence.value.serverId}/autobackup`);
|
||||
return await PyroFetch(`servers/${internalServerReference.value.serverId}/autobackup`);
|
||||
} catch (error) {
|
||||
console.error("Error getting auto backup settings:", error);
|
||||
throw error;
|
||||
@@ -743,10 +766,10 @@ const getAutoBackup = async () => {
|
||||
|
||||
const lockBackup = async (backupId: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/backups/${backupId}/lock`, {
|
||||
method: "POST",
|
||||
});
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
await internalServerReference.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error locking backup:", error);
|
||||
throw error;
|
||||
@@ -755,22 +778,36 @@ const lockBackup = async (backupId: string) => {
|
||||
|
||||
const unlockBackup = async (backupId: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`, {
|
||||
method: "POST",
|
||||
});
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
await PyroFetch(
|
||||
`servers/${internalServerReference.value.serverId}/backups/${backupId}/unlock`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
await internalServerReference.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error unlocking backup:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const retryBackup = async (backupId: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/backups/${backupId}/retry`, {
|
||||
method: "POST",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error retrying backup:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// ------------------ NETWORK ------------------ //
|
||||
|
||||
const reserveAllocation = async (name: string): Promise<Allocation> => {
|
||||
try {
|
||||
return await PyroFetch<Allocation>(
|
||||
`servers/${internalServerRefrence.value.serverId}/allocations?name=${name}`,
|
||||
`servers/${internalServerReference.value.serverId}/allocations?name=${name}`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
@@ -784,7 +821,7 @@ const reserveAllocation = async (name: string): Promise<Allocation> => {
|
||||
const updateAllocation = async (port: number, name: string) => {
|
||||
try {
|
||||
await PyroFetch(
|
||||
`servers/${internalServerRefrence.value.serverId}/allocations/${port}?name=${name}`,
|
||||
`servers/${internalServerReference.value.serverId}/allocations/${port}?name=${name}`,
|
||||
{
|
||||
method: "PUT",
|
||||
},
|
||||
@@ -797,7 +834,7 @@ const updateAllocation = async (port: number, name: string) => {
|
||||
|
||||
const deleteAllocation = async (port: number) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/allocations/${port}`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/allocations/${port}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -817,7 +854,7 @@ const checkSubdomainAvailability = async (subdomain: string): Promise<{ availabl
|
||||
|
||||
const changeSubdomain = async (subdomain: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/subdomain`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/subdomain`, {
|
||||
method: "POST",
|
||||
body: { subdomain },
|
||||
});
|
||||
@@ -835,7 +872,7 @@ const updateStartupSettings = async (
|
||||
jdkBuild: "corretto" | "temurin" | "graal",
|
||||
) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/startup`, {
|
||||
await PyroFetch(`servers/${internalServerReference.value.serverId}/startup`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
invocation: invocation || null,
|
||||
@@ -856,7 +893,7 @@ const retryWithAuth = async (requestFn: () => Promise<any>) => {
|
||||
return await requestFn();
|
||||
} catch (error) {
|
||||
if (error instanceof PyroServersFetchError && error.statusCode === 401) {
|
||||
await internalServerRefrence.value.refresh(["fs"]);
|
||||
await internalServerReference.value.refresh(["fs"]);
|
||||
return await requestFn();
|
||||
}
|
||||
|
||||
@@ -868,7 +905,7 @@ const listDirContents = (path: string, page: number, pageSize: number) => {
|
||||
return retryWithAuth(async () => {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
return await PyroFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, {
|
||||
override: internalServerRefrence.value.fs.auth,
|
||||
override: internalServerReference.value.fs.auth,
|
||||
retry: false,
|
||||
});
|
||||
});
|
||||
@@ -880,7 +917,7 @@ const createFileOrFolder = (path: string, type: "file" | "directory") => {
|
||||
return await PyroFetch(`/create?path=${encodedPath}&type=${type}`, {
|
||||
method: "POST",
|
||||
contentType: "application/octet-stream",
|
||||
override: internalServerRefrence.value.fs.auth,
|
||||
override: internalServerReference.value.fs.auth,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -923,9 +960,12 @@ const uploadFile = (path: string, file: File) => {
|
||||
|
||||
xhr.open(
|
||||
"POST",
|
||||
`https://${internalServerRefrence.value.fs.auth.url}/create?path=${encodedPath}&type=file`,
|
||||
`https://${internalServerReference.value.fs.auth.url}/create?path=${encodedPath}&type=file`,
|
||||
);
|
||||
xhr.setRequestHeader(
|
||||
"Authorization",
|
||||
`Bearer ${internalServerReference.value.fs.auth.token}`,
|
||||
);
|
||||
xhr.setRequestHeader("Authorization", `Bearer ${internalServerRefrence.value.fs.auth.token}`);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
xhr.send(file);
|
||||
|
||||
@@ -955,7 +995,7 @@ const renameFileOrFolder = (path: string, name: string) => {
|
||||
return retryWithAuth(async () => {
|
||||
await PyroFetch(`/move`, {
|
||||
method: "POST",
|
||||
override: internalServerRefrence.value.fs.auth,
|
||||
override: internalServerReference.value.fs.auth,
|
||||
body: {
|
||||
source: path,
|
||||
destination: pathName,
|
||||
@@ -972,7 +1012,7 @@ const updateFile = (path: string, content: string) => {
|
||||
method: "PUT",
|
||||
contentType: "application/octet-stream",
|
||||
body: octetStream,
|
||||
override: internalServerRefrence.value.fs.auth,
|
||||
override: internalServerReference.value.fs.auth,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1002,7 +1042,7 @@ const moveFileOrFolder = (path: string, newPath: string) => {
|
||||
|
||||
return await PyroFetch(`/move`, {
|
||||
method: "POST",
|
||||
override: internalServerRefrence.value.fs.auth,
|
||||
override: internalServerReference.value.fs.auth,
|
||||
body: {
|
||||
source: path,
|
||||
destination: newPath,
|
||||
@@ -1016,7 +1056,7 @@ const deleteFileOrFolder = (path: string, recursive: boolean) => {
|
||||
return retryWithAuth(async () => {
|
||||
return await PyroFetch(`/delete?path=${encodedPath}&recursive=${recursive}`, {
|
||||
method: "DELETE",
|
||||
override: internalServerRefrence.value.fs.auth,
|
||||
override: internalServerReference.value.fs.auth,
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1025,7 +1065,7 @@ const downloadFile = (path: string, raw?: boolean) => {
|
||||
return retryWithAuth(async () => {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const fileData = await PyroFetch(`/download?path=${encodedPath}`, {
|
||||
override: internalServerRefrence.value.fs.auth,
|
||||
override: internalServerReference.value.fs.auth,
|
||||
});
|
||||
|
||||
if (fileData instanceof Blob) {
|
||||
@@ -1137,11 +1177,12 @@ const modules: any = {
|
||||
rename: renameBackup,
|
||||
delete: deleteBackup,
|
||||
restore: restoreBackup,
|
||||
download: downloadBackup,
|
||||
prepare: prepareBackup,
|
||||
updateAutoBackup,
|
||||
getAutoBackup,
|
||||
lock: lockBackup,
|
||||
unlock: unlockBackup,
|
||||
retry: retryBackup,
|
||||
},
|
||||
network: {
|
||||
get: async (serverId: string) => {
|
||||
@@ -1382,6 +1423,12 @@ type BackupFunctions = {
|
||||
*/
|
||||
download: (backupId: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Prepare a backup for the server.
|
||||
* @param backupId - The ID of the backup.
|
||||
*/
|
||||
prepare: (backupId: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Updates the auto backup settings of the server.
|
||||
* @param autoBackup - Whether to enable auto backup.
|
||||
@@ -1405,6 +1452,12 @@ type BackupFunctions = {
|
||||
* @param backupId - The ID of the backup.
|
||||
*/
|
||||
unlock: (backupId: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Retries a failed backup for the server.
|
||||
* @param backupId - The ID of the backup.
|
||||
*/
|
||||
retry: (backupId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type NetworkFunctions = {
|
||||
@@ -1702,7 +1755,7 @@ export const usePyroServer = async (serverId: string, includedModules: avaliable
|
||||
server[module] = modules[module];
|
||||
});
|
||||
|
||||
internalServerRefrence.value = server;
|
||||
internalServerReference.value = server;
|
||||
await server.refresh(initialModules);
|
||||
|
||||
if (deferredModules.length > 0) {
|
||||
|
||||
@@ -298,6 +298,12 @@
|
||||
link: '/admin/user_email',
|
||||
shown: isAdmin(auth.user),
|
||||
},
|
||||
{
|
||||
id: 'servers-notices',
|
||||
color: 'primary',
|
||||
link: '/admin/servers/notices',
|
||||
shown: isAdmin(auth.user),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<ModrinthIcon aria-hidden="true" />
|
||||
@@ -305,6 +311,9 @@
|
||||
<template #review-projects> <ScaleIcon aria-hidden="true" /> Review projects </template>
|
||||
<template #review-reports> <ReportIcon aria-hidden="true" /> Reports </template>
|
||||
<template #user-lookup> <UserIcon aria-hidden="true" /> Lookup by email </template>
|
||||
<template #servers-notices>
|
||||
<IssuesIcon aria-hidden="true" /> Manage server notices
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
|
||||
@@ -959,6 +959,117 @@
|
||||
"search.filter.locked.server.sync": {
|
||||
"message": "Sync with server"
|
||||
},
|
||||
"servers.backup.create.in-progress.tooltip": {
|
||||
"message": "Backup creation in progress"
|
||||
},
|
||||
"servers.backup.restore.in-progress.tooltip": {
|
||||
"message": "Backup restore in progress"
|
||||
},
|
||||
"servers.backups.item.already-preparing": {
|
||||
"message": "Already preparing backup for download"
|
||||
},
|
||||
"servers.backups.item.automated": {
|
||||
"message": "Automated"
|
||||
},
|
||||
"servers.backups.item.creating-backup": {
|
||||
"message": "Creating backup..."
|
||||
},
|
||||
"servers.backups.item.failed-to-create-backup": {
|
||||
"message": "Failed to create backup"
|
||||
},
|
||||
"servers.backups.item.failed-to-prepare-backup": {
|
||||
"message": "Failed to prepare download"
|
||||
},
|
||||
"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.prepare-download-again": {
|
||||
"message": "Try preparing again"
|
||||
},
|
||||
"servers.backups.item.preparing-download": {
|
||||
"message": "Preparing download..."
|
||||
},
|
||||
"servers.backups.item.queued-for-backup": {
|
||||
"message": "Queued for backup"
|
||||
},
|
||||
"servers.backups.item.rename": {
|
||||
"message": "Rename"
|
||||
},
|
||||
"servers.backups.item.restore": {
|
||||
"message": "Restore"
|
||||
},
|
||||
"servers.backups.item.restoring-backup": {
|
||||
"message": "Restoring from backup..."
|
||||
},
|
||||
"servers.backups.item.retry": {
|
||||
"message": "Retry"
|
||||
},
|
||||
"servers.backups.item.unlock": {
|
||||
"message": "Unlock"
|
||||
},
|
||||
"servers.notice.actions": {
|
||||
"message": "Actions"
|
||||
},
|
||||
"servers.notice.begins": {
|
||||
"message": "Begins"
|
||||
},
|
||||
"servers.notice.dismissable": {
|
||||
"message": "Dismissable"
|
||||
},
|
||||
"servers.notice.expires": {
|
||||
"message": "Expires"
|
||||
},
|
||||
"servers.notice.id": {
|
||||
"message": "ID"
|
||||
},
|
||||
"servers.notice.level": {
|
||||
"message": "Level"
|
||||
},
|
||||
"servers.notice.undismissable": {
|
||||
"message": "Undismissable"
|
||||
},
|
||||
"servers.notices.create-notice": {
|
||||
"message": "Create notice"
|
||||
},
|
||||
"servers.notices.no-notices": {
|
||||
"message": "No notices"
|
||||
},
|
||||
"servers.plan.large.description": {
|
||||
"message": "Ideal for larger communities, modpacks, and heavy modding."
|
||||
},
|
||||
"servers.plan.large.name": {
|
||||
"message": "Large"
|
||||
},
|
||||
"servers.plan.large.symbol": {
|
||||
"message": "L"
|
||||
},
|
||||
"servers.plan.medium.description": {
|
||||
"message": "Great for modded multiplayer and small communities."
|
||||
},
|
||||
"servers.plan.medium.name": {
|
||||
"message": "Medium"
|
||||
},
|
||||
"servers.plan.medium.symbol": {
|
||||
"message": "M"
|
||||
},
|
||||
"servers.plan.small.description": {
|
||||
"message": "Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding."
|
||||
},
|
||||
"servers.plan.small.name": {
|
||||
"message": "Small"
|
||||
},
|
||||
"servers.plan.small.symbol": {
|
||||
"message": "S"
|
||||
},
|
||||
"settings.billing.modal.cancel.action": {
|
||||
"message": "Cancel subscription"
|
||||
},
|
||||
|
||||
@@ -1346,7 +1346,7 @@ async function setProcessing() {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
@@ -1389,7 +1389,7 @@ async function patchProject(resData, quiet = false) {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
@@ -1426,7 +1426,7 @@ async function patchIcon(icon) {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
|
||||
|
||||
@@ -621,6 +621,12 @@
|
||||
<h4>Version ID</h4>
|
||||
<CopyCode :text="version.id" />
|
||||
</div>
|
||||
<div v-if="!isEditing && flags.developerMode">
|
||||
<h4>Modrinth Maven</h4>
|
||||
<div class="maven-section">
|
||||
<CopyCode :text="`maven.modrinth:${project.id}:${version.id}`" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1136,7 +1142,7 @@ export default defineNuxtComponent({
|
||||
this.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
@@ -1545,6 +1551,12 @@ export default defineNuxtComponent({
|
||||
margin: 1rem 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.maven-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.team-member {
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
|
||||
@@ -106,6 +106,13 @@
|
||||
},
|
||||
shown: currentMember || flags.developerMode,
|
||||
},
|
||||
{
|
||||
id: 'copy-maven',
|
||||
action: () => {
|
||||
copyToClipboard(`maven.modrinth:${project.slug}:${version.id}`);
|
||||
},
|
||||
shown: flags.developerMode,
|
||||
},
|
||||
{ divider: true, shown: currentMember },
|
||||
{
|
||||
id: 'edit',
|
||||
@@ -160,6 +167,10 @@
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
Copy ID
|
||||
</template>
|
||||
<template #copy-maven>
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
Copy Modrinth Maven
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
|
||||
@@ -277,7 +277,7 @@ const refunding = ref(false);
|
||||
const refundModal = ref();
|
||||
const selectedCharge = ref(null);
|
||||
const refundType = ref("full");
|
||||
const refundTypes = ref(["full", "partial"]);
|
||||
const refundTypes = ref(["full", "partial", "none"]);
|
||||
const refundAmount = ref(0);
|
||||
const unprovision = ref(false);
|
||||
|
||||
|
||||
504
apps/frontend/src/pages/admin/servers/notices.vue
Normal file
504
apps/frontend/src/pages/admin/servers/notices.vue
Normal file
@@ -0,0 +1,504 @@
|
||||
<template>
|
||||
<NewModal ref="createNoticeModal">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">{{
|
||||
editingNotice ? `Editing notice #${editingNotice?.id}` : "Creating a notice"
|
||||
}}</span>
|
||||
</template>
|
||||
<div class="flex w-[700px] flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label for="level-selector" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Level </span>
|
||||
<span>Determines how the notice should be styled.</span>
|
||||
</label>
|
||||
<TeleportDropdownMenu
|
||||
id="level-selector"
|
||||
v-model="newNoticeLevel"
|
||||
class="max-w-[10rem]"
|
||||
:options="levelOptions"
|
||||
:display-name="(x) => formatMessage(x.name)"
|
||||
name="Level"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!newNoticeSurvey" class="flex flex-col gap-2">
|
||||
<label for="notice-title" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Title </span>
|
||||
</label>
|
||||
<input
|
||||
id="notice-title"
|
||||
v-model="newNoticeTitle"
|
||||
placeholder="E.g. Maintenance"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="notice-message" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
{{ newNoticeSurvey ? "Survey ID" : "Message" }}
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="newNoticeSurvey"
|
||||
id="notice-message"
|
||||
v-model="newNoticeMessage"
|
||||
placeholder="E.g. rXGtq2"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div v-else class="textarea-wrapper h-32">
|
||||
<textarea id="notice-message" v-model="newNoticeMessage" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!newNoticeSurvey" class="flex items-center justify-between gap-2">
|
||||
<label for="dismissable-toggle" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Dismissable </span>
|
||||
<span>Allow users to dismiss the notice from their panel.</span>
|
||||
</label>
|
||||
<Toggle id="dismissable-toggle" v-model="newNoticeDismissable" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label for="scheduled-date" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Announcement date </span>
|
||||
<span>Leave blank for notice to be available immediately.</span>
|
||||
</label>
|
||||
<input
|
||||
id="scheduled-date"
|
||||
v-model="newNoticeScheduledDate"
|
||||
type="datetime-local"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<label for="expiration-date" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Expiration date </span>
|
||||
<span>The notice will automatically be deleted after this date.</span>
|
||||
</label>
|
||||
<input
|
||||
id="expiration-date"
|
||||
v-model="newNoticeExpiresDate"
|
||||
type="datetime-local"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!newNoticeSurvey" class="flex flex-col gap-2">
|
||||
<span class="text-lg font-semibold text-contrast"> Preview </span>
|
||||
<ServerNotice
|
||||
:level="newNoticeLevel.id"
|
||||
:message="
|
||||
!trimmedMessage || trimmedMessage.length < 1
|
||||
? 'Type a message to begin previewing it.'
|
||||
: trimmedMessage
|
||||
"
|
||||
:dismissable="newNoticeDismissable"
|
||||
:title="trimmedTitle"
|
||||
preview
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button v-if="editingNotice" :disabled="!!noticeSubmitError" @click="() => saveChanges()">
|
||||
<SaveIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.saveChangesButton) }}
|
||||
</button>
|
||||
<button v-else :disabled="!!noticeSubmitError" @click="() => createNotice()">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.createNotice) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="createNoticeModal?.hide">
|
||||
<XIcon aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<AssignNoticeModal ref="assignNoticeModal" @close="refreshNotices" />
|
||||
<div class="page experimental-styles-within">
|
||||
<div
|
||||
class="mb-6 flex items-end justify-between border-0 border-b border-solid border-divider pb-4"
|
||||
>
|
||||
<h1 class="m-0 text-2xl">Servers notices</h1>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="openNewNoticeModal">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.createNotice) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="!notices || notices.length === 0">{{ formatMessage(messages.noNotices) }}</div>
|
||||
<div
|
||||
v-else
|
||||
class="grid grid-cols-[auto_auto_auto] gap-4 md:grid-cols-[min-content_auto_auto_auto_auto_min-content]"
|
||||
>
|
||||
<div class="col-span-full grid grid-cols-subgrid gap-4 px-4 font-bold text-contrast">
|
||||
<div>{{ formatMessage(messages.id) }}</div>
|
||||
<div>{{ formatMessage(messages.begins) }}</div>
|
||||
<div>{{ formatMessage(messages.expires) }}</div>
|
||||
<div class="hidden md:block">{{ formatMessage(messages.level) }}</div>
|
||||
<div class="hidden md:block">{{ formatMessage(messages.dismissable) }}</div>
|
||||
<div class="hidden md:block">{{ formatMessage(messages.actions) }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="notice in notices"
|
||||
:key="`notice-${notice.id}`"
|
||||
class="col-span-full grid grid-cols-subgrid gap-4 rounded-2xl bg-bg-raised p-4"
|
||||
>
|
||||
<div class="col-span-full grid grid-cols-subgrid items-center gap-4">
|
||||
<div>
|
||||
<CopyCode :text="`${notice.id}`" />
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span v-if="notice.announce_at">
|
||||
{{ dayjs(notice.announce_at).format("MMM D, YYYY [at] h:mm A") }} ({{
|
||||
dayjs(notice.announce_at).fromNow()
|
||||
}})
|
||||
</span>
|
||||
<template v-else> Never begins </template>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span
|
||||
v-if="notice.expires"
|
||||
v-tooltip="dayjs(notice.expires).format('MMMM D, YYYY [at] h:mm A')"
|
||||
>
|
||||
{{ dayjs(notice.expires).fromNow() }}
|
||||
</span>
|
||||
<template v-else> Never expires </template>
|
||||
</div>
|
||||
<div
|
||||
:style="
|
||||
NOTICE_LEVELS[notice.level]
|
||||
? {
|
||||
'--_color': NOTICE_LEVELS[notice.level].colors.text,
|
||||
'--_bg-color': NOTICE_LEVELS[notice.level].colors.bg,
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<TagItem>
|
||||
{{
|
||||
NOTICE_LEVELS[notice.level]
|
||||
? formatMessage(NOTICE_LEVELS[notice.level].name)
|
||||
: notice.level
|
||||
}}
|
||||
</TagItem>
|
||||
</div>
|
||||
<div
|
||||
:style="{
|
||||
'--_color': notice.dismissable ? 'var(--color-green)' : 'var(--color-red)',
|
||||
'--_bg-color': notice.dismissable ? 'var(--color-green-bg)' : 'var(--color-red-bg)',
|
||||
}"
|
||||
>
|
||||
<TagItem>
|
||||
{{
|
||||
formatMessage(notice.dismissable ? messages.dismissable : messages.undismissable)
|
||||
}}
|
||||
</TagItem>
|
||||
</div>
|
||||
<div class="col-span-2 flex gap-2 md:col-span-1">
|
||||
<ButtonStyled>
|
||||
<button @click="() => startEditing(notice)">
|
||||
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="() => deleteNotice(notice)">
|
||||
<TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-full grid">
|
||||
<ServerNotice
|
||||
:level="notice.level"
|
||||
:message="notice.message"
|
||||
:dismissable="notice.dismissable"
|
||||
:title="notice.title"
|
||||
preview
|
||||
/>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<span v-if="!notice.assigned || notice.assigned.length === 0"
|
||||
>Not assigned to any servers</span
|
||||
>
|
||||
<span v-else-if="!notice.assigned.some((n) => n.kind === 'server')">
|
||||
Assigned to
|
||||
{{ notice.assigned.filter((n) => n.kind === "node").length }} nodes
|
||||
</span>
|
||||
<span v-else-if="!notice.assigned.some((n) => n.kind === 'node')">
|
||||
Assigned to
|
||||
{{ notice.assigned.filter((n) => n.kind === "server").length }} servers
|
||||
</span>
|
||||
<span v-else>
|
||||
Assigned to
|
||||
{{ notice.assigned.filter((n) => n.kind === "server").length }} servers and
|
||||
{{ notice.assigned.filter((n) => n.kind === "node").length }} nodes
|
||||
</span>
|
||||
•
|
||||
<button
|
||||
class="m-0 flex items-center gap-1 border-none bg-transparent p-0 text-blue hover:underline hover:brightness-125 active:scale-95 active:brightness-150"
|
||||
@click="() => startEditing(notice, true)"
|
||||
>
|
||||
<SettingsIcon />
|
||||
Edit assignments
|
||||
</button>
|
||||
<template v-if="notice.dismissed_by.length > 0">
|
||||
•
|
||||
<span> Dismissed by {{ notice.dismissed_by.length }} servers </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CopyCode,
|
||||
TagItem,
|
||||
ButtonStyled,
|
||||
ServerNotice,
|
||||
commonMessages,
|
||||
NewModal,
|
||||
TeleportDropdownMenu,
|
||||
Toggle,
|
||||
} from "@modrinth/ui";
|
||||
import { SettingsIcon, PlusIcon, SaveIcon, TrashIcon, EditIcon, XIcon } from "@modrinth/assets";
|
||||
import dayjs from "dayjs";
|
||||
import { useVIntl } from "@vintl/vintl";
|
||||
import type { ServerNotice as ServerNoticeType } from "@modrinth/utils";
|
||||
import { computed } from "vue";
|
||||
import { NOTICE_LEVELS } from "@modrinth/ui/src/utils/notices.ts";
|
||||
import { usePyroFetch } from "~/composables/pyroFetch.ts";
|
||||
import AssignNoticeModal from "~/components/ui/servers/notice/AssignNoticeModal.vue";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
const app = useNuxtApp() as unknown as { $notify: any };
|
||||
|
||||
const notices = ref<ServerNoticeType[]>([]);
|
||||
const createNoticeModal = ref<InstanceType<typeof NewModal>>();
|
||||
const assignNoticeModal = ref<InstanceType<typeof AssignNoticeModal>>();
|
||||
|
||||
await refreshNotices();
|
||||
|
||||
async function refreshNotices() {
|
||||
await usePyroFetch("notices").then((res) => {
|
||||
notices.value = res as ServerNoticeType[];
|
||||
notices.value.sort((a, b) => {
|
||||
const dateDiff = dayjs(b.announce_at).diff(dayjs(a.announce_at));
|
||||
if (dateDiff === 0) {
|
||||
return b.id - a.id;
|
||||
}
|
||||
|
||||
return dateDiff;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const levelOptions = Object.keys(NOTICE_LEVELS).map((x) => ({
|
||||
id: x,
|
||||
...NOTICE_LEVELS[x],
|
||||
}));
|
||||
|
||||
const DATE_TIME_FORMAT = "YYYY-MM-DDTHH:mm";
|
||||
|
||||
const newNoticeLevel = ref(levelOptions[0]);
|
||||
const newNoticeDismissable = ref(false);
|
||||
const newNoticeMessage = ref("");
|
||||
const newNoticeScheduledDate = ref<string>();
|
||||
const newNoticeTitle = ref<string>();
|
||||
const newNoticeExpiresDate = ref<string>();
|
||||
|
||||
function openNewNoticeModal() {
|
||||
newNoticeLevel.value = levelOptions[0];
|
||||
newNoticeDismissable.value = false;
|
||||
newNoticeMessage.value = "";
|
||||
newNoticeScheduledDate.value = undefined;
|
||||
newNoticeExpiresDate.value = undefined;
|
||||
editingNotice.value = undefined;
|
||||
createNoticeModal.value?.show();
|
||||
}
|
||||
|
||||
const editingNotice = ref<undefined | ServerNoticeType>();
|
||||
|
||||
function startEditing(notice: ServerNoticeType, assignments: boolean = false) {
|
||||
newNoticeLevel.value = levelOptions.find((x) => x.id === notice.level) ?? levelOptions[0];
|
||||
newNoticeDismissable.value = notice.dismissable;
|
||||
newNoticeMessage.value = notice.message;
|
||||
newNoticeTitle.value = notice.title;
|
||||
newNoticeScheduledDate.value = dayjs(notice.announce_at).format(DATE_TIME_FORMAT);
|
||||
newNoticeExpiresDate.value = notice.expires
|
||||
? dayjs(notice.expires).format(DATE_TIME_FORMAT)
|
||||
: undefined;
|
||||
editingNotice.value = notice;
|
||||
if (assignments) {
|
||||
assignNoticeModal.value?.show?.(notice);
|
||||
} else {
|
||||
createNoticeModal.value?.show();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNotice(notice: ServerNoticeType) {
|
||||
await usePyroFetch(`notices/${notice.id}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
.then(() => {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: `Successfully deleted notice #${notice.id}`,
|
||||
type: "success",
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "Error deleting notice",
|
||||
text: err,
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
await refreshNotices();
|
||||
}
|
||||
|
||||
const trimmedMessage = computed(() => newNoticeMessage.value?.trim());
|
||||
const trimmedTitle = computed(() => newNoticeTitle.value?.trim());
|
||||
const newNoticeSurvey = computed(() => newNoticeLevel.value.id === "survey");
|
||||
|
||||
const noticeSubmitError = computed(() => {
|
||||
let error: undefined | string;
|
||||
if (!trimmedMessage.value || trimmedMessage.value.length === 0) {
|
||||
error = "Notice message is required";
|
||||
}
|
||||
if (!newNoticeLevel.value) {
|
||||
error = "Notice level is required";
|
||||
}
|
||||
return error;
|
||||
});
|
||||
|
||||
function validateSubmission(message: string) {
|
||||
if (noticeSubmitError.value) {
|
||||
addNotification({
|
||||
group: "main",
|
||||
title: message,
|
||||
text: noticeSubmitError.value,
|
||||
type: "error",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (!validateSubmission("Error saving notice")) {
|
||||
return;
|
||||
}
|
||||
|
||||
await usePyroFetch(`notices/${editingNotice.value?.id}`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
message: newNoticeMessage.value,
|
||||
title: newNoticeSurvey.value ? undefined : trimmedTitle.value,
|
||||
level: newNoticeLevel.value.id,
|
||||
dismissable: newNoticeSurvey.value ? true : newNoticeDismissable.value,
|
||||
announce_at: newNoticeScheduledDate.value
|
||||
? dayjs(newNoticeScheduledDate.value).toISOString()
|
||||
: dayjs().toISOString(),
|
||||
expires: newNoticeExpiresDate.value
|
||||
? dayjs(newNoticeExpiresDate.value).toISOString()
|
||||
: undefined,
|
||||
},
|
||||
}).catch((err) => {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "Error saving changes to notice",
|
||||
text: err,
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
await refreshNotices();
|
||||
createNoticeModal.value?.hide();
|
||||
}
|
||||
|
||||
async function createNotice() {
|
||||
if (!validateSubmission("Error creating notice")) {
|
||||
return;
|
||||
}
|
||||
|
||||
await usePyroFetch("notices", {
|
||||
method: "POST",
|
||||
body: {
|
||||
message: newNoticeMessage.value,
|
||||
title: newNoticeSurvey.value ? undefined : trimmedTitle.value,
|
||||
level: newNoticeLevel.value.id,
|
||||
dismissable: newNoticeSurvey.value ? true : newNoticeDismissable.value,
|
||||
announce_at: newNoticeScheduledDate.value
|
||||
? dayjs(newNoticeScheduledDate.value).toISOString()
|
||||
: dayjs().toISOString(),
|
||||
expires: newNoticeExpiresDate.value
|
||||
? dayjs(newNoticeExpiresDate.value).toISOString()
|
||||
: undefined,
|
||||
},
|
||||
}).catch((err) => {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "Error creating notice",
|
||||
text: err,
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
await refreshNotices();
|
||||
createNoticeModal.value?.hide();
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
createNotice: {
|
||||
id: "servers.notices.create-notice",
|
||||
defaultMessage: "Create notice",
|
||||
},
|
||||
noNotices: {
|
||||
id: "servers.notices.no-notices",
|
||||
defaultMessage: "No notices",
|
||||
},
|
||||
dismissable: {
|
||||
id: "servers.notice.dismissable",
|
||||
defaultMessage: "Dismissable",
|
||||
},
|
||||
undismissable: {
|
||||
id: "servers.notice.undismissable",
|
||||
defaultMessage: "Undismissable",
|
||||
},
|
||||
id: {
|
||||
id: "servers.notice.id",
|
||||
defaultMessage: "ID",
|
||||
},
|
||||
begins: {
|
||||
id: "servers.notice.begins",
|
||||
defaultMessage: "Begins",
|
||||
},
|
||||
expires: {
|
||||
id: "servers.notice.expires",
|
||||
defaultMessage: "Expires",
|
||||
},
|
||||
actions: {
|
||||
id: "servers.notice.actions",
|
||||
defaultMessage: "Actions",
|
||||
},
|
||||
level: {
|
||||
id: "servers.notice.level",
|
||||
defaultMessage: "Level",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
padding: 1rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 78.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -697,7 +697,7 @@ async function deleteCollection() {
|
||||
addNotification({
|
||||
group: "main",
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ async function updateVenmo() {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ async function cancelPayout(id) {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -369,7 +369,7 @@ async function withdraw() {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
<h2 class="m-0 text-lg font-bold">Experience modern, reliable hosting</h2>
|
||||
<h3 class="m-0 text-base font-normal text-secondary">
|
||||
Modrinth Servers are hosted on
|
||||
<span class="text-contrast">2023 Ryzen 7/9 CPUs with DDR5 RAM</span>, running on
|
||||
<span class="text-contrast">high-performance AMD CPUs with DDR5 RAM</span>, running on
|
||||
custom-built software to ensure your server performs smoothly.
|
||||
</h3>
|
||||
</div>
|
||||
@@ -329,27 +329,6 @@
|
||||
alt=""
|
||||
class="absolute -bottom-12 -right-[15%] hidden max-w-2xl rounded-2xl bg-brand p-4 lg:block"
|
||||
/>
|
||||
<div class="flex flex-row items-center gap-3">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="max-w-fit rounded-full bg-brand p-4 text-sm font-bold text-[var(--color-accent-contrast)] lg:absolute lg:bottom-8 lg:right-8 lg:block"
|
||||
>
|
||||
8.49 GB used
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="flex w-fit items-center gap-2 rounded-full bg-button-bg p-3 lg:hidden"
|
||||
>
|
||||
<SortAscendingIcon class="h-6 w-6" />
|
||||
Sort
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="flex w-fit items-center rounded-full bg-button-bg p-3 lg:hidden"
|
||||
>
|
||||
<SearchIcon class="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid w-full grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
@@ -418,9 +397,24 @@
|
||||
</span>
|
||||
What kind of CPUs do Modrinth Servers run on?
|
||||
</summary>
|
||||
<p class="m-0 !leading-[190%]">
|
||||
Modrinth Servers use 2023 Ryzen 7 and Ryzen 9 CPUs at 4+ GHz, paired with DDR5
|
||||
memory.
|
||||
<p class="m-0 ml-6 leading-[160%]">
|
||||
Modrinth Servers are powered by AMD Ryzen 7900 and 7950X3D equivalent CPUs at 5+
|
||||
GHz, paired with with DDR5 memory.
|
||||
</p>
|
||||
</details>
|
||||
<details pyro-hash="cpu-burst" class="group" :open="$route.hash === '#cpu-burst'">
|
||||
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
|
||||
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||
<RightArrowIcon />
|
||||
</span>
|
||||
How do CPU burst threads work?
|
||||
</summary>
|
||||
<p class="m-0 ml-6 leading-[160%]">
|
||||
When your server is under heavy load, we temporarily give it access to additional
|
||||
CPU threads to help mitigate lag spikes and instability. This helps prevent the TPS
|
||||
from going below 20, ensuring the smoothest experience possible. Since those extra
|
||||
CPU threads are only shortly available during high load periods, they might not show
|
||||
up in Spark reports or other profiling tools.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -431,10 +425,12 @@
|
||||
</span>
|
||||
Do Modrinth Servers have DDoS protection?
|
||||
</summary>
|
||||
<p class="m-0 !leading-[190%]">
|
||||
Yes. All Modrinth Servers come with DDoS protection. Protection is powered by a
|
||||
combination of in-house network filtering as well as with our data center provider.
|
||||
Your server is safe on Modrinth.
|
||||
<p class="m-0 ml-6 leading-[160%]">
|
||||
Yes. All Modrinth Servers come with DDoS protection powered by
|
||||
<a href="https://us.ovhcloud.com/security/anti-ddos/" target="_blank"
|
||||
>OVHcloud® Anti-DDoS infrastructure</a
|
||||
>
|
||||
which has over 17Tbps capacity. Your server is safe on Modrinth.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -445,11 +441,9 @@
|
||||
</span>
|
||||
Where are Modrinth Servers located? Can I choose a region?
|
||||
</summary>
|
||||
<p class="m-0 !leading-[190%]">
|
||||
Currently, Modrinth Servers are located throughout the United States in New York,
|
||||
Los Angelas, Dallas, Miami, and Spokane. More regions are coming soon! Your server's
|
||||
location is currently chosen algorithmically, but you will be able to choose a
|
||||
region in the future.
|
||||
<p class="m-0 ml-6 leading-[160%]">
|
||||
Currently, Modrinth Servers are located on the east coast of the United States in
|
||||
Vint Hill, Virginia. More regions to come in the future!
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -460,7 +454,7 @@
|
||||
</span>
|
||||
Can I increase the storage on my server?
|
||||
</summary>
|
||||
<p class="m-0 !leading-[190%]">
|
||||
<p class="m-0 ml-6 leading-[160%]">
|
||||
Yes, storage can be increased on your server at no additional cost. If you need more
|
||||
storage, reach out to Modrinth Support.
|
||||
</p>
|
||||
@@ -471,13 +465,19 @@
|
||||
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||
<RightArrowIcon />
|
||||
</span>
|
||||
How fast are Modrinth Servers? How many players can they handle?
|
||||
How fast are Modrinth Servers?
|
||||
</summary>
|
||||
<p class="m-0 !leading-[190%]">
|
||||
During the Modrinth "Emergency SMP" test, we had over 80 players on a server running
|
||||
on the Large plan. The server ran smoothly and was only limited by RAM. We're
|
||||
confident that Modrinth Servers can handle a large number of players, with any kind
|
||||
of modpack.
|
||||
<p class="m-0 ml-6 leading-[160%]">
|
||||
Modrinth Servers are hosted on very modern high-performance hardware, but it's tough
|
||||
to say how exactly that will translate into how fast your server will run because
|
||||
there are so many factors that affect it, such as the mods, data packs, or plugins
|
||||
you're running on your server, and even user behavior.
|
||||
</p>
|
||||
<p class="mb-0 ml-6 mt-3 leading-[160%]">
|
||||
Most performance issues that arise tend to be the fault of an unoptimized modpack,
|
||||
mod, data pack, or plugin that causes the server to lag. Since our servers are very
|
||||
high-end, you shouldn't run into much trouble as long as you pick an appropriate
|
||||
plan for the content you're running on the server.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
@@ -486,6 +486,7 @@
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="false"
|
||||
class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48"
|
||||
>
|
||||
<div class="faded-brand-line absolute left-0 top-0 h-[1px] w-full"></div>
|
||||
@@ -596,182 +597,49 @@
|
||||
</h2>
|
||||
|
||||
<ul class="m-0 mt-8 flex w-full flex-col gap-8 p-0 lg:flex-row">
|
||||
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
|
||||
<div
|
||||
v-if="isSmallLowStock"
|
||||
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl bg-yellow-500/20 p-4 text-center font-bold"
|
||||
>
|
||||
Only {{ capacityStatuses?.small?.available }} left in stock!
|
||||
</div>
|
||||
<div
|
||||
class="flex w-full flex-col justify-between gap-4 rounded-2xl bg-bg p-8 text-left"
|
||||
:class="{ '!rounded-t-none': isSmallLowStock }"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<h1 class="m-0">Small</h1>
|
||||
<div
|
||||
class="grid size-8 place-content-center rounded-full bg-highlight-blue text-xs font-bold text-blue"
|
||||
>
|
||||
S
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0">
|
||||
Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding.
|
||||
</p>
|
||||
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
||||
<p class="m-0">4 GB RAM</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">4 vCPUs</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">32 GB Storage</p>
|
||||
</div>
|
||||
<h2 class="m-0 text-3xl text-contrast">
|
||||
$12<span class="text-sm font-normal text-secondary">/month</span>
|
||||
</h2>
|
||||
</div>
|
||||
<ButtonStyled color="blue" size="large">
|
||||
<a
|
||||
v-if="!loggedOut && isSmallAtCapacity"
|
||||
:href="outOfStockUrl"
|
||||
target="_blank"
|
||||
class="flex items-center gap-2 !bg-highlight-blue !font-medium !text-blue"
|
||||
>
|
||||
Out of Stock
|
||||
<ExternalIcon class="!min-h-4 !min-w-4 !text-blue" />
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
class="!bg-highlight-blue !font-medium !text-blue"
|
||||
@click="selectProduct('small')"
|
||||
>
|
||||
Get Started
|
||||
<RightArrowIcon class="!min-h-4 !min-w-4 !text-blue" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
|
||||
<div
|
||||
v-if="isMediumLowStock"
|
||||
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl bg-yellow-500/20 p-4 text-center font-bold"
|
||||
>
|
||||
Only {{ capacityStatuses?.medium?.available }} left in stock!
|
||||
</div>
|
||||
<div
|
||||
style="
|
||||
background: radial-gradient(
|
||||
86.12% 101.64% at 95.97% 94.07%,
|
||||
rgba(27, 217, 106, 0.23) 0%,
|
||||
rgba(14, 115, 56, 0.2) 100%
|
||||
);
|
||||
border: 1px solid rgba(12, 107, 52, 0.55);
|
||||
box-shadow: 0px 12px 38.1px rgba(27, 217, 106, 0.13);
|
||||
"
|
||||
class="flex w-full flex-col justify-between gap-4 rounded-2xl p-8 text-left"
|
||||
:class="{ '!rounded-t-none': isMediumLowStock }"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<h1 class="m-0">Medium</h1>
|
||||
<div
|
||||
class="grid size-8 place-content-center rounded-full bg-highlight-green text-xs font-bold text-brand"
|
||||
>
|
||||
M
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0">Great for modded multiplayer and small communities.</p>
|
||||
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
||||
<p class="m-0">6 GB RAM</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">6 vCPUs</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">48 GB Storage</p>
|
||||
</div>
|
||||
<h2 class="m-0 text-3xl text-contrast">
|
||||
$18<span class="text-sm font-normal text-secondary">/month</span>
|
||||
</h2>
|
||||
</div>
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<a
|
||||
v-if="!loggedOut && isMediumAtCapacity"
|
||||
:href="outOfStockUrl"
|
||||
target="_blank"
|
||||
class="flex items-center gap-2 !bg-highlight-green !font-medium !text-green"
|
||||
>
|
||||
Out of Stock
|
||||
<ExternalIcon class="!min-h-4 !min-w-4 !text-green" />
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
class="!bg-highlight-green !font-medium !text-green"
|
||||
@click="selectProduct('medium')"
|
||||
>
|
||||
Get Started
|
||||
<RightArrowIcon class="!min-h-4 !min-w-4 !text-green" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
|
||||
<div
|
||||
v-if="isLargeLowStock"
|
||||
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl bg-yellow-500/20 p-4 text-center font-bold"
|
||||
>
|
||||
Only {{ capacityStatuses?.large?.available }} left in stock!
|
||||
</div>
|
||||
<div
|
||||
class="flex w-full flex-col justify-between gap-4 rounded-2xl bg-bg p-8 text-left"
|
||||
:class="{ '!rounded-t-none': isLargeLowStock }"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<h1 class="m-0">Large</h1>
|
||||
<div
|
||||
class="grid size-8 place-content-center rounded-full bg-highlight-purple text-xs font-bold text-purple"
|
||||
>
|
||||
L
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0">Ideal for larger communities, modpacks, and heavy modding.</p>
|
||||
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
||||
<p class="m-0">8 GB RAM</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">8 vCPUs</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">64 GB Storage</p>
|
||||
</div>
|
||||
<h2 class="m-0 text-3xl text-contrast">
|
||||
$24<span class="text-sm font-normal text-secondary">/month</span>
|
||||
</h2>
|
||||
</div>
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<a
|
||||
v-if="!loggedOut && isLargeAtCapacity"
|
||||
:href="outOfStockUrl"
|
||||
target="_blank"
|
||||
class="flex items-center gap-2 !bg-highlight-purple !font-medium !text-purple"
|
||||
>
|
||||
Out of Stock
|
||||
<ExternalIcon class="!min-h-4 !min-w-4 !text-purple" />
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
class="!bg-highlight-purple !font-medium !text-purple"
|
||||
@click="selectProduct('large')"
|
||||
>
|
||||
Get Started
|
||||
<RightArrowIcon class="!min-h-4 !min-w-4 !text-purple" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</li>
|
||||
<ServerPlanSelector
|
||||
:capacity="capacityStatuses?.small?.available"
|
||||
plan="small"
|
||||
:ram="plans.small.metadata.ram"
|
||||
:storage="plans.small.metadata.storage"
|
||||
:cpus="plans.small.metadata.cpu"
|
||||
:price="
|
||||
plans.small?.prices?.find((x) => x.currency_code === 'USD')?.prices?.intervals
|
||||
?.monthly
|
||||
"
|
||||
@select="selectProduct('small')"
|
||||
@scroll-to-faq="scrollToFaq()"
|
||||
/>
|
||||
<ServerPlanSelector
|
||||
:capacity="capacityStatuses?.medium?.available"
|
||||
plan="medium"
|
||||
:ram="plans.medium.metadata.ram"
|
||||
:storage="plans.medium.metadata.storage"
|
||||
:cpus="plans.medium.metadata.cpu"
|
||||
:price="
|
||||
plans.medium?.prices?.find((x) => x.currency_code === 'USD')?.prices?.intervals
|
||||
?.monthly
|
||||
"
|
||||
@select="selectProduct('medium')"
|
||||
@scroll-to-faq="scrollToFaq()"
|
||||
/>
|
||||
<ServerPlanSelector
|
||||
:capacity="capacityStatuses?.large?.available"
|
||||
:ram="plans.large.metadata.ram"
|
||||
:storage="plans.large.metadata.storage"
|
||||
:cpus="plans.large.metadata.cpu"
|
||||
:price="
|
||||
plans.large?.prices?.find((x) => x.currency_code === 'USD')?.prices?.intervals
|
||||
?.monthly
|
||||
"
|
||||
plan="large"
|
||||
@select="selectProduct('large')"
|
||||
@scroll-to-faq="scrollToFaq()"
|
||||
/>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
class="mb-4 flex w-full flex-col items-start justify-between gap-4 rounded-2xl bg-bg p-8 text-left lg:flex-row lg:gap-0"
|
||||
class="mb-24 flex w-full flex-col items-start justify-between gap-4 rounded-2xl bg-bg p-8 text-left lg:flex-row lg:gap-0"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<h1 class="m-0">Build your own</h1>
|
||||
@@ -781,11 +649,13 @@
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col-reverse gap-2 md:w-auto md:flex-col md:items-center">
|
||||
<div
|
||||
class="experimental-styles-within flex w-full flex-col-reverse gap-2 md:w-auto md:flex-col md:items-center"
|
||||
>
|
||||
<ButtonStyled color="standard" size="large">
|
||||
<button class="w-full md:w-fit" @click="selectProduct('custom')">
|
||||
Build your own
|
||||
<RightArrowIcon class="!min-h-4 !min-w-4" />
|
||||
<RightArrowIcon class="shrink-0" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<p class="m-0 text-sm">Starting at $3/GB RAM</p>
|
||||
@@ -802,9 +672,6 @@ import {
|
||||
BoxIcon,
|
||||
GameIcon,
|
||||
RightArrowIcon,
|
||||
SearchIcon,
|
||||
SortAscendingIcon,
|
||||
ExternalIcon,
|
||||
TerminalSquareIcon,
|
||||
TransferIcon,
|
||||
VersionIcon,
|
||||
@@ -813,6 +680,7 @@ import {
|
||||
import { products } from "~/generated/state.json";
|
||||
import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
|
||||
import Globe from "~/components/ui/servers/Globe.vue";
|
||||
import ServerPlanSelector from "~/components/ui/servers/marketing/ServerPlanSelector.vue";
|
||||
|
||||
const pyroProducts = products.filter((p) => p.metadata.type === "pyro");
|
||||
const pyroPlanProducts = pyroProducts.filter(
|
||||
@@ -893,6 +761,7 @@ async function fetchCapacityStatuses(customProduct = null) {
|
||||
swap_mb: product.metadata.swap,
|
||||
storage_mb: product.metadata.storage,
|
||||
},
|
||||
bypassAuth: true,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -931,21 +800,6 @@ const isMediumAtCapacity = computed(() => capacityStatuses.value?.medium?.availa
|
||||
const isLargeAtCapacity = computed(() => capacityStatuses.value?.large?.available === 0);
|
||||
const isCustomAtCapacity = computed(() => capacityStatuses.value?.custom?.available === 0);
|
||||
|
||||
const isSmallLowStock = computed(() => {
|
||||
const available = capacityStatuses.value?.small?.available;
|
||||
return available !== undefined && available > 0 && available < 8;
|
||||
});
|
||||
|
||||
const isMediumLowStock = computed(() => {
|
||||
const available = capacityStatuses.value?.medium?.available;
|
||||
return available !== undefined && available > 0 && available < 8;
|
||||
});
|
||||
|
||||
const isLargeLowStock = computed(() => {
|
||||
const available = capacityStatuses.value?.large?.available;
|
||||
return available !== undefined && available > 0 && available < 8;
|
||||
});
|
||||
|
||||
const startTyping = () => {
|
||||
const currentWord = words[currentWordIndex.value];
|
||||
if (isDeleting.value) {
|
||||
|
||||
@@ -1,386 +1,391 @@
|
||||
<template>
|
||||
<div class="contents">
|
||||
<div
|
||||
v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'upgrading'"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
|
||||
<TransferIcon class="size-12 text-blue" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server upgrading</h1>
|
||||
<div
|
||||
v-if="filteredNotices.length > 0"
|
||||
class="experimental-styles-within relative mx-auto flex w-full min-w-0 max-w-[1280px] flex-col gap-3 px-6"
|
||||
>
|
||||
<ServerNotice
|
||||
v-for="notice in filteredNotices"
|
||||
:key="`notice-${notice.id}`"
|
||||
:level="notice.level"
|
||||
:message="notice.message"
|
||||
:dismissable="notice.dismissable"
|
||||
:title="notice.title"
|
||||
class="w-full"
|
||||
@dismiss="() => dismissNotice(notice.id)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'upgrading'"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
|
||||
<TransferIcon class="size-12 text-blue" />
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
Your server's hardware is currently being upgraded and will be back online shortly!
|
||||
</p>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server upgrading</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
Your server's hardware is currently being upgraded and will be back online shortly!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'support'"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
|
||||
<TransferIcon class="size-12 text-blue" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">We're working on your server</h1>
|
||||
</div>
|
||||
<div
|
||||
v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'support'"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
|
||||
<TransferIcon class="size-12 text-blue" />
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
You recently contacted Modrinth Support, and we're actively working on your server. It
|
||||
will be back online shortly.
|
||||
</p>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">We're working on your server</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
You recently contacted Modrinth Support, and we're actively working on your server. It
|
||||
will be back online shortly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="serverData?.status === 'suspended' && serverData.suspension_reason !== 'upgrading'"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<LockIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server suspended</h1>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="serverData?.status === 'suspended' && serverData.suspension_reason !== 'upgrading'"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<LockIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server suspended</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
{{
|
||||
serverData.suspension_reason === "cancelled"
|
||||
? "Your subscription has been cancelled."
|
||||
: serverData.suspension_reason
|
||||
? `Your server has been suspended: ${serverData.suspension_reason}`
|
||||
: "Your server has been suspended."
|
||||
}}
|
||||
<br />
|
||||
Contact Modrinth support if you believe this is an error.
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')">
|
||||
<button class="mt-6 !w-full">Go to billing settings</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
server.general?.error?.error.statusCode === 403 ||
|
||||
server.general?.error?.error.statusCode === 404
|
||||
"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<TransferIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server not found</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
You don't have permission to view this server or it no longer exists. If you believe this
|
||||
is an error, please contact Modrinth support.
|
||||
</p>
|
||||
</div>
|
||||
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
|
||||
|
||||
<ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')">
|
||||
<button class="mt-6 !w-full">Go back to all servers</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="server.general?.error?.error.statusCode === 503"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-red p-4">
|
||||
<UiServersIconsPanelErrorIcon class="size-12 text-red" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-4 w-fit text-4xl font-bold">Server Node Unavailable</h1>
|
||||
</div>
|
||||
<p class="m-0 mb-4 leading-[170%] text-secondary">
|
||||
Your server's node, where your Modrinth Server is physically hosted, is experiencing
|
||||
issues. We are working with our datacenter to resolve the issue as quickly as possible.
|
||||
</p>
|
||||
<p class="m-0 mb-4 leading-[170%] text-secondary">
|
||||
Your data is safe and will not be lost, and your server will be back online as soon as the
|
||||
issue is resolved.
|
||||
</p>
|
||||
<p class="m-0 mb-4 leading-[170%] text-secondary">
|
||||
For updates, please join the Modrinth Discord or contact Modrinth Support via the chat
|
||||
bubble in the bottom right corner and we'll be happy to help.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<UiCopyCode :text="'Server ID: ' + server.serverId" />
|
||||
<UiCopyCode :text="'Node: ' + server.general?.datacenter" />
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
size="large"
|
||||
color="standard"
|
||||
@click="
|
||||
() =>
|
||||
navigateTo('https://discord.modrinth.com', {
|
||||
external: true,
|
||||
})
|
||||
"
|
||||
>
|
||||
<button class="mt-6 !w-full">Join Modrinth Discord</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
:disabled="formattedTime !== '00'"
|
||||
size="large"
|
||||
color="standard"
|
||||
@click="() => reloadNuxtApp()"
|
||||
>
|
||||
<button class="mt-3 !w-full">Reload</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="server.general?.error"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<TransferIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Connection lost</h1>
|
||||
<div class="text-center text-secondary">
|
||||
{{
|
||||
serverData.suspension_reason === "cancelled"
|
||||
? "Your subscription has been cancelled."
|
||||
: serverData.suspension_reason
|
||||
? `Your server has been suspended: ${serverData.suspension_reason}`
|
||||
: "Your server has been suspended."
|
||||
formattedTime == "00" ? "Reconnecting..." : `Retrying in ${formattedTime} seconds...`
|
||||
}}
|
||||
<br />
|
||||
Contact Modrinth support if you believe this is an error.
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')">
|
||||
<button class="mt-6 !w-full">Go to billing settings</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="
|
||||
server.general?.error?.error.statusCode === 403 ||
|
||||
server.general?.error?.error.statusCode === 404
|
||||
"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<TransferIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server not found</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
You don't have permission to view this server or it no longer exists. If you believe
|
||||
this is an error, please contact Modrinth support.
|
||||
</p>
|
||||
</div>
|
||||
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
|
||||
<p class="text-lg text-secondary">
|
||||
Something went wrong, and we couldn't connect to your server. This is likely due to a
|
||||
temporary network issue. You'll be reconnected automatically.
|
||||
</p>
|
||||
</div>
|
||||
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
|
||||
<ButtonStyled
|
||||
:disabled="formattedTime !== '00'"
|
||||
size="large"
|
||||
color="brand"
|
||||
@click="() => reloadNuxtApp()"
|
||||
>
|
||||
<button class="mt-6 !w-full">Reload</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<!-- SERVER START -->
|
||||
<div
|
||||
v-else-if="serverData"
|
||||
data-pyro-server-manager-root
|
||||
class="experimental-styles-within mobile-blurred-servericon relative mx-auto mb-6 box-border flex min-h-screen w-full min-w-0 max-w-[1280px] flex-col gap-6 px-6 transition-all duration-300"
|
||||
:style="{
|
||||
'--server-bg-image': serverData.image
|
||||
? `url(${serverData.image})`
|
||||
: `linear-gradient(180deg, rgba(153,153,153,1) 0%, rgba(87,87,87,1) 100%)`,
|
||||
}"
|
||||
>
|
||||
<div class="flex w-full min-w-0 select-none flex-col items-center gap-6 pt-4 sm:flex-row">
|
||||
<UiServersServerIcon :image="serverData.image" class="drop-shadow-lg sm:drop-shadow-none" />
|
||||
<div
|
||||
class="flex min-w-0 flex-1 flex-col-reverse items-center gap-2 sm:flex-col sm:items-start"
|
||||
>
|
||||
<div class="hidden shrink-0 flex-row items-center gap-1 sm:flex">
|
||||
<NuxtLink to="/servers/manage" class="breadcrumb goto-link flex w-fit items-center">
|
||||
<LeftArrowIcon />
|
||||
All servers
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="flex w-full flex-col items-center gap-4 sm:flex-row">
|
||||
<h1
|
||||
class="m-0 w-screen flex-shrink gap-3 truncate px-3 text-center text-4xl font-bold text-contrast sm:w-full sm:p-0 sm:text-left"
|
||||
>
|
||||
{{ serverData.name }}
|
||||
</h1>
|
||||
<div
|
||||
v-if="isConnected"
|
||||
data-pyro-server-action-buttons
|
||||
class="server-action-buttons-anim flex w-fit flex-shrink-0"
|
||||
>
|
||||
<UiServersPanelServerActionButton
|
||||
class="flex-shrink-0"
|
||||
:is-online="isServerRunning"
|
||||
:is-actioning="isActioning"
|
||||
:is-installing="serverData.status === 'installing'"
|
||||
:disabled="isActioning || !!error"
|
||||
:server-name="serverData.name"
|
||||
:server-data="serverData"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
@action="sendPowerAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')">
|
||||
<button class="mt-6 !w-full">Go back to all servers</button>
|
||||
</ButtonStyled>
|
||||
<UiServersServerInfoLabels
|
||||
:server-data="serverData"
|
||||
:show-game-label="showGameLabel"
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
:linked="true"
|
||||
class="server-action-buttons-anim flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="server.general?.error?.error.statusCode === 503"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-red p-4">
|
||||
<UiServersIconsPanelErrorIcon class="size-12 text-red" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-4 w-fit text-4xl font-bold">Server Node Unavailable</h1>
|
||||
</div>
|
||||
<p class="m-0 mb-4 leading-[170%] text-secondary">
|
||||
Your server's node, where your Modrinth Server is physically hosted, is experiencing
|
||||
issues. We are working with our datacenter to resolve the issue as quickly as possible.
|
||||
</p>
|
||||
<p class="m-0 mb-4 leading-[170%] text-secondary">
|
||||
Your data is safe and will not be lost, and your server will be back online as soon as
|
||||
the issue is resolved.
|
||||
</p>
|
||||
<p class="m-0 mb-4 leading-[170%] text-secondary">
|
||||
For updates, please join the Modrinth Discord or contact Modrinth Support via the chat
|
||||
bubble in the bottom right corner and we'll be happy to help.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<UiCopyCode :text="'Server ID: ' + server.serverId" />
|
||||
<UiCopyCode :text="'Node: ' + server.general?.datacenter" />
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
size="large"
|
||||
color="standard"
|
||||
@click="
|
||||
() =>
|
||||
navigateTo('https://discord.modrinth.com', {
|
||||
external: true,
|
||||
})
|
||||
"
|
||||
>
|
||||
<button class="mt-6 !w-full">Join Modrinth Discord</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
:disabled="formattedTime !== '00'"
|
||||
size="large"
|
||||
color="standard"
|
||||
@click="() => reloadNuxtApp()"
|
||||
>
|
||||
<button class="mt-3 !w-full">Reload</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="server.general?.error"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
data-pyro-navigation
|
||||
class="isolate flex w-full select-none flex-col justify-between gap-4 overflow-auto md:flex-row md:items-center"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<TransferIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Connection lost</h1>
|
||||
<div class="text-center text-secondary">
|
||||
{{
|
||||
formattedTime == "00"
|
||||
? "Reconnecting..."
|
||||
: `Retrying in ${formattedTime} seconds...`
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
Something went wrong, and we couldn't connect to your server. This is likely due to a
|
||||
temporary network issue. You'll be reconnected automatically.
|
||||
</p>
|
||||
</div>
|
||||
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
|
||||
<ButtonStyled
|
||||
:disabled="formattedTime !== '00'"
|
||||
size="large"
|
||||
color="brand"
|
||||
@click="() => reloadNuxtApp()"
|
||||
>
|
||||
<button class="mt-6 !w-full">Reload</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<UiNavTabs :links="navLinks" />
|
||||
</div>
|
||||
<!-- SERVER START -->
|
||||
<div
|
||||
v-else-if="serverData"
|
||||
data-pyro-server-manager-root
|
||||
class="experimental-styles-within mobile-blurred-servericon relative mx-auto box-border flex min-h-screen w-full min-w-0 max-w-[1280px] flex-col gap-6 px-6 transition-all duration-300"
|
||||
:style="{
|
||||
'--server-bg-image': serverData.image
|
||||
? `url(${serverData.image})`
|
||||
: `linear-gradient(180deg, rgba(153,153,153,1) 0%, rgba(87,87,87,1) 100%)`,
|
||||
}"
|
||||
>
|
||||
<div class="flex w-full min-w-0 select-none flex-col items-center gap-6 pt-4 sm:flex-row">
|
||||
<UiServersServerIcon :image="serverData.image" class="drop-shadow-lg sm:drop-shadow-none" />
|
||||
<div
|
||||
class="flex min-w-0 flex-1 flex-col-reverse items-center gap-2 sm:flex-col sm:items-start"
|
||||
>
|
||||
<div class="hidden shrink-0 flex-row items-center gap-1 sm:flex">
|
||||
<NuxtLink to="/servers/manage" class="breadcrumb goto-link flex w-fit items-center">
|
||||
<LeftArrowIcon />
|
||||
All servers
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="flex w-full flex-col items-center gap-4 sm:flex-row">
|
||||
<h1
|
||||
class="m-0 w-screen flex-shrink gap-3 truncate px-3 text-center text-4xl font-bold text-contrast sm:w-full sm:p-0 sm:text-left"
|
||||
>
|
||||
{{ serverData.name }}
|
||||
</h1>
|
||||
<div
|
||||
v-if="isConnected"
|
||||
data-pyro-server-action-buttons
|
||||
class="server-action-buttons-anim flex w-fit flex-shrink-0"
|
||||
>
|
||||
<UiServersPanelServerActionButton
|
||||
class="flex-shrink-0"
|
||||
:is-online="isServerRunning"
|
||||
:is-actioning="isActioning"
|
||||
:is-installing="serverData.status === 'installing'"
|
||||
:disabled="isActioning || !!error"
|
||||
:server-name="serverData.name"
|
||||
:server-data="serverData"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
@action="sendPowerAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiServersServerInfoLabels
|
||||
:server-data="serverData"
|
||||
:show-game-label="showGameLabel"
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
:linked="true"
|
||||
class="server-action-buttons-anim flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||
/>
|
||||
<div data-pyro-mount class="h-full w-full flex-1">
|
||||
<div
|
||||
v-if="error"
|
||||
class="mx-auto mb-4 flex justify-between gap-2 rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-4">
|
||||
<IssuesIcon class="hidden h-8 w-8 shrink-0 text-red sm:block" />
|
||||
<div class="flex flex-col gap-2 leading-[150%]">
|
||||
<div class="flex items-center gap-3">
|
||||
<IssuesIcon class="flex h-8 w-8 shrink-0 text-red sm:hidden" />
|
||||
<div class="flex gap-2 text-2xl font-bold">{{ errorTitle }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errorTitle.toLocaleLowerCase() === 'installation error'" class="font-normal">
|
||||
<div
|
||||
v-if="errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'"
|
||||
>
|
||||
An invalid loader or Minecraft version was specified and could not be installed.
|
||||
<ul class="m-0 mt-4 p-0 pl-4">
|
||||
<li>
|
||||
If this version of Minecraft was released recently, please check if Modrinth
|
||||
Servers supports it.
|
||||
</li>
|
||||
<li>
|
||||
If you've installed a modpack, it may have been packaged incorrectly or may not
|
||||
be compatible with the loader.
|
||||
</li>
|
||||
<li>
|
||||
Your server may need to be reinstalled with a valid mod loader and version. You
|
||||
can change the loader by clicking the "Change Loader" button.
|
||||
</li>
|
||||
<li>
|
||||
If you're stuck, please contact Modrinth support with the information below:
|
||||
</li>
|
||||
</ul>
|
||||
<ButtonStyled>
|
||||
<button class="mt-2" @click="copyServerDebugInfo">
|
||||
<CopyIcon v-if="!copied" />
|
||||
<CheckIcon v-else />
|
||||
Copy Debug Info
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
|
||||
An internal error occurred while installing your server. Don't fret — try
|
||||
reinstalling your server, and if the problem persists, please contact Modrinth
|
||||
support with your server's debug information.
|
||||
</div>
|
||||
<div v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'">
|
||||
An error occurred while installing your server because Modrinth Servers does not
|
||||
support the version of Minecraft or the loader you specified. Try reinstalling your
|
||||
server with a different version or loader, and if the problem persists, please
|
||||
contact Modrinth support with your server's debug information.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorTitle === 'Installation error'"
|
||||
class="mt-2 flex flex-col gap-4 sm:flex-row"
|
||||
>
|
||||
<ButtonStyled v-if="errorLog">
|
||||
<button @click="openInstallLog"><FileIcon />Open Installation Log</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="copyServerDebugInfo">
|
||||
<CopyIcon v-if="!copied" />
|
||||
<CheckIcon v-else />
|
||||
Copy Debug Info
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red" type="standard">
|
||||
<NuxtLink
|
||||
class="whitespace-pre"
|
||||
:to="`/servers/manage/${serverId}/options/loader`"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
Change Loader
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-pyro-navigation
|
||||
class="isolate flex w-full select-none flex-col justify-between gap-4 overflow-auto md:flex-row md:items-center"
|
||||
v-if="!isConnected && !isReconnecting && !isLoading"
|
||||
data-pyro-server-ws-error
|
||||
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-red p-4 text-contrast"
|
||||
>
|
||||
<UiNavTabs :links="navLinks" />
|
||||
<IssuesIcon class="size-5 text-red" />
|
||||
Something went wrong...
|
||||
</div>
|
||||
|
||||
<div data-pyro-mount class="h-full w-full flex-1">
|
||||
<div
|
||||
v-if="error"
|
||||
class="mx-auto mb-4 flex justify-between gap-2 rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-4">
|
||||
<IssuesIcon class="hidden h-8 w-8 shrink-0 text-red sm:block" />
|
||||
<div class="flex flex-col gap-2 leading-[150%]">
|
||||
<div class="flex items-center gap-3">
|
||||
<IssuesIcon class="flex h-8 w-8 shrink-0 text-red sm:hidden" />
|
||||
<div class="flex gap-2 text-2xl font-bold">{{ errorTitle }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorTitle.toLocaleLowerCase() === 'installation error'"
|
||||
class="font-normal"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'
|
||||
"
|
||||
>
|
||||
An invalid loader or Minecraft version was specified and could not be installed.
|
||||
<ul class="m-0 mt-4 p-0 pl-4">
|
||||
<li>
|
||||
If this version of Minecraft was released recently, please check if Modrinth
|
||||
Servers supports it.
|
||||
</li>
|
||||
<li>
|
||||
If you've installed a modpack, it may have been packaged incorrectly or may
|
||||
not be compatible with the loader.
|
||||
</li>
|
||||
<li>
|
||||
Your server may need to be reinstalled with a valid mod loader and version.
|
||||
You can change the loader by clicking the "Change Loader" button.
|
||||
</li>
|
||||
<li>
|
||||
If you're stuck, please contact Modrinth support with the information below:
|
||||
</li>
|
||||
</ul>
|
||||
<ButtonStyled>
|
||||
<button class="mt-2" @click="copyServerDebugInfo">
|
||||
<CopyIcon v-if="!copied" />
|
||||
<CheckIcon v-else />
|
||||
Copy Debug Info
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
|
||||
An internal error occurred while installing your server. Don't fret — try
|
||||
reinstalling your server, and if the problem persists, please contact Modrinth
|
||||
support with your server's debug information.
|
||||
</div>
|
||||
<div
|
||||
v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'"
|
||||
>
|
||||
An error occurred while installing your server because Modrinth Servers does not
|
||||
support the version of Minecraft or the loader you specified. Try reinstalling
|
||||
your server with a different version or loader, and if the problem persists,
|
||||
please contact Modrinth support with your server's debug information.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorTitle === 'Installation error'"
|
||||
class="mt-2 flex flex-col gap-4 sm:flex-row"
|
||||
>
|
||||
<ButtonStyled v-if="errorLog">
|
||||
<button @click="openInstallLog"><FileIcon />Open Installation Log</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="copyServerDebugInfo">
|
||||
<CopyIcon v-if="!copied" />
|
||||
<CheckIcon v-else />
|
||||
Copy Debug Info
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red" type="standard">
|
||||
<NuxtLink
|
||||
class="whitespace-pre"
|
||||
:to="`/servers/manage/${serverId}/options/loader`"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
Change Loader
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isConnected && !isReconnecting && !isLoading"
|
||||
data-pyro-server-ws-error
|
||||
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-red p-4 text-contrast"
|
||||
>
|
||||
<IssuesIcon class="size-5 text-red" />
|
||||
Something went wrong...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isReconnecting"
|
||||
data-pyro-server-ws-reconnecting
|
||||
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
|
||||
>
|
||||
<UiServersPanelSpinner />
|
||||
Hang on, we're reconnecting to your server.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="serverData.status === 'installing'"
|
||||
data-pyro-server-installing
|
||||
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
|
||||
>
|
||||
<UiServersServerIcon :image="serverData.image" class="!h-10 !w-10" />
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold"> We're preparing your server! </span>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<UiServersPanelSpinner class="!h-3 !w-3" /> <LazyUiServersInstallingTicker />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NuxtPage
|
||||
:route="route"
|
||||
:is-connected="isConnected"
|
||||
:is-ws-auth-incorrect="isWSAuthIncorrect"
|
||||
:is-server-running="isServerRunning"
|
||||
:stats="stats"
|
||||
:server-power-state="serverPowerState"
|
||||
:power-state-details="powerStateDetails"
|
||||
:socket="socket"
|
||||
:server="server"
|
||||
@reinstall="onReinstall"
|
||||
/>
|
||||
<div
|
||||
v-if="isReconnecting"
|
||||
data-pyro-server-ws-reconnecting
|
||||
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
|
||||
>
|
||||
<UiServersPanelSpinner />
|
||||
Hang on, we're reconnecting to your server.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="serverData.status === 'installing'"
|
||||
data-pyro-server-installing
|
||||
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
|
||||
>
|
||||
<UiServersServerIcon :image="serverData.image" class="!h-10 !w-10" />
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold"> We're preparing your server! </span>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<UiServersPanelSpinner class="!h-3 !w-3" /> <LazyUiServersInstallingTicker />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NuxtPage
|
||||
:route="route"
|
||||
:is-connected="isConnected"
|
||||
:is-ws-auth-incorrect="isWSAuthIncorrect"
|
||||
:is-server-running="isServerRunning"
|
||||
:stats="stats"
|
||||
:server-power-state="serverPowerState"
|
||||
:power-state-details="powerStateDetails"
|
||||
:socket="socket"
|
||||
:server="server"
|
||||
:backup-in-progress="backupInProgress"
|
||||
@reinstall="onReinstall"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -398,11 +403,16 @@ import {
|
||||
LockIcon,
|
||||
} from "@modrinth/assets";
|
||||
import DOMPurify from "dompurify";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ButtonStyled, ServerNotice } from "@modrinth/ui";
|
||||
import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
|
||||
import { reloadNuxtApp, navigateTo } from "#app";
|
||||
import type { MessageDescriptor } from "@vintl/vintl";
|
||||
import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers";
|
||||
import { usePyroConsole } from "~/store/console.ts";
|
||||
import { type Backup } from "~/composables/pyroServers.ts";
|
||||
import { usePyroFetch } from "~/composables/pyroFetch.ts";
|
||||
|
||||
const app = useNuxtApp() as unknown as { $notify: any };
|
||||
|
||||
const socket = ref<WebSocket | null>(null);
|
||||
const isReconnecting = ref(false);
|
||||
@@ -412,15 +422,13 @@ const isFirstMount = ref(true);
|
||||
const isMounted = ref(true);
|
||||
|
||||
const INTERCOM_APP_ID = ref("ykeritl9");
|
||||
const auth = await useAuth();
|
||||
// @ts-expect-error - Auth is untyped
|
||||
const auth = (await useAuth()) as unknown as {
|
||||
value: { user: { id: string; username: string; email: string; created: string } };
|
||||
};
|
||||
const userId = ref(auth.value?.user?.id ?? null);
|
||||
// @ts-expect-error - Auth is untyped
|
||||
const username = ref(auth.value?.user?.username ?? null);
|
||||
// @ts-expect-error - Auth is untyped
|
||||
const email = ref(auth.value?.user?.email ?? null);
|
||||
const createdAt = ref(
|
||||
// @ts-expect-error - Auth is untyped
|
||||
auth.value?.user?.created ? Math.floor(new Date(auth.value.user.created).getTime() / 1000) : null,
|
||||
);
|
||||
|
||||
@@ -526,6 +534,99 @@ const navLinks = [
|
||||
},
|
||||
];
|
||||
|
||||
const filteredNotices = computed(
|
||||
() => serverData.value?.notices?.filter((n) => n.level !== "survey") ?? [],
|
||||
);
|
||||
const surveyNotice = computed(() => serverData.value?.notices?.find((n) => n.level === "survey"));
|
||||
|
||||
async function dismissSurvey() {
|
||||
const noticeId = surveyNotice.value?.id;
|
||||
if (noticeId === undefined) {
|
||||
console.warn("No survey notice to dismiss");
|
||||
return;
|
||||
}
|
||||
await dismissNotice(noticeId);
|
||||
console.log(`Dismissed survey notice ${noticeId}`);
|
||||
}
|
||||
|
||||
type TallyPopupOptions = {
|
||||
key?: string;
|
||||
layout?: "default" | "modal";
|
||||
width?: number;
|
||||
alignLeft?: boolean;
|
||||
hideTitle?: boolean;
|
||||
overlay?: boolean;
|
||||
emoji?: {
|
||||
text: string;
|
||||
animation:
|
||||
| "none"
|
||||
| "wave"
|
||||
| "tada"
|
||||
| "heart-beat"
|
||||
| "spin"
|
||||
| "flash"
|
||||
| "bounce"
|
||||
| "rubber-band"
|
||||
| "head-shake";
|
||||
};
|
||||
autoClose?: number;
|
||||
showOnce?: boolean;
|
||||
doNotShowAfterSubmit?: boolean;
|
||||
customFormUrl?: string;
|
||||
hiddenFields?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
onPageView?: (page: number) => void;
|
||||
onSubmit?: (payload: unknown) => void;
|
||||
};
|
||||
|
||||
const popupOptions = computed(
|
||||
() =>
|
||||
({
|
||||
layout: "default",
|
||||
width: 400,
|
||||
autoClose: 2000,
|
||||
hideTitle: true,
|
||||
hiddenFields: {
|
||||
username: auth.value?.user?.username,
|
||||
user_id: auth.value?.user?.id,
|
||||
user_email: auth.value?.user?.email,
|
||||
server_id: serverData.value?.server_id,
|
||||
loader: serverData.value?.loader,
|
||||
game_version: serverData.value?.mc_version,
|
||||
modpack_id: serverData.value?.project?.id,
|
||||
modpack_name: serverData.value?.project?.title,
|
||||
},
|
||||
onOpen: () => console.log(`Opened survey notice: ${surveyNotice.value?.id}`),
|
||||
onClose: async () => await dismissSurvey(),
|
||||
onSubmit: (payload: any) => {
|
||||
console.log("Form submitted:", payload);
|
||||
},
|
||||
}) satisfies TallyPopupOptions,
|
||||
);
|
||||
|
||||
function showSurvey() {
|
||||
if (!surveyNotice.value) {
|
||||
console.warn("No survey notice to open");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if ((window as any).Tally?.openPopup) {
|
||||
console.log(
|
||||
`Opening Tally popup for survey notice ${surveyNotice.value?.id} (form ID: ${surveyNotice.value?.message})`,
|
||||
);
|
||||
(window as any).Tally.openPopup(surveyNotice.value?.message, popupOptions.value);
|
||||
} else {
|
||||
console.warn("Tally script not yet loaded");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error opening Tally popup:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const connectWebSocket = () => {
|
||||
if (!isMounted.value) return;
|
||||
|
||||
@@ -661,6 +762,31 @@ const handleWebSocketMessage = (data: WSEvent) => {
|
||||
uptimeSeconds.value = data.uptime;
|
||||
startUptimeUpdates();
|
||||
break;
|
||||
case "backup-progress": {
|
||||
// Update a backup's state
|
||||
const curBackup = server.backups?.data.find((backup) => backup.id === data.id);
|
||||
|
||||
if (!curBackup) {
|
||||
console.log(`Ignoring backup-progress event for unknown backup: ${data.id}`);
|
||||
} else {
|
||||
console.log(
|
||||
`Handling backup progress for ${curBackup.name} (${data.id}) task: ${data.task} state: ${data.state} progress: ${data.progress}`,
|
||||
);
|
||||
|
||||
if (!curBackup.task) {
|
||||
curBackup.task = {};
|
||||
}
|
||||
|
||||
curBackup.task[data.task] = {
|
||||
progress: data.progress,
|
||||
state: data.state,
|
||||
};
|
||||
|
||||
curBackup.ongoing = data.task === "create" && data.state === "ongoing";
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.warn("Unhandled WebSocket event:", data);
|
||||
}
|
||||
@@ -859,6 +985,41 @@ const formattedTime = computed(() => {
|
||||
return `${seconds.toString().padStart(2, "0")}`;
|
||||
});
|
||||
|
||||
export type BackupInProgressReason = {
|
||||
type: string;
|
||||
tooltip: MessageDescriptor;
|
||||
};
|
||||
|
||||
const RestoreInProgressReason = {
|
||||
type: "restore",
|
||||
tooltip: defineMessage({
|
||||
id: "servers.backup.restore.in-progress.tooltip",
|
||||
defaultMessage: "Backup restore in progress",
|
||||
}),
|
||||
} satisfies BackupInProgressReason;
|
||||
|
||||
const CreateInProgressReason = {
|
||||
type: "create",
|
||||
tooltip: defineMessage({
|
||||
id: "servers.backup.create.in-progress.tooltip",
|
||||
defaultMessage: "Backup creation in progress",
|
||||
}),
|
||||
} satisfies BackupInProgressReason;
|
||||
|
||||
const backupInProgress = computed(() => {
|
||||
const backups = server.backups?.data;
|
||||
if (!backups) {
|
||||
return undefined;
|
||||
}
|
||||
if (backups.find((backup: Backup) => backup?.task?.create?.state === "ongoing")) {
|
||||
return CreateInProgressReason;
|
||||
}
|
||||
if (backups.find((backup: Backup) => backup?.task?.restore?.state === "ongoing")) {
|
||||
return RestoreInProgressReason;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const stopPolling = () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
@@ -927,6 +1088,20 @@ const cleanup = () => {
|
||||
DOMPurify.removeHook("afterSanitizeAttributes");
|
||||
};
|
||||
|
||||
async function dismissNotice(noticeId: number) {
|
||||
await usePyroFetch(`servers/${serverId}/notices/${noticeId}/dismiss`, {
|
||||
method: "POST",
|
||||
}).catch((err) => {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "Error dismissing notice",
|
||||
text: err,
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
await server.refresh(["general"]);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true;
|
||||
if (server.general?.status === "suspended") {
|
||||
@@ -974,6 +1149,10 @@ onMounted(() => {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (surveyNotice.value) {
|
||||
showSurvey();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -998,6 +1177,15 @@ watch(
|
||||
definePageMeta({
|
||||
middleware: "auth",
|
||||
});
|
||||
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
src: "https://tally.so/widgets/embed.js",
|
||||
defer: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,248 +1,147 @@
|
||||
<template>
|
||||
<div class="contents">
|
||||
<div
|
||||
v-if="server.backups?.error"
|
||||
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<IssuesIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load backups</h1>
|
||||
<div
|
||||
v-if="server.backups?.error"
|
||||
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<IssuesIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
We couldn't load your server's backups. Here's what went wrong:
|
||||
</p>
|
||||
<p>
|
||||
<span class="break-all font-mono">{{ JSON.stringify(server.backups.error) }}</span>
|
||||
</p>
|
||||
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['backups'])">
|
||||
<button class="mt-6 !w-full">Retry</button>
|
||||
</ButtonStyled>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load backups</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
We couldn't load your server's backups. Here's what went wrong:
|
||||
</p>
|
||||
<p>
|
||||
<span class="break-all font-mono">{{ JSON.stringify(server.backups.error) }}</span>
|
||||
</p>
|
||||
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['backups'])">
|
||||
<button class="mt-6 !w-full">Retry</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="data" class="contents">
|
||||
<LazyUiServersBackupCreateModal
|
||||
ref="createBackupModal"
|
||||
:server="server"
|
||||
@backup-created="handleBackupCreated"
|
||||
/>
|
||||
<LazyUiServersBackupRenameModal
|
||||
ref="renameBackupModal"
|
||||
:server="server"
|
||||
:current-backup-id="currentBackup"
|
||||
:backup-name="renameBackupName"
|
||||
@backup-renamed="handleBackupRenamed"
|
||||
/>
|
||||
<LazyUiServersBackupRestoreModal
|
||||
ref="restoreBackupModal"
|
||||
:server="server"
|
||||
:backup-id="currentBackup"
|
||||
:backup-name="currentBackupDetails?.name ?? ''"
|
||||
:backup-created-at="currentBackupDetails?.created_at ?? ''"
|
||||
@backup-restored="handleBackupRestored"
|
||||
/>
|
||||
<LazyUiServersBackupDeleteModal
|
||||
ref="deleteBackupModal"
|
||||
:server="server"
|
||||
:backup-id="currentBackup"
|
||||
:backup-name="currentBackupDetails?.name ?? ''"
|
||||
:backup-created-at="currentBackupDetails?.created_at ?? ''"
|
||||
@backup-deleted="handleBackupDeleted"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="data" class="contents">
|
||||
<BackupCreateModal ref="createBackupModal" :server="server" />
|
||||
<BackupRenameModal ref="renameBackupModal" :server="server" />
|
||||
<BackupRestoreModal ref="restoreBackupModal" :server="server" />
|
||||
<BackupDeleteModal ref="deleteBackupModal" :server="server" @delete="deleteBackup" />
|
||||
<BackupSettingsModal ref="backupSettingsModal" :server="server" />
|
||||
|
||||
<LazyUiServersBackupSettingsModal ref="backupSettingsModal" :server="server" />
|
||||
|
||||
<ul class="m-0 flex list-none flex-col gap-4 p-0">
|
||||
<div class="relative w-full overflow-hidden rounded-2xl bg-bg-raised p-6 shadow-md">
|
||||
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row sm:gap-0">
|
||||
<div class="flex flex-col items-baseline gap-2">
|
||||
<div class="text-2xl font-bold text-contrast">
|
||||
{{
|
||||
data.used_backup_quota === 0
|
||||
? "No backups"
|
||||
: `You've created ${data.used_backup_quota} backup${data.used_backup_quota === 1 ? "" : "s"}`
|
||||
}}
|
||||
</div>
|
||||
<div>
|
||||
{{
|
||||
data.backup_quota - data.used_backup_quota === 0
|
||||
? "You have reached your backup limit. Consider removing old backups to create new ones."
|
||||
: `You can create ${data.backup_quota - data.used_backup_quota} more backups for your server.`
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2 sm:w-fit sm:flex-row">
|
||||
<ButtonStyled type="standard">
|
||||
<button
|
||||
:disabled="server.general?.status === 'installing'"
|
||||
@click="showbackupSettingsModal"
|
||||
>
|
||||
<SettingsIcon class="h-5 w-5" />
|
||||
Auto backups
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="standard" color="brand">
|
||||
<button
|
||||
v-tooltip="
|
||||
isServerRunning && !userPreferences.backupWhileRunning
|
||||
? 'Cannot create backup while server is running. You can disable this from your server Options > Preferences.'
|
||||
: server.general?.status === 'installing'
|
||||
? 'Cannot create backups while server is being installed'
|
||||
: ''
|
||||
"
|
||||
class="w-full sm:w-fit"
|
||||
:disabled="
|
||||
(isServerRunning && !userPreferences.backupWhileRunning) ||
|
||||
data.used_backup_quota >= data.backup_quota ||
|
||||
backups.some((backup) => backup.ongoing) ||
|
||||
server.general?.status === 'installing'
|
||||
"
|
||||
@click="showCreateModel"
|
||||
>
|
||||
<PlusIcon class="h-5 w-5" />
|
||||
Create backup
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="backups.some((backup) => backup.ongoing)"
|
||||
data-pyro-server-backup-ongoing
|
||||
class="flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-contrast"
|
||||
>
|
||||
A backup is currently being created. This may take a few minutes. This page will
|
||||
automatically refresh when the backup is complete.
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<li
|
||||
v-for="(backup, index) in backups"
|
||||
:key="backup.id"
|
||||
class="relative m-0 w-full list-none rounded-2xl bg-bg-raised p-2 shadow-md"
|
||||
<div class="mb-6 flex flex-col items-center justify-between gap-4 sm:flex-row">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="m-0 text-2xl font-extrabold text-contrast">Backups</h1>
|
||||
<TagItem
|
||||
v-tooltip="`${data.backup_quota - data.used_backup_quota} backup slots remaining`"
|
||||
class="cursor-help"
|
||||
:style="{
|
||||
'--_color':
|
||||
data.backup_quota <= data.used_backup_quota
|
||||
? 'var(--color-red)'
|
||||
: data.backup_quota - data.used_backup_quota <= 3
|
||||
? 'var(--color-orange)'
|
||||
: undefined,
|
||||
'--_bg-color':
|
||||
data.backup_quota <= data.used_backup_quota
|
||||
? 'var(--color-red-bg)'
|
||||
: data.backup_quota - data.used_backup_quota <= 3
|
||||
? 'var(--color-orange-bg)'
|
||||
: undefined,
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex min-w-0 flex-row items-center gap-4">
|
||||
<div
|
||||
class="grid size-14 shrink-0 place-content-center overflow-hidden rounded-xl border-[1px] border-solid border-button-border shadow-sm"
|
||||
:class="backup.ongoing ? 'text-green [&&]:bg-bg-green' : 'bg-button-bg'"
|
||||
>
|
||||
<UiServersIconsLoadingIcon
|
||||
v-if="backup.ongoing"
|
||||
v-tooltip="'Backup in progress'"
|
||||
class="size-6 animate-spin"
|
||||
/>
|
||||
<LockIcon v-else-if="backup.locked" class="size-8" />
|
||||
<BoxIcon v-else class="size-8" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div class="max-w-full truncate font-bold text-contrast">
|
||||
{{ backup.name }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="index == 0"
|
||||
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
|
||||
>
|
||||
<CheckIcon class="size-4" /> Latest
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-xs">
|
||||
<CalendarIcon class="size-4" />
|
||||
{{
|
||||
new Date(backup.created_at).toLocaleString("en-US", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "2-digit",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled v-if="!backup.ongoing" circular type="transparent">
|
||||
<UiServersTeleportOverflowMenu
|
||||
direction="left"
|
||||
position="bottom"
|
||||
class="bg-transparent"
|
||||
:disabled="backups.some((b) => b.ongoing)"
|
||||
:options="[
|
||||
{
|
||||
id: 'rename',
|
||||
action: () => {
|
||||
renameBackupName = backup.name;
|
||||
currentBackup = backup.id;
|
||||
renameBackupModal?.show();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'restore',
|
||||
action: () => {
|
||||
currentBackup = backup.id;
|
||||
restoreBackupModal?.show();
|
||||
},
|
||||
},
|
||||
{ id: 'download', action: () => initiateDownload(backup.id) },
|
||||
{
|
||||
id: 'lock',
|
||||
action: () => {
|
||||
if (backup.locked) {
|
||||
unlockBackup(backup.id);
|
||||
} else {
|
||||
lockBackup(backup.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
action: () => {
|
||||
currentBackup = backup.id;
|
||||
deleteBackupModal?.show();
|
||||
},
|
||||
color: 'red',
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
||||
<template #rename> <EditIcon /> Rename </template>
|
||||
<template #restore> <ClipboardCopyIcon /> Restore </template>
|
||||
<template v-if="backup.locked" #lock> <LockOpenIcon /> Unlock </template>
|
||||
<template v-else #lock> <LockIcon /> Lock </template>
|
||||
<template #download> <DownloadIcon /> Download </template>
|
||||
<template #delete> <TrashIcon /> Delete </template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{{ data.used_backup_quota }} / {{ data.backup_quota }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</ul>
|
||||
|
||||
<p class="m-0">
|
||||
You can have up to {{ data.backup_quota }} backups at once, securely off-site with
|
||||
Backblaze.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="over-the-top-download-animation"
|
||||
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
|
||||
class="grid w-full grid-cols-[repeat(auto-fit,_minmax(180px,1fr))] gap-2 sm:flex sm:w-fit sm:flex-row"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
|
||||
></div>
|
||||
<div
|
||||
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
|
||||
></div>
|
||||
<div
|
||||
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
|
||||
<ButtonStyled type="standard">
|
||||
<button
|
||||
v-tooltip="
|
||||
'Auto backups are currently unavailable; we apologize for the inconvenience.'
|
||||
"
|
||||
:disabled="true || server.general?.status === 'installing'"
|
||||
@click="showbackupSettingsModal"
|
||||
>
|
||||
<DownloadIcon class="h-20 w-20 text-contrast" />
|
||||
</div>
|
||||
<SettingsIcon class="h-5 w-5" />
|
||||
Auto backups
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="standard" color="brand">
|
||||
<button
|
||||
v-tooltip="backupCreationDisabled"
|
||||
class="w-full sm:w-fit"
|
||||
:disabled="!!backupCreationDisabled"
|
||||
@click="showCreateModel"
|
||||
>
|
||||
<PlusIcon class="h-5 w-5" />
|
||||
Create backup
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div
|
||||
v-if="backups.length === 0"
|
||||
class="mt-6 flex items-center justify-center gap-2 text-center text-secondary"
|
||||
>
|
||||
<template v-if="data.used_backup_quota">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
Loading backups...
|
||||
</template>
|
||||
<template v-else> You don't have any backups yet. </template>
|
||||
</div>
|
||||
<BackupItem
|
||||
v-for="backup in backups"
|
||||
:key="`backup-${backup.id}`"
|
||||
:backup="backup"
|
||||
:kyros-url="props.server.general?.node.instance"
|
||||
:jwt="props.server.general?.node.token"
|
||||
@prepare="() => prepareDownload(backup.id)"
|
||||
@download="() => triggerDownloadAnimation()"
|
||||
@rename="() => renameBackupModal?.show(backup)"
|
||||
@restore="() => restoreBackupModal?.show(backup)"
|
||||
@lock="
|
||||
() => {
|
||||
if (backup.locked) {
|
||||
unlockBackup(backup.id);
|
||||
} else {
|
||||
lockBackup(backup.id);
|
||||
}
|
||||
}
|
||||
"
|
||||
@delete="
|
||||
(skipConfirmation?: boolean) =>
|
||||
!skipConfirmation ? deleteBackup(backup) : deleteBackupModal?.show(backup)
|
||||
"
|
||||
@retry="() => retryBackup(backup.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="over-the-top-download-animation"
|
||||
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
|
||||
></div>
|
||||
<div
|
||||
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
|
||||
></div>
|
||||
<div
|
||||
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
|
||||
>
|
||||
<DownloadIcon class="h-20 w-20 text-contrast" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -250,25 +149,17 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { ButtonStyled, TagItem } from "@modrinth/ui";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import {
|
||||
PlusIcon,
|
||||
CheckIcon,
|
||||
CalendarIcon,
|
||||
MoreHorizontalIcon,
|
||||
EditIcon,
|
||||
ClipboardCopyIcon,
|
||||
DownloadIcon,
|
||||
TrashIcon,
|
||||
SettingsIcon,
|
||||
BoxIcon,
|
||||
LockIcon,
|
||||
LockOpenIcon,
|
||||
IssuesIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { SpinnerIcon, PlusIcon, DownloadIcon, SettingsIcon, IssuesIcon } from "@modrinth/assets";
|
||||
import { ref, computed } from "vue";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import BackupItem from "~/components/ui/servers/BackupItem.vue";
|
||||
import BackupRenameModal from "~/components/ui/servers/BackupRenameModal.vue";
|
||||
import BackupCreateModal from "~/components/ui/servers/BackupCreateModal.vue";
|
||||
import BackupRestoreModal from "~/components/ui/servers/BackupRestoreModal.vue";
|
||||
import BackupDeleteModal from "~/components/ui/servers/BackupDeleteModal.vue";
|
||||
import BackupSettingsModal from "~/components/ui/servers/BackupSettingsModal.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
@@ -299,19 +190,30 @@ useHead({
|
||||
|
||||
const overTheTopDownloadAnimation = ref();
|
||||
|
||||
const createBackupModal = ref<typeof NewModal>();
|
||||
const renameBackupModal = ref<typeof NewModal>();
|
||||
const restoreBackupModal = ref<typeof NewModal>();
|
||||
const deleteBackupModal = ref<typeof NewModal>();
|
||||
const backupSettingsModal = ref<typeof NewModal>();
|
||||
const createBackupModal = ref<InstanceType<typeof BackupCreateModal>>();
|
||||
const renameBackupModal = ref<InstanceType<typeof BackupRenameModal>>();
|
||||
const restoreBackupModal = ref<InstanceType<typeof BackupRestoreModal>>();
|
||||
const deleteBackupModal = ref<InstanceType<typeof BackupDeleteModal>>();
|
||||
const backupSettingsModal = ref<InstanceType<typeof BackupSettingsModal>>();
|
||||
|
||||
const renameBackupName = ref("");
|
||||
const currentBackup = ref("");
|
||||
|
||||
const refreshInterval = ref<ReturnType<typeof setInterval>>();
|
||||
|
||||
const currentBackupDetails = computed(() => {
|
||||
return backups.value.find((backup) => backup.id === currentBackup.value);
|
||||
const backupCreationDisabled = computed(() => {
|
||||
if (props.isServerRunning && !userPreferences.value.backupWhileRunning) {
|
||||
return "Cannot create backup while server is running";
|
||||
}
|
||||
if (
|
||||
data.value?.used_backup_quota !== undefined &&
|
||||
data.value?.backup_quota !== undefined &&
|
||||
data.value?.used_backup_quota >= data.value?.backup_quota
|
||||
) {
|
||||
return `All ${data.value.backup_quota} of your backup slots are in use`;
|
||||
}
|
||||
if (backups.value.some((backup) => backup.task?.create?.state === "ongoing")) {
|
||||
return "A backup is already in progress";
|
||||
}
|
||||
if (props.server.general?.status === "installing") {
|
||||
return "Cannot create backup while server is installing";
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const showCreateModel = () => {
|
||||
@@ -322,69 +224,17 @@ const showbackupSettingsModal = () => {
|
||||
backupSettingsModal.value?.show();
|
||||
};
|
||||
|
||||
const handleBackupCreated = async (payload: { success: boolean; message: string }) => {
|
||||
if (payload.success) {
|
||||
addNotification({ type: "success", text: payload.message });
|
||||
await props.server.refresh(["backups"]);
|
||||
} else {
|
||||
addNotification({ type: "error", text: payload.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackupRenamed = async (payload: { success: boolean; message: string }) => {
|
||||
if (payload.success) {
|
||||
addNotification({ type: "success", text: payload.message });
|
||||
await props.server.refresh(["backups"]);
|
||||
} else {
|
||||
addNotification({ type: "error", text: payload.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackupRestored = async (payload: { success: boolean; message: string }) => {
|
||||
if (payload.success) {
|
||||
addNotification({ type: "success", text: payload.message });
|
||||
await props.server.refresh(["backups"]);
|
||||
} else {
|
||||
addNotification({ type: "error", text: payload.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackupDeleted = async (payload: { success: boolean; message: string }) => {
|
||||
if (payload.success) {
|
||||
addNotification({ type: "success", text: payload.message });
|
||||
await props.server.refresh(["backups"]);
|
||||
} else {
|
||||
addNotification({ type: "error", text: payload.message });
|
||||
}
|
||||
};
|
||||
|
||||
function triggerDownloadAnimation() {
|
||||
overTheTopDownloadAnimation.value = true;
|
||||
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500);
|
||||
}
|
||||
|
||||
const initiateDownload = async (backupId: string) => {
|
||||
triggerDownloadAnimation();
|
||||
|
||||
const prepareDownload = async (backupId: string) => {
|
||||
try {
|
||||
const downloadurl: any = await props.server.backups?.download(backupId);
|
||||
if (!downloadurl || !downloadurl.download_url) {
|
||||
throw new Error("Invalid download URL.");
|
||||
}
|
||||
|
||||
let finalDownloadUrl: string = downloadurl.download_url;
|
||||
|
||||
if (!/^https?:\/\//i.test(finalDownloadUrl)) {
|
||||
finalDownloadUrl = `https://${finalDownloadUrl.startsWith("/") ? finalDownloadUrl.substring(1) : finalDownloadUrl}`;
|
||||
}
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = finalDownloadUrl;
|
||||
a.setAttribute("download", "");
|
||||
a.click();
|
||||
a.remove();
|
||||
await props.server.backups?.prepare(backupId);
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
console.error("Failed to prepare download:", error);
|
||||
addNotification({ type: "error", title: "Failed to prepare backup for download", text: error });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -406,28 +256,37 @@ const unlockBackup = async (backupId: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
watchEffect(() => {
|
||||
const hasOngoingBackups = backups.value.some((backup) => backup.ongoing);
|
||||
|
||||
if (refreshInterval.value) {
|
||||
clearInterval(refreshInterval.value);
|
||||
refreshInterval.value = undefined;
|
||||
}
|
||||
|
||||
if (hasOngoingBackups) {
|
||||
refreshInterval.value = setInterval(async () => {
|
||||
await props.server.refresh(["backups"]);
|
||||
}, 10000);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval.value) {
|
||||
clearInterval(refreshInterval.value);
|
||||
const retryBackup = async (backupId: string) => {
|
||||
try {
|
||||
await props.server.backups?.retry(backupId);
|
||||
await props.server.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Failed to retry backup:", error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
async function deleteBackup(backup?: Backup) {
|
||||
if (!backup) {
|
||||
addNotification({
|
||||
type: "error",
|
||||
title: "Error deleting backup",
|
||||
text: "Backup is null",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await props.server.backups?.delete(backup.id);
|
||||
await props.server.refresh();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
addNotification({
|
||||
type: "error",
|
||||
title: "Error deleting backup",
|
||||
text: message,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<template>
|
||||
<UiServersServerSidebar :route="route" :nav-links="navLinks" :server="props.server" />
|
||||
<UiServersServerSidebar
|
||||
:route="route"
|
||||
:nav-links="navLinks"
|
||||
:server="server"
|
||||
:backup-in-progress="backupInProgress"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
InfoIcon,
|
||||
@@ -14,12 +18,14 @@ import {
|
||||
WrenchIcon,
|
||||
} from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
|
||||
const route = useRoute();
|
||||
const serverId = route.params.id as string;
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
backupInProgress?: BackupInProgressReason;
|
||||
}>();
|
||||
|
||||
useHead({
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
ref="versionSelectModal"
|
||||
:server="props.server"
|
||||
:current-loader="data?.loader as Loaders"
|
||||
:backup-in-progress="backupInProgress"
|
||||
@reinstall="emit('reinstall', $event)"
|
||||
/>
|
||||
|
||||
@@ -93,13 +94,23 @@
|
||||
</div>
|
||||
<div v-else class="flex w-full flex-col items-center gap-2 sm:w-fit sm:flex-row">
|
||||
<ButtonStyled>
|
||||
<nuxt-link class="!w-full sm:!w-auto" :to="`/modpacks?sid=${props.server.serverId}`">
|
||||
<nuxt-link
|
||||
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||
:class="{ disabled: backupInProgress }"
|
||||
class="!w-full sm:!w-auto"
|
||||
:to="`/modpacks?sid=${props.server.serverId}`"
|
||||
>
|
||||
<CompassIcon class="size-4" /> Find a modpack
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<span class="hidden sm:block">or</span>
|
||||
<ButtonStyled>
|
||||
<button class="!w-full sm:!w-auto" @click="mrpackModal.show()">
|
||||
<button
|
||||
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||
:disabled="!!backupInProgress"
|
||||
class="!w-full sm:!w-auto"
|
||||
@click="mrpackModal.show()"
|
||||
>
|
||||
<UploadIcon class="size-4" /> Upload .mrpack file
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -143,9 +154,13 @@ import { ButtonStyled, NewProjectCard } from "@modrinth/ui";
|
||||
import { TransferIcon, UploadIcon, InfoIcon, CompassIcon, SettingsIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import type { Loaders } from "~/types/servers";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
backupInProgress?: BackupInProgressReason;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -463,7 +463,7 @@ async function saveEmail() {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
@@ -495,7 +495,7 @@ async function savePassword() {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
@@ -532,7 +532,7 @@ async function showTwoFactorModal() {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
@@ -622,7 +622,7 @@ async function deleteAccount() {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
@@ -652,7 +652,7 @@ async function exportData() {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -196,7 +196,10 @@
|
||||
<div class="mt-2 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckCircleIcon class="h-5 w-5 text-brand" />
|
||||
<span> {{ getPyroProduct(subscription)?.metadata?.cpu }} vCores (CPU) </span>
|
||||
<span>
|
||||
{{ getPyroProduct(subscription)?.metadata?.cpu / 2 }} Shared CPUs (Bursts up
|
||||
to {{ getPyroProduct(subscription)?.metadata?.cpu }} CPUs)
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckCircleIcon class="h-5 w-5 text-brand" />
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
>
|
||||
<UploadIcon />
|
||||
</FileInput>
|
||||
<Button v-if="avatarUrl !== null" :action="removePreviewImage">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(commonMessages.removeImageButton) }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="previewImage"
|
||||
:action="
|
||||
@@ -86,7 +90,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { UserIcon, SaveIcon, UploadIcon, UndoIcon, XIcon } from "@modrinth/assets";
|
||||
import { UserIcon, SaveIcon, UploadIcon, UndoIcon, XIcon, TrashIcon } from "@modrinth/assets";
|
||||
import { Avatar, FileInput, Button, commonMessages } from "@modrinth/ui";
|
||||
|
||||
useHead({
|
||||
@@ -142,6 +146,7 @@ const bio = ref(auth.value.user.bio);
|
||||
const avatarUrl = ref(auth.value.user.avatar_url);
|
||||
const icon = shallowRef(null);
|
||||
const previewImage = shallowRef(null);
|
||||
const pendingAvatarDeletion = ref(false);
|
||||
const saved = ref(false);
|
||||
|
||||
const hasUnsavedChanges = computed(
|
||||
@@ -160,9 +165,15 @@ function showPreviewImage(files) {
|
||||
};
|
||||
}
|
||||
|
||||
function removePreviewImage() {
|
||||
pendingAvatarDeletion.value = true;
|
||||
previewImage.value = "https://cdn.modrinth.com/placeholder.png";
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
icon.value = null;
|
||||
previewImage.value = null;
|
||||
pendingAvatarDeletion.value = false;
|
||||
username.value = auth.value.user.username;
|
||||
bio.value = auth.value.user.bio;
|
||||
}
|
||||
@@ -170,6 +181,14 @@ function cancel() {
|
||||
async function saveChanges() {
|
||||
startLoading();
|
||||
try {
|
||||
if (pendingAvatarDeletion.value) {
|
||||
await useBaseFetch(`user/${auth.value.user.id}/icon`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
pendingAvatarDeletion.value = false;
|
||||
previewImage.value = null;
|
||||
}
|
||||
|
||||
if (icon.value) {
|
||||
await useBaseFetch(
|
||||
`user/${auth.value.user.id}/icon?ext=${
|
||||
|
||||
@@ -119,7 +119,7 @@ async function revokeSession(id) {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: formatMessage(commonMessages.errorNotificationTitle),
|
||||
text: err.data.description,
|
||||
text: err.data ? err.data.description : err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -212,6 +212,18 @@ export interface WSNewModEvent {
|
||||
event: "new-mod";
|
||||
}
|
||||
|
||||
export type WSBackupTask = "file" | "create" | "restore";
|
||||
export type WSBackupState = "ongoing" | "done" | "failed" | "cancelled" | "unchanged";
|
||||
|
||||
export interface WSBackupProgressEvent {
|
||||
event: "backup-progress";
|
||||
task: WSBackupTask;
|
||||
id: string;
|
||||
progress: number; // percentage
|
||||
state: WSBackupState;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
export type WSEvent =
|
||||
| WSLogEvent
|
||||
| WSStatsEvent
|
||||
@@ -221,7 +233,8 @@ export type WSEvent =
|
||||
| WSInstallationResultEvent
|
||||
| WSAuthOkEvent
|
||||
| WSUptimeEvent
|
||||
| WSNewModEvent;
|
||||
| WSNewModEvent
|
||||
| WSBackupProgressEvent;
|
||||
|
||||
export interface Servers {
|
||||
servers: Server[];
|
||||
|
||||
Reference in New Issue
Block a user