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,8 +124,14 @@ 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) {
console.debug("Service unavailable, skipping icon processing");
sharedImage.value = undefined;
return undefined;
}
if (error.statusCode === 404 && iconUrl) {
try { try {
const response = await fetch(iconUrl); const response = await fetch(iconUrl);
if (!response.ok) throw new Error("Failed to fetch icon"); if (!response.ok) throw new Error("Failed to fetch icon");
@@ -145,7 +151,9 @@ export class ModrinthServer {
ctx?.drawImage(img, 0, 0, 64, 64); ctx?.drawImage(img, 0, 0, 64, 64);
canvas.toBlob(async (blob) => { canvas.toBlob(async (blob) => {
if (blob) { if (blob) {
const scaledFile = new File([blob], "server-icon.png", { type: "image/png" }); const scaledFile = new File([blob], "server-icon.png", {
type: "image/png",
});
await useServersFetch(`/create?path=/server-icon.png&type=file`, { await useServersFetch(`/create?path=/server-icon.png&type=file`, {
method: "POST", method: "POST",
contentType: "application/octet-stream", contentType: "application/octet-stream",
@@ -169,19 +177,61 @@ export class ModrinthServer {
}); });
return dataURL; return dataURL;
} }
} catch (error) { } catch (externalError: any) {
console.error("Failed to process external icon:", error); console.debug("Could not process external icon:", externalError.message);
} }
} }
} 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; 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,6 +46,7 @@ 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;
} }
try {
const motd = await this.getMotd(); const motd = await this.getMotd();
if (motd === "A Minecraft Server") { if (motd === "A Minecraft Server") {
await this.setMotd( await this.setMotd(
@@ -53,6 +54,10 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
); );
} }
data.motd = motd; data.motd = motd;
} catch {
console.error("[Modrinth Servers] [General] Failed to fetch MOTD.");
data.motd = undefined;
}
// 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,6 +199,7 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
} }
async setMotd(motd: string): Promise<void> { async setMotd(motd: string): Promise<void> {
try {
const props = (await this.server.fetchConfigFile("ServerProperties")) as any; const props = (await this.server.fetchConfigFile("ServerProperties")) as any;
if (props) { if (props) {
props.motd = motd; props.motd = motd;
@@ -208,5 +214,10 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
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> >
</div> <template #description>
<p class="m-0 mb-4 leading-[170%] text-secondary"> <div class="text-md space-y-4">
Your server's node, where your Modrinth Server is physically hosted, is experiencing <p class="leading-[170%] text-secondary">
issues. We are working with our datacenter to resolve the issue as quickly as possible. 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>
<p class="m-0 mb-4 leading-[170%] text-secondary"> <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 Your data is safe and will not be lost, and your server will be back online as soon as
issue is resolved. the issue is resolved.
</p> </p>
<p class="m-0 mb-4 leading-[170%] text-secondary"> <p class="leading-[170%] text-secondary">
For updates, please join the Modrinth Discord or contact Modrinth Support via the chat 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. bubble in the bottom right corner and we'll be happy to help.
</p> </p>
<div class="flex flex-col gap-2">
<UiCopyCode :text="'Server ID: ' + server.serverId" />
<UiCopyCode :text="'Node: ' + server.general?.datacenter" />
</div> </div>
</template>
</ErrorInformationCard>
</div> </div>
<ButtonStyled <!-- <div
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.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...`
}}
</div>
</div>
<p class="text-lg text-secondary"> <p class="text-lg text-secondary">
Something went wrong, and we couldn't connect to your server. This is likely due to a 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. temporary network issue.
</p> </p>
</div> </div>
<UiCopyCode :text="JSON.stringify(server.moduleErrors?.general?.error)" /> </template>
<ButtonStyled </ErrorInformationCard>
:disabled="formattedTime !== '00'" </div> -->
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();
} else {
countdown.value--;
} }
}, 1000); 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: "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;
} }
if (server.moduleErrors.general?.error) {
if (!server.moduleErrors.general?.error?.message?.includes("Forbidden")) { server
startPolling(); .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) {
isLoading.value = false;
} 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'
} }
} }