feat: reimplement error handling improvements w/o polling (#3942)

* Reapply "fix: error handling improvements (#3797)"

This reverts commit e0cde2d6ff.

* fix: polling issues

* fix: circuit breaker logic for spam

* fix: remove polling & ping test node instead

* fix: remove broken url from debugging

* fix: show error information display if node access fails in fs module
This commit is contained in:
IMB11
2025-07-08 18:40:44 +01:00
committed by GitHub
parent f256ef43c0
commit 7a12c4d5e2
9 changed files with 504 additions and 280 deletions

View File

@@ -102,7 +102,7 @@ export class ModrinthServer {
try { try {
const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, { const fileData = await useServersFetch(`/download?path=/server-icon-original.png`, {
override: auth, override: auth,
retry: false, retry: 1, // Reduce retries for optional resources
}); });
if (fileData instanceof Blob && import.meta.client) { if (fileData instanceof Blob && import.meta.client) {
@@ -124,64 +124,114 @@ export class ModrinthServer {
return dataURL; return dataURL;
} }
} catch (error) { } catch (error) {
if (error instanceof ModrinthServerError && error.statusCode === 404 && iconUrl) { if (error instanceof ModrinthServerError) {
// Handle external icon processing if (error.statusCode && error.statusCode >= 500) {
try { console.debug("Service unavailable, skipping icon processing");
const response = await fetch(iconUrl); sharedImage.value = undefined;
if (!response.ok) throw new Error("Failed to fetch icon"); return undefined;
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);
});
return dataURL;
}
} catch (error) {
console.error("Failed to process external icon:", error);
} }
if (error.statusCode === 404 && 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",
});
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);
}
}
} else {
throw error;
} }
} }
} catch (error) { } catch (error: any) {
console.error("Failed to process server icon:", error); console.debug("Icon processing failed:", error.message);
} }
sharedImage.value = undefined; sharedImage.value = undefined;
return undefined; return undefined;
} }
async testNodeReachability(): Promise<boolean> {
if (!this.general?.datacenter) {
console.warn("No datacenter info available for ping test");
return false;
}
const datacenter = this.general.datacenter;
const wsUrl = `wss://${datacenter}.nodes.modrinth.com/pingtest`;
try {
return await new Promise((resolve) => {
const socket = new WebSocket(wsUrl);
const timeout = setTimeout(() => {
socket.close();
resolve(false);
}, 5000);
socket.onopen = () => {
clearTimeout(timeout);
socket.send(performance.now().toString());
};
socket.onmessage = () => {
clearTimeout(timeout);
socket.close();
resolve(true);
};
socket.onerror = () => {
clearTimeout(timeout);
resolve(false);
};
});
} catch (error) {
console.error(`Failed to ping node ${wsUrl}:`, error);
return false;
}
}
async refresh( async refresh(
modules: ModuleName[] = [], modules: ModuleName[] = [],
options?: { options?: {
@@ -195,6 +245,8 @@ export class ModrinthServer {
: (["general", "content", "backups", "network", "startup", "ws", "fs"] as ModuleName[]); : (["general", "content", "backups", "network", "startup", "ws", "fs"] as ModuleName[]);
for (const module of modulesToRefresh) { for (const module of modulesToRefresh) {
this.errors[module] = undefined;
try { try {
switch (module) { switch (module) {
case "general": { case "general": {
@@ -239,6 +291,18 @@ export class ModrinthServer {
break; break;
} }
} catch (error) { } 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 && error.statusCode >= 500) {
console.debug(`Temporary ${module} unavailable:`, error.message);
continue;
}
}
this.errors[module] = { this.errors[module] = {
error: error:
error instanceof ModrinthServerError error instanceof ModrinthServerError

View File

@@ -22,26 +22,49 @@ export class FSModule extends ServerModule {
this.opsQueuedForModification = []; this.opsQueuedForModification = [];
} }
private async retryWithAuth<T>(requestFn: () => Promise<T>): Promise<T> { private async retryWithAuth<T>(
requestFn: () => Promise<T>,
ignoreFailure: boolean = false,
): Promise<T> {
try { try {
return await requestFn(); return await requestFn();
} catch (error) { } catch (error) {
if (error instanceof ModrinthServerError && error.statusCode === 401) { if (error instanceof ModrinthServerError && error.statusCode === 401) {
console.debug("Auth failed, refreshing JWT and retrying");
await this.fetch(); // Refresh auth await this.fetch(); // Refresh auth
return await requestFn(); return await requestFn();
} }
const available = await this.server.testNodeReachability();
if (!available && !ignoreFailure) {
this.server.moduleErrors.general = {
error: new ModrinthServerError(
"Unable to reach node. FS operation failed and subsequent ping test failed.",
500,
error as Error,
"fs",
),
timestamp: Date.now(),
};
}
throw error; throw error;
} }
} }
listDirContents(path: string, page: number, pageSize: number): Promise<DirectoryResponse> { listDirContents(
path: string,
page: number,
pageSize: number,
ignoreFailure: boolean = false,
): Promise<DirectoryResponse> {
return this.retryWithAuth(async () => { return this.retryWithAuth(async () => {
const encodedPath = encodeURIComponent(path); const encodedPath = encodeURIComponent(path);
return await useServersFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, { return await useServersFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, {
override: this.auth, override: this.auth,
retry: false, retry: false,
}); });
}); }, ignoreFailure);
} }
createFileOrFolder(path: string, type: "file" | "directory"): Promise<void> { createFileOrFolder(path: string, type: "file" | "directory"): Promise<void> {
@@ -150,7 +173,7 @@ export class FSModule extends ServerModule {
}); });
} }
downloadFile(path: string, raw?: boolean): Promise<any> { downloadFile(path: string, raw: boolean = false, ignoreFailure: boolean = false): Promise<any> {
return this.retryWithAuth(async () => { return this.retryWithAuth(async () => {
const encodedPath = encodeURIComponent(path); const encodedPath = encodeURIComponent(path);
const fileData = await useServersFetch(`/download?path=${encodedPath}`, { const fileData = await useServersFetch(`/download?path=${encodedPath}`, {
@@ -161,7 +184,7 @@ export class FSModule extends ServerModule {
return raw ? fileData : await fileData.text(); return raw ? fileData : await fileData.text();
} }
return fileData; return fileData;
}); }, ignoreFailure);
} }
extractFile( extractFile(

View File

@@ -46,13 +46,18 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined; data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined;
} }
const motd = await this.getMotd(); try {
if (motd === "A Minecraft Server") { const motd = await this.getMotd();
await this.setMotd( if (motd === "A Minecraft Server") {
`§b${data.project?.title || data.loader + " " + data.mc_version} §f♦ §aModrinth Servers`, await this.setMotd(
); `§b${data.project?.title || data.loader + " " + data.mc_version} §f♦ §aModrinth Servers`,
);
}
data.motd = motd;
} catch {
console.error("[Modrinth Servers] [General] Failed to fetch MOTD.");
data.motd = undefined;
} }
data.motd = motd;
// Copy data to this module // Copy data to this module
Object.assign(this, data); Object.assign(this, data);
@@ -178,7 +183,7 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
async getMotd(): Promise<string | undefined> { async getMotd(): Promise<string | undefined> {
try { try {
const props = await this.server.fs.downloadFile("/server.properties"); const props = await this.server.fs.downloadFile("/server.properties", false, true);
if (props) { if (props) {
const lines = props.split("\n"); const lines = props.split("\n");
for (const line of lines) { for (const line of lines) {
@@ -194,19 +199,25 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
} }
async setMotd(motd: string): Promise<void> { async setMotd(motd: string): Promise<void> {
const props = (await this.server.fetchConfigFile("ServerProperties")) as any; try {
if (props) { const props = (await this.server.fetchConfigFile("ServerProperties")) as any;
props.motd = motd; if (props) {
const newProps = this.server.constructServerProperties(props); props.motd = motd;
const octetStream = new Blob([newProps], { type: "application/octet-stream" }); const newProps = this.server.constructServerProperties(props);
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/fs`); const octetStream = new Blob([newProps], { type: "application/octet-stream" });
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/fs`);
await useServersFetch(`/update?path=/server.properties`, { await useServersFetch(`/update?path=/server.properties`, {
method: "PUT", method: "PUT",
contentType: "application/octet-stream", contentType: "application/octet-stream",
body: octetStream, body: octetStream,
override: auth, override: auth,
}); });
}
} catch {
console.error(
"[Modrinth Servers] [General] Failed to set MOTD due to lack of server properties file.",
);
} }
} }
} }

View File

@@ -42,6 +42,23 @@ export async function useServersFetch<T>(
retry = method === "GET" ? 3 : 0, retry = method === "GET" ? 3 : 0,
} = options; } = options;
const circuitBreakerKey = `${module || "default"}_${path}`;
const failureCount = useState<number>(`fetch_failures_${circuitBreakerKey}`, () => 0);
const lastFailureTime = useState<number>(`last_failure_${circuitBreakerKey}`, () => 0);
const now = Date.now();
if (failureCount.value >= 3 && now - lastFailureTime.value < 30000) {
const error = new ModrinthServersFetchError(
"[Modrinth Servers] Circuit breaker open - too many recent failures",
503,
);
throw new ModrinthServerError("Service temporarily unavailable", 503, error, module);
}
if (now - lastFailureTime.value > 30000) {
failureCount.value = 0;
}
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace( const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
/\/$/, /\/$/,
"", "",
@@ -99,6 +116,7 @@ export async function useServersFetch<T>(
timeout: 10000, timeout: 10000,
}); });
failureCount.value = 0;
return response; return response;
} catch (error) { } catch (error) {
lastError = error as Error; lastError = error as Error;
@@ -108,6 +126,11 @@ export async function useServersFetch<T>(
const statusCode = error.response?.status; const statusCode = error.response?.status;
const statusText = error.response?.statusText || "Unknown error"; const statusText = error.response?.statusText || "Unknown error";
if (statusCode && statusCode >= 500) {
failureCount.value++;
lastFailureTime.value = now;
}
let v1Error: V1ErrorInfo | undefined; let v1Error: V1ErrorInfo | undefined;
if (error.data?.error && error.data?.description) { if (error.data?.error && error.data?.description) {
v1Error = { v1Error = {
@@ -135,9 +158,11 @@ export async function useServersFetch<T>(
? errorMessages[statusCode] ? errorMessages[statusCode]
: `HTTP Error: ${statusCode || "unknown"} ${statusText}`; : `HTTP Error: ${statusCode || "unknown"} ${statusText}`;
const isRetryable = statusCode ? [408, 429, 500, 502, 504].includes(statusCode) : true; const isRetryable = statusCode ? [408, 429].includes(statusCode) : false;
const is5xxRetryable =
statusCode && statusCode >= 500 && statusCode < 600 && method === "GET" && attempts === 1;
if (!isRetryable || attempts >= maxAttempts) { if (!(isRetryable || is5xxRetryable) || attempts >= maxAttempts) {
console.error("Fetch error:", error); console.error("Fetch error:", error);
const fetchError = new ModrinthServersFetchError( const fetchError = new ModrinthServersFetchError(
@@ -148,7 +173,8 @@ export async function useServersFetch<T>(
throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error); throw new ModrinthServerError(error.message, statusCode, fetchError, module, v1Error);
} }
const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000); const baseDelay = statusCode && statusCode >= 500 ? 5000 : 1000;
const delay = Math.min(baseDelay * Math.pow(2, attempts - 1) + Math.random() * 1000, 15000);
console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`); console.warn(`Retrying request in ${delay}ms (attempt ${attempts}/${maxAttempts - 1})`);
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
continue; continue;

View File

@@ -18,48 +18,25 @@
v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'upgrading'" v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'upgrading'"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast" class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
> >
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl"> <ErrorInformationCard
<div class="flex flex-col items-center text-center"> title="Server upgrading"
<div class="flex flex-col items-center gap-4"> description="Your server's hardware is currently being upgraded and will be back online shortly!"
<div class="grid place-content-center rounded-full bg-bg-blue p-4"> :icon="TransferIcon"
<TransferIcon class="size-12 text-blue" /> icon-color="blue"
</div> :action="generalErrorAction"
<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> </div>
<div <div
v-else-if="serverData?.status === 'suspended'" v-else-if="serverData?.status === 'suspended'"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast" class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
> >
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl"> <ErrorInformationCard
<div class="flex flex-col items-center text-center"> title="Server suspended"
<div class="flex flex-col items-center gap-4"> :description="suspendedDescription"
<div class="grid place-content-center rounded-full bg-bg-orange p-4"> :icon="LockIcon"
<LockIcon class="size-12 text-orange" /> icon-color="orange"
</div> :action="suspendedAction"
<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>
<div <div
v-else-if=" v-else-if="
@@ -68,111 +45,65 @@
" "
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast" class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
> >
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl"> <ErrorInformationCard
<div class="flex flex-col items-center text-center"> title="An error occured."
<div class="flex flex-col items-center gap-4"> description="Please contact Modrinth Support."
<div class="grid place-content-center rounded-full bg-bg-orange p-4"> :icon="TransferIcon"
<TransferIcon class="size-12 text-orange" /> icon-color="orange"
</div> :error-details="generalErrorDetails"
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server not found</h1> :action="generalErrorAction"
</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>
</div> </div>
<div <div
v-else-if="server.moduleErrors?.general?.error.statusCode === 503" v-else-if="server.moduleErrors?.general?.error || !nodeAccessible"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast" class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
> >
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl"> <ErrorInformationCard
<div class="flex flex-col items-center text-center"> title="Server Node Unavailable"
<div class="flex flex-col items-center gap-4"> :icon="PanelErrorIcon"
<div class="grid place-content-center rounded-full bg-bg-red p-4"> icon-color="red"
<UiServersIconsPanelErrorIcon class="size-12 text-red" /> :action="nodeUnavailableAction"
</div> :error-details="nodeUnavailableDetails"
<h1 class="m-0 mb-4 w-fit text-4xl font-bold">Server Node Unavailable</h1> >
<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 not accessible
at the moment. We are working 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">
If reloading does not work initially, please contact Modrinth Support via the chat
bubble in the bottom right corner and we'll be happy to help.
</p>
</div> </div>
<p class="m-0 mb-4 leading-[170%] text-secondary"> </template>
Your server's node, where your Modrinth Server is physically hosted, is experiencing </ErrorInformationCard>
issues. We are working with our datacenter to resolve the issue as quickly as possible.
</p>
<p class="m-0 mb-4 leading-[170%] text-secondary">
Your data is safe and will not be lost, and your server will be back online as soon as the
issue is resolved.
</p>
<p class="m-0 mb-4 leading-[170%] text-secondary">
For updates, please join the Modrinth Discord or contact Modrinth Support via the chat
bubble in the bottom right corner and we'll be happy to help.
</p>
<div class="flex flex-col gap-2">
<UiCopyCode :text="'Server ID: ' + server.serverId" />
<UiCopyCode :text="'Node: ' + server.general?.datacenter" />
</div>
</div>
<ButtonStyled
size="large"
color="standard"
@click="
() =>
navigateTo('https://discord.modrinth.com', {
external: true,
})
"
>
<button class="mt-6 !w-full">Join Modrinth Discord</button>
</ButtonStyled>
<ButtonStyled
:disabled="formattedTime !== '00'"
size="large"
color="standard"
@click="() => reloadNuxtApp()"
>
<button class="mt-3 !w-full">Reload</button>
</ButtonStyled>
</div>
</div> </div>
<div <!-- <div
v-else-if="server.moduleErrors?.general?.error" v-else-if="server.moduleErrors?.general?.error"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast" class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
> >
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl"> <ErrorInformationCard
<div class="flex flex-col items-center text-center"> title="Connection lost"
<div class="flex flex-col items-center gap-4"> description=""
<div class="grid place-content-center rounded-full bg-bg-orange p-4"> :icon="TransferIcon"
<TransferIcon class="size-12 text-orange" /> icon-color="orange"
</div> :action="connectionLostAction"
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Connection lost</h1> >
<div class="text-center text-secondary"> <template #description>
{{ <div class="space-y-4">
formattedTime == "00" ? "Reconnecting..." : `Retrying in ${formattedTime} seconds...` <p class="text-lg text-secondary">
}} Something went wrong, and we couldn't connect to your server. This is likely due to a
</div> temporary network issue.
</p>
</div> </div>
<p class="text-lg text-secondary"> </template>
Something went wrong, and we couldn't connect to your server. This is likely due to a </ErrorInformationCard>
temporary network issue. You'll be reconnected automatically. </div> -->
</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>
</div>
<!-- SERVER START --> <!-- SERVER START -->
<div <div
v-else-if="serverData" v-else-if="serverData"
@@ -419,7 +350,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, type Reactive } from "vue"; import { ref, computed, onMounted, onUnmounted, type Reactive } from "vue";
import { import {
SettingsIcon, SettingsIcon,
CopyIcon, CopyIcon,
@@ -432,22 +363,23 @@ import {
LockIcon, LockIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import DOMPurify from "dompurify"; 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 { Intercom, shutdown } from "@intercom/messenger-js-sdk";
import type { MessageDescriptor } from "@vintl/vintl"; import type { MessageDescriptor } from "@vintl/vintl";
import type { import {
ServerState, type ServerState,
Stats, type Stats,
WSEvent, type WSEvent,
WSInstallationResultEvent, type WSInstallationResultEvent,
Backup, type Backup,
PowerAction, type PowerAction,
} from "@modrinth/utils"; } from "@modrinth/utils";
import { reloadNuxtApp, navigateTo } from "#app"; import { reloadNuxtApp } from "#app";
import { useModrinthServersConsole } from "~/store/console.ts"; import { useModrinthServersConsole } from "~/store/console.ts";
import { useServersFetch } from "~/composables/servers/servers-fetch.ts"; import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import { ModrinthServer, useModrinthServers } from "~/composables/servers/modrinth-servers.ts"; import { ModrinthServer, useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
import ServerInstallation from "~/components/ui/servers/ServerInstallation.vue"; 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 }; const app = useNuxtApp() as unknown as { $notify: any };
@@ -455,7 +387,6 @@ const socket = ref<WebSocket | null>(null);
const isReconnecting = ref(false); const isReconnecting = ref(false);
const isLoading = ref(true); const isLoading = ref(true);
const reconnectInterval = ref<ReturnType<typeof setInterval> | null>(null); const reconnectInterval = ref<ReturnType<typeof setInterval> | null>(null);
const isFirstMount = ref(true);
const isMounted = ref(true); const isMounted = ref(true);
const flags = useFeatureFlags(); const flags = useFeatureFlags();
@@ -485,18 +416,6 @@ const loadModulesPromise = Promise.resolve().then(() => {
provide("modulesLoaded", loadModulesPromise); provide("modulesLoaded", loadModulesPromise);
watch(
() => [server.moduleErrors?.general, server.moduleErrors?.ws],
([generalError, wsError]) => {
if (server.general?.status === "suspended") return;
const error = generalError?.error || wsError?.error;
if (error && error.statusCode !== 403) {
startPolling();
}
},
);
const errorTitle = ref("Error"); const errorTitle = ref("Error");
const errorMessage = ref("An unexpected error occurred."); const errorMessage = ref("An unexpected error occurred.");
const errorLog = ref(""); const errorLog = ref("");
@@ -760,7 +679,6 @@ const startUptimeUpdates = () => {
const stopUptimeUpdates = () => { const stopUptimeUpdates = () => {
if (uptimeIntervalId) { if (uptimeIntervalId) {
clearInterval(uptimeIntervalId); clearInterval(uptimeIntervalId);
intervalId = null;
} }
}; };
@@ -899,8 +817,6 @@ const handleInstallationResult = async (data: WSInstallationResultEvent) => {
case "ok": { case "ok": {
if (!serverData.value) break; if (!serverData.value) break;
stopPolling();
try { try {
await new Promise((resolve) => setTimeout(resolve, 2000)); await new Promise((resolve) => setTimeout(resolve, 2000));
@@ -1055,14 +971,6 @@ const notifyError = (title: string, text: string) => {
}); });
}; };
let intervalId: ReturnType<typeof setInterval> | null = null;
const countdown = ref(15);
const formattedTime = computed(() => {
const seconds = countdown.value % 60;
return `${seconds.toString().padStart(2, "0")}`;
});
export type BackupInProgressReason = { export type BackupInProgressReason = {
type: string; type: string;
tooltip: MessageDescriptor; tooltip: MessageDescriptor;
@@ -1098,23 +1006,95 @@ const backupInProgress = computed(() => {
return undefined; return undefined;
}); });
const stopPolling = () => { const nodeUnavailableDetails = computed(() => [
if (intervalId) { {
clearInterval(intervalId); label: "Server ID",
intervalId = null; value: server.serverId,
} type: "inline" as const,
}; },
{
label: "Node",
value: server.general?.datacenter ?? "Unknown",
type: "inline" as const,
},
{
label: "Error message",
value: nodeAccessible.value
? server.moduleErrors?.general?.error.message ?? "Unknown"
: "Unable to reach node. Ping test failed.",
type: "block" as const,
},
]);
const startPolling = () => { const suspendedDescription = computed(() => {
countdown.value = 15; if (serverData.value?.suspension_reason === "cancelled") {
intervalId = setInterval(() => { return "Your subscription has been cancelled.\nContact Modrinth Support if you believe this is an error.";
if (countdown.value <= 0) { }
reloadNuxtApp(); if (serverData.value?.suspension_reason) {
} else { return `Your server has been suspended: ${serverData.value.suspension_reason}\nContact Modrinth Support if you believe this is an error.`;
countdown.value--; }
} return "Your server has been suspended.\nContact Modrinth Support if you believe this is an error.";
}, 1000); });
};
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: "Reload",
onClick: () => reloadNuxtApp(),
color: "brand" as const,
disabled: false,
}));
const copyServerDebugInfo = () => { 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}`; 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}`;
@@ -1137,7 +1117,6 @@ const cleanup = () => {
shutdown(); shutdown();
stopPolling();
stopUptimeUpdates(); stopUptimeUpdates();
if (reconnectInterval.value) { if (reconnectInterval.value) {
clearInterval(reconnectInterval.value); clearInterval(reconnectInterval.value);
@@ -1180,16 +1159,31 @@ async function dismissNotice(noticeId: number) {
await server.refresh(["general"]); await server.refresh(["general"]);
} }
const nodeAccessible = ref(true);
onMounted(() => { onMounted(() => {
isMounted.value = true; isMounted.value = true;
if (server.general?.status === "suspended") { if (server.general?.status === "suspended") {
isLoading.value = false; isLoading.value = false;
return; return;
} }
server
.testNodeReachability()
.then((result) => {
nodeAccessible.value = result;
if (!nodeAccessible.value) {
isLoading.value = false;
}
})
.catch((err) => {
console.error("Error testing node reachability:", err);
nodeAccessible.value = false;
isLoading.value = false;
});
if (server.moduleErrors.general?.error) { if (server.moduleErrors.general?.error) {
if (!server.moduleErrors.general?.error?.message?.includes("Forbidden")) { isLoading.value = false;
startPolling();
}
} else { } else {
connectWebSocket(); connectWebSocket();
} }
@@ -1241,21 +1235,6 @@ onUnmounted(() => {
cleanup(); cleanup();
}); });
watch(
() => serverData.value?.status,
(newStatus, oldStatus) => {
if (isFirstMount.value) {
isFirstMount.value = false;
return;
}
if (newStatus === "installing" && oldStatus !== "installing") {
countdown.value = 15;
startPolling();
}
},
);
definePageMeta({ definePageMeta({
middleware: "auth", middleware: "auth",
}); });

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 DropArea } from './base/DropArea.vue'
export { default as DropdownSelect } from './base/DropdownSelect.vue' export { default as DropdownSelect } from './base/DropdownSelect.vue'
export { default as EnvironmentIndicator } from './base/EnvironmentIndicator.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 FileInput } from './base/FileInput.vue'
export { default as FilterBar } from './base/FilterBar.vue' export { default as FilterBar } from './base/FilterBar.vue'
export type { FilterBarOption } from './base/FilterBar.vue' export type { FilterBarOption } from './base/FilterBar.vue'

View File

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

View File

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