fix: error handling improvements (#3797)

* fix: error handling improvements

* refactor: error info cards

* refactor: PyroError -> ModrinthError

* fix: lint

* fix: idiot

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
IMB11 2025-06-16 18:34:18 +01:00 committed by GitHub
parent 0a9ffd3dc8
commit 706976439d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 398 additions and 198 deletions

View File

@ -124,58 +124,63 @@ export class ModrinthServer {
return dataURL;
}
} catch (error) {
if (error instanceof ModrinthServerError && error.statusCode === 404 && iconUrl) {
// Handle external icon processing
try {
const response = await fetch(iconUrl);
if (!response.ok) throw new Error("Failed to fetch icon");
const file = await response.blob();
const originalFile = new File([file], "server-icon-original.png", {
type: "image/png",
});
if (import.meta.client) {
const dataURL = await new Promise<string>((resolve) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.onload = () => {
canvas.width = 64;
canvas.height = 64;
ctx?.drawImage(img, 0, 0, 64, 64);
canvas.toBlob(async (blob) => {
if (blob) {
const scaledFile = new File([blob], "server-icon.png", { type: "image/png" });
await useServersFetch(`/create?path=/server-icon.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: scaledFile,
override: auth,
});
await useServersFetch(`/create?path=/server-icon-original.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: originalFile,
override: auth,
});
}
}, "image/png");
const dataURL = canvas.toDataURL("image/png");
sharedImage.value = dataURL;
resolve(dataURL);
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
if (error instanceof ModrinthServerError && error.statusCode === 404) {
if (iconUrl) {
try {
const response = await fetch(iconUrl);
if (!response.ok) throw new Error("Failed to fetch icon");
const file = await response.blob();
const originalFile = new File([file], "server-icon-original.png", {
type: "image/png",
});
return dataURL;
if (import.meta.client) {
const dataURL = await new Promise<string>((resolve) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.onload = () => {
canvas.width = 64;
canvas.height = 64;
ctx?.drawImage(img, 0, 0, 64, 64);
canvas.toBlob(async (blob) => {
if (blob) {
const scaledFile = new File([blob], "server-icon.png", {
type: "image/png",
});
await useServersFetch(`/create?path=/server-icon.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: scaledFile,
override: auth,
});
await useServersFetch(`/create?path=/server-icon-original.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: originalFile,
override: auth,
});
}
}, "image/png");
const dataURL = canvas.toDataURL("image/png");
sharedImage.value = dataURL;
resolve(dataURL);
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
return dataURL;
}
} catch (externalError: any) {
console.debug("Could not process external icon:", externalError.message);
}
} catch (error) {
console.error("Failed to process external icon:", error);
}
} else {
throw error;
}
}
} catch (error) {
console.error("Failed to process server icon:", error);
} catch (error: any) {
console.debug("Icon processing failed:", error.message);
}
sharedImage.value = undefined;
@ -239,6 +244,18 @@ export class ModrinthServer {
break;
}
} catch (error) {
if (error instanceof ModrinthServerError) {
if (error.statusCode === 404 && ["fs", "content"].includes(module)) {
console.debug(`Optional ${module} resource not found:`, error.message);
continue;
}
if (error.statusCode === 503) {
console.debug(`Temporary ${module} unavailable:`, error.message);
continue;
}
}
this.errors[module] = {
error:
error instanceof ModrinthServerError

View File

@ -155,19 +155,25 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
}
async setMotd(motd: string): Promise<void> {
const props = (await this.server.fetchConfigFile("ServerProperties")) as any;
if (props) {
props.motd = motd;
const newProps = this.server.constructServerProperties(props);
const octetStream = new Blob([newProps], { type: "application/octet-stream" });
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/fs`);
try {
const props = (await this.server.fetchConfigFile("ServerProperties")) as any;
if (props) {
props.motd = motd;
const newProps = this.server.constructServerProperties(props);
const octetStream = new Blob([newProps], { type: "application/octet-stream" });
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/fs`);
await useServersFetch(`/update?path=/server.properties`, {
method: "PUT",
contentType: "application/octet-stream",
body: octetStream,
override: auth,
});
await useServersFetch(`/update?path=/server.properties`, {
method: "PUT",
contentType: "application/octet-stream",
body: octetStream,
override: auth,
});
}
} catch {
console.error(
"[Modrinth Servers] [General] Failed to set MOTD due to lack of server properties file.",
);
}
}
}

View File

@ -18,48 +18,25 @@
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>
<p class="text-lg text-secondary">
Your server's hardware is currently being upgraded and will be back online shortly!
</p>
</div>
</div>
<ErrorInformationCard
title="Server upgrading"
description="Your server's hardware is currently being upgraded and will be back online shortly!"
:icon="TransferIcon"
icon-color="blue"
:action="generalErrorAction"
/>
</div>
<div
v-else-if="serverData?.status === 'suspended'"
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>
<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>
<ErrorInformationCard
title="Server suspended"
:description="suspendedDescription"
:icon="LockIcon"
icon-color="orange"
:action="suspendedAction"
/>
</div>
<div
v-else-if="
@ -68,110 +45,69 @@
"
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.moduleErrors?.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>
<ErrorInformationCard
title="An error occured."
description="Please contact Modrinth Support."
:icon="TransferIcon"
icon-color="orange"
:error-details="generalErrorDetails"
:action="generalErrorAction"
/>
</div>
<div
v-else-if="server.moduleErrors?.general?.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>
<ErrorInformationCard
title="Server Node Unavailable"
:icon="PanelErrorIcon"
icon-color="red"
:action="nodeUnavailableAction"
:error-details="nodeUnavailableDetails"
>
<template #description>
<div class="text-md space-y-4">
<p class="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="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="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>
<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>
</template>
</ErrorInformationCard>
</div>
<div
v-else-if="server.moduleErrors?.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>
<ErrorInformationCard
title="Connection lost"
description=""
:icon="TransferIcon"
icon-color="orange"
:action="connectionLostAction"
>
<template #description>
<div class="space-y-4">
<div class="text-center text-secondary">
{{
formattedTime == "00" ? "Reconnecting..." : `Retrying in ${formattedTime} seconds...`
}}
</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>
<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.moduleErrors?.general?.error)" />
<ButtonStyled
:disabled="formattedTime !== '00'"
size="large"
color="brand"
@click="() => reloadNuxtApp()"
>
<button class="mt-6 !w-full">Reload</button>
</ButtonStyled>
</div>
</template>
</ErrorInformationCard>
</div>
<!-- SERVER START -->
<div
@ -432,7 +368,7 @@ import {
LockIcon,
} from "@modrinth/assets";
import DOMPurify from "dompurify";
import { ButtonStyled, ServerNotice } from "@modrinth/ui";
import { ButtonStyled, ErrorInformationCard, ServerNotice } from "@modrinth/ui";
import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
import type { MessageDescriptor } from "@vintl/vintl";
import type {
@ -448,6 +384,7 @@ import { useModrinthServersConsole } from "~/store/console.ts";
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import { ModrinthServer, useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
import ServerInstallation from "~/components/ui/servers/ServerInstallation.vue";
import PanelErrorIcon from "~/components/ui/servers/icons/PanelErrorIcon.vue";
const app = useNuxtApp() as unknown as { $notify: any };
@ -760,7 +697,7 @@ const startUptimeUpdates = () => {
const stopUptimeUpdates = () => {
if (uptimeIntervalId) {
clearInterval(uptimeIntervalId);
intervalId = null;
pollingIntervalId = null;
}
};
@ -1055,7 +992,7 @@ const notifyError = (title: string, text: string) => {
});
};
let intervalId: ReturnType<typeof setInterval> | null = null;
let pollingIntervalId: ReturnType<typeof setInterval> | null = null;
const countdown = ref(15);
const formattedTime = computed(() => {
@ -1099,23 +1036,142 @@ const backupInProgress = computed(() => {
});
const stopPolling = () => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
if (pollingIntervalId) {
clearTimeout(pollingIntervalId);
pollingIntervalId = null;
}
};
const startPolling = () => {
countdown.value = 15;
intervalId = setInterval(() => {
if (countdown.value <= 0) {
reloadNuxtApp();
} else {
countdown.value--;
stopPolling();
let retryCount = 0;
const maxRetries = 10;
const poll = async () => {
try {
await server.refresh(["general", "ws"]);
if (!server.moduleErrors?.general?.error) {
stopPolling();
connectWebSocket();
return;
}
retryCount++;
if (retryCount >= maxRetries) {
console.error("Max retries reached, stopping polling");
stopPolling();
return;
}
// Exponential backoff: 3s, 6s, 12s, 24s, etc.
const delay = Math.min(3000 * Math.pow(2, retryCount - 1), 60000);
pollingIntervalId = setTimeout(poll, delay);
} catch (error) {
console.error("Polling failed:", error);
retryCount++;
if (retryCount < maxRetries) {
const delay = Math.min(3000 * Math.pow(2, retryCount - 1), 60000);
pollingIntervalId = setTimeout(poll, delay);
}
}
}, 1000);
};
poll();
};
const nodeUnavailableDetails = computed(() => [
{
label: "Server ID",
value: server.serverId,
type: "inline" as const,
},
{
label: "Node",
value: server.general?.datacenter ?? "Unknown! Please contact support!",
type: "inline" as const,
},
]);
const suspendedDescription = computed(() => {
if (serverData.value?.suspension_reason === "cancelled") {
return "Your subscription has been cancelled.\nContact Modrinth Support if you believe this is an error.";
}
if (serverData.value?.suspension_reason) {
return `Your server has been suspended: ${serverData.value.suspension_reason}\nContact Modrinth Support if you believe this is an error.`;
}
return "Your server has been suspended.\nContact Modrinth Support if you believe this is an error.";
});
const generalErrorDetails = computed(() => [
{
label: "Server ID",
value: server.serverId,
type: "inline" as const,
},
{
label: "Timestamp",
value: String(server.moduleErrors?.general?.timestamp),
type: "inline" as const,
},
{
label: "Error Name",
value: server.moduleErrors?.general?.error.name,
type: "inline" as const,
},
{
label: "Error Message",
value: server.moduleErrors?.general?.error.message,
type: "block" as const,
},
...(server.moduleErrors?.general?.error.originalError
? [
{
label: "Original Error",
value: String(server.moduleErrors.general.error.originalError),
type: "hidden" as const,
},
]
: []),
...(server.moduleErrors?.general?.error.stack
? [
{
label: "Stack Trace",
value: server.moduleErrors.general.error.stack,
type: "hidden" as const,
},
]
: []),
]);
const suspendedAction = computed(() => ({
label: "Go to billing settings",
onClick: () => router.push("/settings/billing"),
color: "brand" as const,
}));
const generalErrorAction = computed(() => ({
label: "Go back to all servers",
onClick: () => router.push("/servers/manage"),
color: "brand" as const,
}));
const nodeUnavailableAction = computed(() => ({
label: "Join Modrinth Discord",
onClick: () => navigateTo("https://discord.modrinth.com", { external: true }),
color: "standard" as const,
}));
const connectionLostAction = computed(() => ({
label: "Reload",
onClick: () => reloadNuxtApp(),
color: "brand" as const,
disabled: formattedTime.value !== "00",
}));
const copyServerDebugInfo = () => {
const debugInfo = `Server ID: ${serverData.value?.server_id}\nError: ${errorMessage.value}\nKind: ${serverData.value?.upstream?.kind}\nProject ID: ${serverData.value?.upstream?.project_id}\nVersion ID: ${serverData.value?.upstream?.version_id}\nLog: ${errorLog.value}`;
navigator.clipboard.writeText(debugInfo);

View File

@ -0,0 +1,120 @@
<template>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-8 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">
<component :is="icon" class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">{{ title }}</h1>
</div>
<div v-if="!description">
<slot name="description" />
</div>
<p v-else class="text-lg text-secondary">{{ description }}</p>
</div>
<div v-if="errorDetails" class="my-4 w-full rounded-lg border border-divider bg-bg-raised">
<div class="divide-y divide-divider">
<div
v-for="detail in errorDetails.filter((detail) => detail.type !== 'hidden')"
:key="detail.label"
class="px-4 py-3"
>
<div v-if="detail.type === 'inline'" class="flex items-center justify-between">
<span class="font-medium text-secondary">{{ detail.label }}</span>
<div class="flex items-center gap-2">
<code class="rounded-lg bg-code-bg px-2 py-1 text-sm text-code-text">
{{ detail.value }}
</code>
</div>
</div>
<div v-else-if="detail.type === 'block'" class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="font-medium text-secondary">{{ detail.label }}</span>
</div>
<div class="w-full overflow-hidden rounded-lg bg-code-bg p-3">
<code
class="block w-full overflow-x-auto break-words text-sm text-code-text whitespace-pre-wrap"
>
{{ detail.value }}
</code>
</div>
</div>
</div>
</div>
</div>
<div class="mt-4 flex !w-full flex-row gap-4">
<ButtonStyled
v-if="action"
size="large"
:color="action.color || 'brand'"
:disabled="action.disabled"
@click="action.onClick"
>
<button class="!w-full">
<component :is="action.icon" v-if="action.icon && !action.showAltIcon" class="size-4" />
<component
:is="action.altIcon"
v-else-if="action.icon && action.showAltIcon"
class="size-4"
/>
{{ action.label }}
</button>
</ButtonStyled>
<ButtonStyled v-if="errorDetails" size="large" color="standard" @click="copyErrorInformation">
<button class="!w-full">
<CopyIcon v-if="!infoCopied" class="size-4" />
<CheckIcon v-else class="size-4" />
Copy Information
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ButtonStyled from './ButtonStyled.vue'
import { CopyIcon, CheckIcon } from '@modrinth/assets'
import type { Component } from 'vue'
const infoCopied = ref(false)
const props = defineProps<{
title: string
description?: string
icon: Component
errorDetails?: {
label?: string
value?: string
type?: 'inline' | 'block' | 'hidden'
}[]
action?: {
label: string
onClick: () => void
color?: 'brand' | 'standard' | 'red' | 'orange' | 'blue'
disabled?: boolean
icon?: Component
altIcon?: Component
showAltIcon?: boolean
}
}>()
const copyErrorInformation = async () => {
if (!props.errorDetails || props.errorDetails.length === 0) return
const formattedErrorInfo = props.errorDetails
.filter((detail) => detail.label && detail.value)
.map((detail) => `${detail.label}: ${detail.value}`)
.join('\n\n')
await navigator.clipboard.writeText(formattedErrorInfo)
infoCopied.value = true
setTimeout(() => {
infoCopied.value = false
}, 2000)
}
</script>

View File

@ -16,6 +16,7 @@ export { default as DoubleIcon } from './base/DoubleIcon.vue'
export { default as DropArea } from './base/DropArea.vue'
export { default as DropdownSelect } from './base/DropdownSelect.vue'
export { default as EnvironmentIndicator } from './base/EnvironmentIndicator.vue'
export { default as ErrorInformationCard } from './base/ErrorInformationCard.vue'
export { default as FileInput } from './base/FileInput.vue'
export { default as FilterBar } from './base/FilterBar.vue'
export type { FilterBarOption } from './base/FilterBar.vue'

View File

@ -54,6 +54,6 @@ export class ModrinthServerError extends Error {
}
super(errorMessage)
this.name = 'PyroServersFetchError'
this.name = 'ModrinthServersFetchError'
}
}

View File

@ -5,6 +5,6 @@ export class ModrinthServersFetchError extends Error {
public originalError?: Error,
) {
super(message)
this.name = 'PyroFetchError'
this.name = 'ModrinthFetchError'
}
}