You've already forked AstralRinth
forked from didirus/AstralRinth
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:
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
|
|||||||
120
packages/ui/src/components/base/ErrorInformationCard.vue
Normal file
120
packages/ui/src/components/base/ErrorInformationCard.vue
Normal 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>
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -54,6 +54,6 @@ export class ModrinthServerError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
super(errorMessage)
|
super(errorMessage)
|
||||||
this.name = 'PyroServersFetchError'
|
this.name = 'ModrinthServersFetchError'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user