From a88593fec57d7135f8f45ddf4b61e01f8a783b42 Mon Sep 17 00:00:00 2001 From: Evan Song <52982404+ferothefox@users.noreply.github.com> Date: Tue, 18 Feb 2025 15:17:50 -0700 Subject: [PATCH] Modrinth Servers February Release: Bug Fix Round 1 (#3267) * chore(pyroservers): attempt better error propogation Signed-off-by: Evan Song * chore(pyroservers): introduce deferred modules * fix(pyroservers): synchronize server icon processing Signed-off-by: Evan Song * refactor: server action buttons Signed-off-by: Evan Song * chore: bring back skeleton * fix(startup): populate values on refresh Signed-off-by: Evan Song * chore: properly refresh network Signed-off-by: Evan Song * fix: do not open backup settings modal if fetch failed * fix(platform): only clear selected loader version if selecting a different loader Signed-off-by: Evan Song * feat: parse links in console log * fix: attempt to mitigate power button state flash Signed-off-by: Evan Song * Revert "fix: attempt to mitigate power button state flash" This reverts commit 3ba5c0b4f7f5bacf1576aba5efe42785696a5aed. * refactor: error accumulation builder in PyroServersFetch Signed-off-by: Evan Song * fix: sentence case Signed-off-by: Evan Song * fix(files): await deferred fs Signed-off-by: Evan Song * fix: startup border Signed-off-by: Evan Song * fix: prevent suspended server errors from being overwritten Signed-off-by: Evan Song * fix: add server id copy button to suspended server listing Signed-off-by: Evan Song * fix: refresh behavior Signed-off-by: Evan Song * fix: behavior of server icon in options Signed-off-by: Evan Song * chore: clean Signed-off-by: Evan Song * chore: clean Signed-off-by: Evan Song * fix: prevent error inspector failures from destroying the page Signed-off-by: Evan Song * chore: clean Signed-off-by: Evan Song * chore: remove nexttick wrapper Signed-off-by: Evan Song * fix: ensure file edit gets initted due to deferred module Signed-off-by: Evan Song * refactor: prevent module errors from breaking the layout * chore: clean Signed-off-by: Evan Song --------- Signed-off-by: Evan Song --- .../ui/servers/BackupSettingsModal.vue | 8 +- .../components/ui/servers/OverviewLoading.vue | 80 +++ .../ui/servers/PanelServerActionButton.vue | 324 ++++----- .../ui/servers/PanelServerStatus.vue | 76 +- .../components/ui/servers/PanelTerminal.vue | 53 +- .../ui/servers/PlatformVersionSelectModal.vue | 8 +- .../components/ui/servers/ServerListing.vue | 10 +- .../ui/servers/ServerUptimeLabel.vue | 2 +- apps/frontend/src/composables/pyroServers.ts | 661 ++++++++++++------ .../src/pages/search/[searchProjectType].vue | 13 +- .../src/pages/servers/manage/[id].vue | 111 +-- .../src/pages/servers/manage/[id]/backups.vue | 43 +- .../servers/manage/[id]/content/index.vue | 26 +- .../src/pages/servers/manage/[id]/files.vue | 19 + .../src/pages/servers/manage/[id]/index.vue | 42 +- .../servers/manage/[id]/options/index.vue | 139 ++-- .../servers/manage/[id]/options/network.vue | 35 +- .../manage/[id]/options/properties.vue | 28 +- .../servers/manage/[id]/options/startup.vue | 104 ++- 19 files changed, 1170 insertions(+), 612 deletions(-) create mode 100644 apps/frontend/src/components/ui/servers/OverviewLoading.vue diff --git a/apps/frontend/src/components/ui/servers/BackupSettingsModal.vue b/apps/frontend/src/components/ui/servers/BackupSettingsModal.vue index 2a6c6267a..a92c8b618 100644 --- a/apps/frontend/src/components/ui/servers/BackupSettingsModal.vue +++ b/apps/frontend/src/components/ui/servers/BackupSettingsModal.vue @@ -106,6 +106,7 @@ const fetchSettings = async () => { initialSettings.value = settings as { interval: number; enabled: boolean }; autoBackupEnabled.value = settings?.enabled ?? false; autoBackupInterval.value = settings?.interval || 6; + return true; } catch (error) { console.error("Error fetching backup settings:", error); addNotification({ @@ -114,6 +115,7 @@ const fetchSettings = async () => { text: "Failed to load backup settings", type: "error", }); + return false; } finally { isLoadingSettings.value = false; } @@ -155,8 +157,10 @@ const saveSettings = async () => { defineExpose({ show: async () => { - await fetchSettings(); - modal.value?.show(); + const success = await fetchSettings(); + if (success) { + modal.value?.show(); + } }, }); diff --git a/apps/frontend/src/components/ui/servers/OverviewLoading.vue b/apps/frontend/src/components/ui/servers/OverviewLoading.vue new file mode 100644 index 000000000..a3d5135f1 --- /dev/null +++ b/apps/frontend/src/components/ui/servers/OverviewLoading.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/apps/frontend/src/components/ui/servers/PanelServerActionButton.vue b/apps/frontend/src/components/ui/servers/PanelServerActionButton.vue index ebb5cf0b7..073909fd3 100644 --- a/apps/frontend/src/components/ui/servers/PanelServerActionButton.vue +++ b/apps/frontend/src/components/ui/servers/PanelServerActionButton.vue @@ -1,23 +1,25 @@ diff --git a/apps/frontend/src/components/ui/servers/PanelServerStatus.vue b/apps/frontend/src/components/ui/servers/PanelServerStatus.vue index 81e6ebc6f..9f6674b0b 100644 --- a/apps/frontend/src/components/ui/servers/PanelServerStatus.vue +++ b/apps/frontend/src/components/ui/servers/PanelServerStatus.vue @@ -1,66 +1,72 @@ diff --git a/apps/frontend/src/components/ui/servers/PanelTerminal.vue b/apps/frontend/src/components/ui/servers/PanelTerminal.vue index 138baa507..02d7edb90 100644 --- a/apps/frontend/src/components/ui/servers/PanelTerminal.vue +++ b/apps/frontend/src/components/ui/servers/PanelTerminal.vue @@ -260,7 +260,25 @@
-
{{ selectedLog }}
+

+        
+

Detected Links

+ +
@@ -272,6 +290,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue"; import { useDebounceFn } from "@vueuse/core"; import { NewModal } from "@modrinth/ui"; import ButtonStyled from "@modrinth/ui/src/components/base/ButtonStyled.vue"; +import DOMPurify from "dompurify"; import { usePyroConsole } from "~/store/console.ts"; const { $cosmetics } = useNuxtApp(); @@ -984,6 +1003,38 @@ const jumpToLine = (line: string, event?: MouseEvent) => { }); }; +const sanitizeUrl = (url: string): string => { + try { + const parsed = new URL(url); + if (!["http:", "https:"].includes(parsed.protocol)) { + return "#"; + } + return parsed.toString(); + } catch { + return "#"; + } +}; + +const detectedLinks = computed(() => { + const urlRegex = /(https?:\/\/[^\s,<]+(?=[,\s<]|$))/g; + const matches = [...selectedLog.value.matchAll(urlRegex)].map((match) => match[0]); + return matches.filter((url) => sanitizeUrl(url) !== "#"); +}); + +const processedLogWithLinks = computed(() => { + const urlRegex = /(https?:\/\/[^\s,<]+(?=[,\s<]|$))/g; + const sanitizedLog = DOMPurify.sanitize(selectedLog.value, { + ALLOWED_TAGS: [], + ALLOWED_ATTR: [], + }); + + return sanitizedLog.replace(urlRegex, (url) => { + const safeUrl = sanitizeUrl(url); + if (safeUrl === "#") return url; + return `${url}`; + }); +}); + watch( () => pyroConsole.filteredOutput.value, () => { diff --git a/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue b/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue index 6bcaef3e1..a7dc1083a 100644 --- a/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue +++ b/apps/frontend/src/components/ui/servers/PlatformVersionSelectModal.vue @@ -337,7 +337,7 @@ watch( selectedLoaderVersions, (newVersions) => { if (newVersions.length > 0 && !selectedLoaderVersion.value) { - selectedLoaderVersion.value = String(newVersions[0]); // Ensure string type + selectedLoaderVersion.value = String(newVersions[0]); } }, { immediate: true }, @@ -516,8 +516,6 @@ const handleReinstall = async () => { const onShow = () => { selectedMCVersion.value = props.server.general?.mc_version || ""; - selectedLoaderVersion.value = ""; - hardReset.value = false; }; const onHide = () => { @@ -528,13 +526,15 @@ const onHide = () => { loadingServerCheck.value = false; isLoading.value = false; selectedMCVersion.value = ""; - selectedLoaderVersion.value = ""; serverCheckError.value = ""; paperVersions.value = {}; purpurVersions.value = {}; }; const show = (loader: Loaders) => { + if (selectedLoader.value !== loader) { + selectedLoaderVersion.value = ""; + } selectedLoader.value = loader; selectedMCVersion.value = props.server.general?.mc_version || ""; versionSelectModal.value?.show(); diff --git a/apps/frontend/src/components/ui/servers/ServerListing.vue b/apps/frontend/src/components/ui/servers/ServerListing.vue index 104891aa6..a8bfeece4 100644 --- a/apps/frontend/src/components/ui/servers/ServerListing.vue +++ b/apps/frontend/src/components/ui/servers/ServerListing.vue @@ -69,11 +69,13 @@
- - Your server has been suspended due to a billing issue. Please visit your billing settings or - contact Modrinth Support for more information. +
+ Your server has been suspended. Please + update your billing information or contact Modrinth Support for more information. +
+
diff --git a/apps/frontend/src/components/ui/servers/ServerUptimeLabel.vue b/apps/frontend/src/components/ui/servers/ServerUptimeLabel.vue index 1ffc5c92f..f90be6640 100644 --- a/apps/frontend/src/components/ui/servers/ServerUptimeLabel.vue +++ b/apps/frontend/src/components/ui/servers/ServerUptimeLabel.vue @@ -2,7 +2,7 @@
diff --git a/apps/frontend/src/composables/pyroServers.ts b/apps/frontend/src/composables/pyroServers.ts index 7130e6d2c..206063a25 100644 --- a/apps/frontend/src/composables/pyroServers.ts +++ b/apps/frontend/src/composables/pyroServers.ts @@ -10,19 +10,111 @@ interface PyroFetchOptions { url?: string; token?: string; }; - retry?: boolean; + retry?: number | boolean; } -async function PyroFetch(path: string, options: PyroFetchOptions = {}): Promise { +class PyroServerError extends Error { + public readonly errors: Map = new Map(); + public readonly timestamp: number = Date.now(); + + constructor(message?: string) { + super(message || "Multiple errors occurred"); + this.name = "PyroServerError"; + } + + addError(module: string, error: Error) { + this.errors.set(module, error); + this.message = this.buildErrorMessage(); + } + + hasErrors() { + return this.errors.size > 0; + } + + private buildErrorMessage(): string { + return Array.from(this.errors.entries()) + .map(([_module, error]) => error.message) + .join("\n"); + } +} + +export class PyroServersFetchError extends Error { + constructor( + message: string, + public readonly statusCode?: number, + public readonly originalError?: Error, + public readonly module?: string, + ) { + let errorMessage = message; + let method = "GET"; + let path = ""; + + if (originalError instanceof FetchError) { + const matches = message.match(/\[([A-Z]+)\]\s+"([^"]+)":/); + if (matches) { + method = matches[1]; + path = matches[2].replace(/https?:\/\/[^/]+\/[^/]+\/v\d+\//, ""); + } + + const statusMessage = (() => { + if (!statusCode) return "Unknown Error"; + switch (statusCode) { + case 400: + return "Bad Request"; + case 401: + return "Unauthorized"; + case 403: + return "Forbidden"; + case 404: + return "Not Found"; + case 408: + return "Request Timeout"; + case 429: + return "Too Many Requests"; + case 500: + return "Internal Server Error"; + case 502: + return "Bad Gateway"; + case 503: + return "Service Unavailable"; + case 504: + return "Gateway Timeout"; + default: + return `HTTP ${statusCode}`; + } + })(); + + errorMessage = `[${method}] ${statusMessage} (${statusCode}) while fetching ${path}${module ? ` in ${module}` : ""}`; + } else { + errorMessage = `${message}${statusCode ? ` (${statusCode})` : ""}${module ? ` in ${module}` : ""}`; + } + + super(errorMessage); + this.name = "PyroServersFetchError"; + } +} + +async function PyroFetch( + path: string, + options: PyroFetchOptions = {}, + module?: string, +): Promise { const config = useRuntimeConfig(); const auth = await useAuth(); const authToken = auth.value?.token; if (!authToken) { - throw new PyroFetchError("Cannot pyrofetch without auth", 10000); + throw new PyroServersFetchError("Missing auth token", 401, undefined, module); } - const { method = "GET", contentType = "application/json", body, version = 0, override } = options; + const { + method = "GET", + contentType = "application/json", + body, + version = 0, + override, + retry = method === "GET" ? 3 : 0, + } = options; const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace( /\/$/, @@ -30,9 +122,11 @@ async function PyroFetch(path: string, options: PyroFetchOptions = {}): Promi ); if (!base) { - throw new PyroFetchError( - "Cannot pyrofetch without base url. Make sure to set a PYRO_BASE_URL in environment variables", - 10001, + throw new PyroServersFetchError( + "Configuration error: Missing PYRO_BASE_URL", + 500, + undefined, + module, ); } @@ -40,9 +134,7 @@ async function PyroFetch(path: string, options: PyroFetchOptions = {}): Promi ? `https://${override.url}/${path.replace(/^\//, "")}` : `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`; - type HeadersRecord = Record; - - const headers: HeadersRecord = { + const headers: Record = { Authorization: `Bearer ${override?.token ?? authToken}`, "Access-Control-Allow-Headers": "Authorization", "User-Agent": "Pyro/1.0 (https://pyro.host)", @@ -57,43 +149,47 @@ async function PyroFetch(path: string, options: PyroFetchOptions = {}): Promi headers.Origin = window.location.origin; } - try { - const response = await $fetch(fullUrl, { - method, - headers, - body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined, - timeout: 10000, - retry: options.retry !== false ? (method === "GET" ? 3 : 0) : 0, - }); - return response; - } catch (error) { - console.error("[PyroServers/PyroFetch]:", error); - if (error instanceof FetchError) { - const statusCode = error.response?.status; - const statusText = error.response?.statusText || "[no status text available]"; - const errorMessages: { [key: number]: string } = { - 400: "Bad Request", - 401: "Unauthorized", - 403: "Forbidden", - 404: "Not Found", - 405: "Method Not Allowed", - 429: "Too Many Requests", - 500: "Internal Server Error", - 502: "Bad Gateway", - 503: "Service Unavailable", - }; - const message = - statusCode && statusCode in errorMessages - ? errorMessages[statusCode] - : `HTTP Error: ${statusCode || "[unhandled status code]"} ${statusText}`; - throw new PyroFetchError(`[PyroServers/PyroFetch] ${message}`, statusCode, error); + let attempts = 0; + const maxAttempts = (typeof retry === "boolean" ? (retry ? 1 : 0) : retry) + 1; + let lastError: Error | null = null; + + while (attempts < maxAttempts) { + try { + const response = await $fetch(fullUrl, { + method, + headers, + body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined, + timeout: 10000, + }); + + return response; + } catch (error) { + lastError = error as Error; + attempts++; + + if (error instanceof FetchError) { + const statusCode = error.response?.status; + const isRetryable = statusCode ? [408, 429, 500, 502, 503, 504].includes(statusCode) : true; + + if (!isRetryable || attempts >= maxAttempts) { + throw new PyroServersFetchError(error.message, statusCode, error, module); + } + + const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + + throw new PyroServersFetchError( + "Unexpected error during fetch operation", + undefined, + error as Error, + module, + ); } - throw new PyroFetchError( - "[PyroServers/PyroFetch] An unexpected error occurred during the fetch operation.", - undefined, - error as Error, - ); } + + throw lastError || new Error("Maximum retry attempts reached"); } const internalServerRefrence = ref(null); @@ -271,100 +367,96 @@ const constructServerProperties = (properties: any): string => { }; const processImage = async (iconUrl: string | undefined) => { - const image = ref(null); const sharedImage = useState( `server-icon-${internalServerRefrence.value.serverId}`, - () => undefined, ); - const auth = await PyroFetch(`servers/${internalServerRefrence.value.serverId}/fs`); - try { - const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, { - override: auth, - retry: false, - }); - if (fileData instanceof Blob) { - if (import.meta.client) { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - const img = new Image(); - img.src = URL.createObjectURL(fileData); - await new Promise((resolve) => { - img.onload = () => { - canvas.width = 512; - canvas.height = 512; - ctx?.drawImage(img, 0, 0, 512, 512); - const dataURL = canvas.toDataURL("image/png"); - internalServerRefrence.value.general.image = dataURL; - image.value = dataURL; - sharedImage.value = dataURL; // Store in useState - resolve(); - }; - }); - } - } - } catch (error) { - if (error instanceof PyroFetchError && error.statusCode === 404) { - sharedImage.value = undefined; - } else { - console.error(error); - } + if (sharedImage.value) { + return sharedImage.value; } - if (image.value === null && iconUrl) { - console.log("iconUrl", iconUrl); + try { + const auth = await PyroFetch(`servers/${internalServerRefrence.value.serverId}/fs`); try { - const response = await fetch(iconUrl); - const file = await response.blob(); - const originalfile = new File([file], "server-icon-original.png", { - type: "image/png", + const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, { + override: auth, + retry: false, }); - if (import.meta.client) { - const scaledFile = await new Promise((resolve, reject) => { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - const img = new Image(); - img.src = URL.createObjectURL(file); - img.onload = () => { - canvas.width = 64; - canvas.height = 64; - ctx?.drawImage(img, 0, 0, 64, 64); - canvas.toBlob((blob) => { - if (blob) { - const data = new File([blob], "server-icon.png", { type: "image/png" }); - resolve(data); - } else { - reject(new Error("Canvas toBlob failed")); - } - }, "image/png"); - }; - img.onerror = reject; - }); - if (scaledFile) { - await PyroFetch(`/create?path=/server-icon.png&type=file`, { - method: "POST", - contentType: "application/octet-stream", - body: scaledFile, - override: auth, - }); - await PyroFetch(`/create?path=/server-icon-original.png&type=file`, { - method: "POST", - contentType: "application/octet-stream", - body: originalfile, - override: auth, + if (fileData instanceof Blob) { + if (import.meta.client) { + const dataURL = await new Promise((resolve) => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const img = new Image(); + img.onload = () => { + canvas.width = 512; + canvas.height = 512; + ctx?.drawImage(img, 0, 0, 512, 512); + const dataURL = canvas.toDataURL("image/png"); + sharedImage.value = dataURL; + resolve(dataURL); + URL.revokeObjectURL(img.src); + }; + img.src = URL.createObjectURL(fileData); }); + return dataURL; } } } catch (error) { - if (error instanceof PyroFetchError && error.statusCode === 404) { - console.log("[PYROSERVERS] No server icon found"); - } else { - console.error(error); + if (error instanceof PyroServersFetchError && 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((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 PyroFetch(`/create?path=/server-icon.png&type=file`, { + method: "POST", + contentType: "application/octet-stream", + body: scaledFile, + override: auth, + }); + await PyroFetch(`/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); + } } } + } catch (error) { + console.error("Failed to process server icon:", error); } - return image.value; + + sharedImage.value = undefined; + return undefined; }; // ------------------ GENERAL ------------------ // @@ -564,10 +656,14 @@ const reinstallContent = async (replace: string, projectId: string, versionId: s const createBackup = async (backupName: string) => { try { - const response = (await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups`, { - method: "POST", - body: { name: backupName }, - })) as { id: string }; + const response = await PyroFetch<{ id: string }>( + `servers/${internalServerRefrence.value.serverId}/backups`, + { + method: "POST", + body: { name: backupName }, + }, + ); + await internalServerRefrence.value.refresh(["backups"]); return response.id; } catch (error) { console.error("Error creating backup:", error); @@ -581,6 +677,7 @@ const renameBackup = async (backupId: string, newName: string) => { method: "POST", body: { name: newName }, }); + await internalServerRefrence.value.refresh(["backups"]); } catch (error) { console.error("Error renaming backup:", error); throw error; @@ -592,6 +689,7 @@ const deleteBackup = async (backupId: string) => { await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}`, { method: "DELETE", }); + await internalServerRefrence.value.refresh(["backups"]); } catch (error) { console.error("Error deleting backup:", error); throw error; @@ -606,6 +704,7 @@ const restoreBackup = async (backupId: string) => { method: "POST", }, ); + await internalServerRefrence.value.refresh(["backups"]); } catch (error) { console.error("Error restoring backup:", error); throw error; @@ -644,12 +743,10 @@ const getAutoBackup = async () => { const lockBackup = async (backupId: string) => { try { - return await PyroFetch( - `servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`, - { - method: "POST", - }, - ); + await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`, { + method: "POST", + }); + await internalServerRefrence.value.refresh(["backups"]); } catch (error) { console.error("Error locking backup:", error); throw error; @@ -658,14 +755,12 @@ const lockBackup = async (backupId: string) => { const unlockBackup = async (backupId: string) => { try { - return await PyroFetch( - `servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`, - { - method: "POST", - }, - ); + await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`, { + method: "POST", + }); + await internalServerRefrence.value.refresh(["backups"]); } catch (error) { - console.error("Error locking backup:", error); + console.error("Error unlocking backup:", error); throw error; } }; @@ -760,7 +855,7 @@ const retryWithAuth = async (requestFn: () => Promise) => { try { return await requestFn(); } catch (error) { - if (error instanceof PyroFetchError && error.statusCode === 401) { + if (error instanceof PyroServersFetchError && error.statusCode === 401) { await internalServerRefrence.value.refresh(["fs"]); return await requestFn(); } @@ -947,17 +1042,18 @@ const modules: any = { general: { get: async (serverId: string) => { try { - const data = await PyroFetch(`servers/${serverId}`); - // TODO: temp hack to fix hydration error + const data = await PyroFetch(`servers/${serverId}`, {}, "general"); if (data.upstream?.project_id) { const res = await $fetch( `https://api.modrinth.com/v2/project/${data.upstream.project_id}`, ); data.project = res as Project; } + if (import.meta.client) { data.image = (await processImage(data.project?.icon_url)) ?? undefined; } + const motd = await getMotd(); if (motd === "A Minecraft Server") { await setMotd( @@ -967,8 +1063,19 @@ const modules: any = { data.motd = motd; return data; } catch (error) { - internalServerRefrence.value.setError(error); - return undefined; + const fetchError = + error instanceof PyroServersFetchError + ? error + : new PyroServersFetchError("Unknown error occurred", undefined, error as Error); + + return { + status: "error", + server_id: serverId, + error: { + error: fetchError, + timestamp: Date.now(), + }, + }; } }, updateName, @@ -982,16 +1089,23 @@ const modules: any = { content: { get: async (serverId: string) => { try { - const mods = await PyroFetch(`servers/${serverId}/mods`); + const mods = await PyroFetch(`servers/${serverId}/mods`, {}, "content"); return { - data: - internalServerRefrence.value.error === undefined - ? mods.sort((a, b) => (a?.name ?? "").localeCompare(b?.name ?? "")) - : [], + data: mods.sort((a, b) => (a?.name ?? "").localeCompare(b?.name ?? "")), }; } catch (error) { - internalServerRefrence.value.setError(error); - return undefined; + const fetchError = + error instanceof PyroServersFetchError + ? error + : new PyroServersFetchError("Unknown error occurred", undefined, error as Error); + + return { + data: [], + error: { + error: fetchError, + timestamp: Date.now(), + }, + }; } }, install: installContent, @@ -1001,10 +1115,22 @@ const modules: any = { backups: { get: async (serverId: string) => { try { - return { data: await PyroFetch(`servers/${serverId}/backups`) }; + return { + data: await PyroFetch(`servers/${serverId}/backups`, {}, "backups"), + }; } catch (error) { - internalServerRefrence.value.setError(error); - return undefined; + const fetchError = + error instanceof PyroServersFetchError + ? error + : new PyroServersFetchError("Unknown error occurred", undefined, error as Error); + + return { + data: [], + error: { + error: fetchError, + timestamp: Date.now(), + }, + }; } }, create: createBackup, @@ -1020,10 +1146,26 @@ const modules: any = { network: { get: async (serverId: string) => { try { - return { allocations: await PyroFetch(`servers/${serverId}/allocations`) }; + return { + allocations: await PyroFetch( + `servers/${serverId}/allocations`, + {}, + "network", + ), + }; } catch (error) { - internalServerRefrence.value.setError(error); - return undefined; + const fetchError = + error instanceof PyroServersFetchError + ? error + : new PyroServersFetchError("Unknown error occurred", undefined, error as Error); + + return { + allocations: [], + error: { + error: fetchError, + timestamp: Date.now(), + }, + }; } }, reserveAllocation, @@ -1035,10 +1177,19 @@ const modules: any = { startup: { get: async (serverId: string) => { try { - return await PyroFetch(`servers/${serverId}/startup`); + return await PyroFetch(`servers/${serverId}/startup`, {}, "startup"); } catch (error) { - internalServerRefrence.value.setError(error); - return undefined; + const fetchError = + error instanceof PyroServersFetchError + ? error + : new PyroServersFetchError("Unknown error occurred", undefined, error as Error); + + return { + error: { + error: fetchError, + timestamp: Date.now(), + }, + }; } }, update: updateStartupSettings, @@ -1046,20 +1197,39 @@ const modules: any = { ws: { get: async (serverId: string) => { try { - return await PyroFetch(`servers/${serverId}/ws`); + return await PyroFetch(`servers/${serverId}/ws`, {}, "ws"); } catch (error) { - internalServerRefrence.value.setError(error); - return undefined; + const fetchError = + error instanceof PyroServersFetchError + ? error + : new PyroServersFetchError("Unknown error occurred", undefined, error as Error); + + return { + error: { + error: fetchError, + timestamp: Date.now(), + }, + }; } }, }, fs: { get: async (serverId: string) => { try { - return { auth: await PyroFetch(`servers/${serverId}/fs`) }; + return { auth: await PyroFetch(`servers/${serverId}/fs`, {}, "fs") }; } catch (error) { - internalServerRefrence.value.setError(error); - return undefined; + const fetchError = + error instanceof PyroServersFetchError + ? error + : new PyroServersFetchError("Unknown error occurred", undefined, error as Error); + + return { + auth: undefined, + error: { + error: fetchError, + timestamp: Date.now(), + }, + }; } }, listDirContents, @@ -1367,12 +1537,44 @@ type FSFunctions = { downloadFile: (path: string, raw?: boolean) => Promise; }; -type GeneralModule = General & GeneralFunctions; -type ContentModule = { data: Mod[] } & ContentFunctions; -type BackupsModule = { data: Backup[] } & BackupFunctions; -type NetworkModule = { allocations: Allocation[] } & NetworkFunctions; -type StartupModule = Startup & StartupFunctions; -export type FSModule = { auth: JWTAuth } & FSFunctions; +type ModuleError = { + error: PyroServersFetchError; + timestamp: number; +}; + +type GeneralModule = General & + GeneralFunctions & { + error?: ModuleError; + }; + +type ContentModule = { + data: Mod[]; + error?: ModuleError; +} & ContentFunctions; + +type BackupsModule = { + data: Backup[]; + error?: ModuleError; +} & BackupFunctions; + +type NetworkModule = { + allocations: Allocation[]; + error?: ModuleError; +} & NetworkFunctions; + +type StartupModule = Startup & + StartupFunctions & { + error?: ModuleError; + }; + +type WSModule = JWTAuth & { + error?: ModuleError; +}; + +type FSModule = { + auth: JWTAuth; + error?: ModuleError; +} & FSFunctions; type ModulesMap = { general: GeneralModule; @@ -1380,7 +1582,7 @@ type ModulesMap = { backups: BackupsModule; network: NetworkModule; startup: StartupModule; - ws: JWTAuth; + ws: WSModule; fs: FSModule; }; @@ -1401,6 +1603,7 @@ export type Server = { preserveInstallState?: boolean; }, ) => Promise; + loadModules: (modulesToLoad: avaliableModules) => Promise; setError: (error: Error) => void; error?: Error; serverId: string; @@ -1419,58 +1622,92 @@ export const usePyroServer = async (serverId: string, includedModules: avaliable return; } - const modulesToRefresh = refreshModules || includedModules; - const promises: Promise[] = []; + const modulesToRefresh = [...new Set(refreshModules || includedModules)]; + const serverError = new PyroServerError(); - const uniqueModules = [...new Set(modulesToRefresh)]; + const modulePromises = modulesToRefresh.map(async (module) => { + try { + const mods = modules[module]; + if (!mods?.get) return; - for (const module of uniqueModules) { - const mods = modules[module]; - if (mods.get) { - promises.push( - (async () => { - const data = await mods.get(serverId); - if (data) { - if (module === "general" && options?.preserveConnection) { - const updatedData = { - ...server[module], - ...data, - }; - if (server[module]?.image) { - updatedData.image = server[module].image; - } - if (server[module]?.motd) { - updatedData.motd = server[module].motd; - } - if (options.preserveInstallState && server[module]?.status === "installing") { - updatedData.status = "installing"; - } - server[module] = updatedData; - } else { - server[module] = { ...server[module], ...data }; - } - } - })(), - ); + const data = await mods.get(serverId); + if (!data) return; + + if (module === "general" && options?.preserveConnection) { + server[module] = { + ...server[module], + ...data, + image: server[module]?.image || data.image, + motd: server[module]?.motd || data.motd, + status: + options.preserveInstallState && server[module]?.status === "installing" + ? "installing" + : data.status, + }; + } else { + server[module] = { ...server[module], ...data }; + } + } catch (error) { + console.error(`Failed to refresh module ${module}:`, error); + if (error instanceof Error) { + serverError.addError(module, error); + } + } + }); + + await Promise.allSettled(modulePromises); + + if (serverError.hasErrors()) { + if (server.error && server.error instanceof PyroServerError) { + serverError.errors.forEach((error, module) => { + (server.error as PyroServerError).addError(module, error); + }); + } else { + server.setError(serverError); } } + }, + loadModules: async (modulesToLoad: avaliableModules) => { + const newModules = modulesToLoad.filter((module) => !server[module]); + if (newModules.length === 0) return; - await Promise.all(promises); + newModules.forEach((module) => { + server[module] = modules[module]; + }); + + await server.refresh(newModules); }, setError: (error: Error) => { - server.error = error; + if (!server.error) { + server.error = error; + } else if (error instanceof PyroServerError) { + if (!(server.error instanceof PyroServerError)) { + const newError = new PyroServerError(); + newError.addError("previous", server.error); + server.error = newError; + } + error.errors.forEach((err, module) => { + (server.error as PyroServerError).addError(module, err); + }); + } }, + serverId, }); - for (const module of includedModules) { - const mods = modules[module]; - server[module] = mods; - } + const initialModules = includedModules.filter((module) => ["general", "ws"].includes(module)); + const deferredModules = includedModules.filter((module) => !["general", "ws"].includes(module)); + + initialModules.forEach((module) => { + server[module] = modules[module]; + }); internalServerRefrence.value = server; + await server.refresh(initialModules); - await server.refresh(); + if (deferredModules.length > 0) { + await server.loadModules(deferredModules); + } return server as Server; }; diff --git a/apps/frontend/src/pages/search/[searchProjectType].vue b/apps/frontend/src/pages/search/[searchProjectType].vue index 28463d969..5cb3b3282 100644 --- a/apps/frontend/src/pages/search/[searchProjectType].vue +++ b/apps/frontend/src/pages/search/[searchProjectType].vue @@ -258,7 +258,8 @@
-

Server Upgrading

+

Server upgrading

- Your server's hardware is currently being upgraded and will be back online shortly. + Your server's hardware is currently being upgraded and will be back online shortly!

@@ -47,17 +47,18 @@
-

Server Suspended

+

Server suspended

{{ - serverData.suspension_reason - ? `Your server has been suspended: ${serverData.suspension_reason}` - : "Your server has been suspended." + 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." }}
- This is most likely due to a billing issue. Please check your billing information and - contact Modrinth support if you believe this is an error. + Contact Modrinth support if you believe this is an error.

@@ -66,7 +67,10 @@
@@ -82,14 +86,15 @@ this is an error, please contact Modrinth support.

- + +
@@ -141,7 +146,7 @@
@@ -164,7 +169,7 @@ temporary network issue. You'll be reconnected automatically.

- +
@@ -363,7 +368,6 @@ - { + if (server.general?.status === "suspended") { + return; + } + return server.loadModules(["content", "backups", "network", "startup", "fs"]); +}); + +provide("modulesLoaded", loadModulesPromise); watch( - () => server.error, - (newError) => { + () => [server.general?.error, server.ws?.error], + ([generalError, wsError]) => { if (server.general?.status === "suspended") return; - if (newError && !newError.message.includes("Forbidden")) { + + const error = generalError?.error || wsError?.error; + if (error && error.statusCode !== 403) { startPolling(); } }, @@ -450,11 +458,9 @@ const errorMessage = ref("An unexpected error occurred."); const errorLog = ref(""); const errorLogFile = ref(""); const serverData = computed(() => server.general); -const error = ref(null); const isConnected = ref(false); const isWSAuthIncorrect = ref(false); const pyroConsole = usePyroConsole(); -console.log("||||||||||||||||||||||| console", pyroConsole.output); const cpuData = ref([]); const ramData = ref([]); const isActioning = ref(false); @@ -465,6 +471,7 @@ const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>(); const uptimeSeconds = ref(0); const firstConnect = ref(true); const copied = ref(false); +const error = ref(null); const initialConsoleMessage = [ " __________________________________________________", @@ -665,6 +672,26 @@ const newLoader = ref(null); const newLoaderVersion = ref(null); const newMCVersion = ref(null); +const onReinstall = (potentialArgs: any) => { + if (!serverData.value) return; + + serverData.value.status = "installing"; + + if (potentialArgs?.loader) { + newLoader.value = potentialArgs.loader; + } + if (potentialArgs?.lVersion) { + newLoaderVersion.value = potentialArgs.lVersion; + } + if (potentialArgs?.mVersion) { + newMCVersion.value = potentialArgs.mVersion; + } + + error.value = null; + errorTitle.value = "Error"; + errorMessage.value = "An unexpected error occurred."; +}; + const handleInstallationResult = async (data: WSInstallationResultEvent) => { switch (data.result) { case "ok": { @@ -738,26 +765,6 @@ const handleInstallationResult = async (data: WSInstallationResultEvent) => { } }; -const onReinstall = (potentialArgs: any) => { - if (!serverData.value) return; - - serverData.value.status = "installing"; - - if (potentialArgs?.loader) { - newLoader.value = potentialArgs.loader; - } - if (potentialArgs?.lVersion) { - newLoaderVersion.value = potentialArgs.lVersion; - } - if (potentialArgs?.mVersion) { - newMCVersion.value = potentialArgs.mVersion; - } - - error.value = null; - errorTitle.value = "Error"; - errorMessage.value = "An unexpected error occurred."; -}; - const updateStats = (currentStats: Stats["current"]) => { isConnected.value = true; stats.value = { @@ -924,6 +931,10 @@ const cleanup = () => { onMounted(() => { isMounted.value = true; + if (server.general?.status === "suspended") { + isLoading.value = false; + return; + } if (server.error) { if (!server.error.message.includes("Forbidden")) { startPolling(); @@ -991,7 +1002,7 @@ definePageMeta({ }); -