Modrinth Servers February Release: Bug Fix Round 1 (#3267)

* chore(pyroservers): attempt better error propogation

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(pyroservers): introduce deferred modules

* fix(pyroservers): synchronize server icon processing

Signed-off-by: Evan Song <theevansong@gmail.com>

* refactor: server action buttons

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: bring back skeleton

* fix(startup): populate values on refresh

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: properly refresh network

Signed-off-by: Evan Song <theevansong@gmail.com>

* 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 <theevansong@gmail.com>

* feat: parse links in console log

* fix: attempt to mitigate power button state flash

Signed-off-by: Evan Song <theevansong@gmail.com>

* Revert "fix: attempt to mitigate power button state flash"

This reverts commit 3ba5c0b4f7f5bacf1576aba5efe42785696a5aed.

* refactor: error accumulation builder in PyroServersFetch

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: sentence case

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(files): await deferred fs

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: startup border

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: prevent suspended server errors from being overwritten

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: add server id copy button to suspended server listing

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: refresh behavior

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: behavior of server icon in options

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: prevent error inspector failures from destroying the page

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: remove nexttick wrapper

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: ensure file edit gets initted due to deferred module

Signed-off-by: Evan Song <theevansong@gmail.com>

* refactor: prevent module errors from breaking the layout

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

---------

Signed-off-by: Evan Song <theevansong@gmail.com>
This commit is contained in:
Evan Song 2025-02-18 15:17:50 -07:00 committed by GitHub
parent 6c4548a303
commit a88593fec5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1170 additions and 612 deletions

View File

@ -106,6 +106,7 @@ const fetchSettings = async () => {
initialSettings.value = settings as { interval: number; enabled: boolean }; initialSettings.value = settings as { interval: number; enabled: boolean };
autoBackupEnabled.value = settings?.enabled ?? false; autoBackupEnabled.value = settings?.enabled ?? false;
autoBackupInterval.value = settings?.interval || 6; autoBackupInterval.value = settings?.interval || 6;
return true;
} catch (error) { } catch (error) {
console.error("Error fetching backup settings:", error); console.error("Error fetching backup settings:", error);
addNotification({ addNotification({
@ -114,6 +115,7 @@ const fetchSettings = async () => {
text: "Failed to load backup settings", text: "Failed to load backup settings",
type: "error", type: "error",
}); });
return false;
} finally { } finally {
isLoadingSettings.value = false; isLoadingSettings.value = false;
} }
@ -155,8 +157,10 @@ const saveSettings = async () => {
defineExpose({ defineExpose({
show: async () => { show: async () => {
await fetchSettings(); const success = await fetchSettings();
if (success) {
modal.value?.show(); modal.value?.show();
}
}, },
}); });
</script> </script>

View File

@ -0,0 +1,80 @@
<template>
<div
aria-hidden="true"
style="font-variant-numeric: tabular-nums"
class="pointer-events-none h-full w-full select-none"
>
<div class="flex flex-col gap-6">
<div class="flex flex-row items-center gap-6">
<div
class="relative max-h-[156px] min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
>
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">0.00%</h2>
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 100%</h3>
</div>
<h3 class="relative z-10 text-base font-normal text-secondary">CPU usage</h3>
</div>
<CPUIcon class="absolute right-10 top-10" />
</div>
<div
class="relative max-h-[156px] min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
>
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">0.00%</h2>
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 100%</h3>
</div>
<h3 class="relative z-10 text-base font-normal text-secondary">Memory usage</h3>
</div>
<DBIcon class="absolute right-10 top-10" />
</div>
<div
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8 transition-transform duration-100 hover:scale-105 active:scale-100"
>
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">0 B</h2>
</div>
<h3 class="relative z-10 text-base font-normal text-secondary">Storage usage</h3>
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
</div>
</div>
<div
class="relative flex h-full w-full flex-col gap-3 overflow-hidden rounded-2xl bg-bg-raised p-8"
>
<div class="experimental-styles-within flex flex-row items-center">
<div class="flex flex-row items-center gap-4">
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
</div>
</div>
<div class="relative w-full">
<input type="text" placeholder="Search logs" class="h-12 !w-full !pl-10 !pr-48" />
<SearchIcon class="absolute left-4 top-1/2 -translate-y-1/2" />
</div>
<div
class="console relative h-full min-h-[516px] w-full overflow-hidden rounded-xl bg-bg text-sm"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CPUIcon, DBIcon, FolderOpenIcon, SearchIcon } from "@modrinth/assets";
</script>
<style scoped>
html.light-mode .console {
background: var(--color-bg);
}
html.dark-mode .console {
background: black;
}
html.oled-mode .console {
background: black;
}
</style>

View File

@ -1,23 +1,25 @@
<template> <template>
<div class="contents"> <div class="contents">
<NewModal ref="confirmActionModal" header="Confirming power action" @close="closePowerModal"> <NewModal ref="confirmActionModal" header="Confirming power action" @close="resetPowerAction">
<div class="flex flex-col gap-4 md:w-[400px]"> <div class="flex flex-col gap-4 md:w-[400px]">
<p class="m-0">Are you sure you want to {{ currentPendingAction }} the server?</p> <p class="m-0">
Are you sure you want to <span class="lowercase">{{ confirmActionText }}</span> the
server?
</p>
<UiCheckbox <UiCheckbox
v-model="powerDontAskAgainCheckbox" v-model="dontAskAgain"
label="Don't ask me again" label="Don't ask me again"
class="text-sm" class="text-sm"
:disabled="!currentPendingAction" :disabled="!powerAction"
/> />
<div class="flex flex-row gap-4"> <div class="flex flex-row gap-4">
<ButtonStyled type="standard" color="brand" @click="confirmAction"> <ButtonStyled type="standard" color="brand" @click="executePowerAction">
<button> <button>
<CheckIcon class="h-5 w-5" /> <CheckIcon class="h-5 w-5" />
{{ currentPendingActionFriendly }} server {{ confirmActionText }} server
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled @click="closePowerModal"> <ButtonStyled @click="resetPowerAction">
<button> <button>
<XIcon class="h-5 w-5" /> <XIcon class="h-5 w-5" />
Cancel Cancel
@ -29,7 +31,7 @@
<NewModal <NewModal
ref="detailsModal" ref="detailsModal"
:header="`All of ${props.serverName ? props.serverName : 'Server'} info`" :header="`All of ${serverName || 'Server'} info`"
@close="closeDetailsModal" @close="closeDetailsModal"
> >
<UiServersServerInfoLabels <UiServersServerInfoLabels
@ -51,39 +53,29 @@
<UiServersPanelSpinner class="size-5" /> Installing... <UiServersPanelSpinner class="size-5" /> Installing...
</button> </button>
</ButtonStyled> </ButtonStyled>
<div v-else class="contents">
<template v-else>
<ButtonStyled v-if="showStopButton" type="transparent"> <ButtonStyled v-if="showStopButton" type="transparent">
<button :disabled="!canTakeAction || disabled || isStopping" @click="stopServer"> <button :disabled="!canTakeAction" @click="initiateAction('stop')">
<div class="flex gap-1"> <div class="flex gap-1">
<StopCircleIcon class="h-5 w-5" /> <StopCircleIcon class="h-5 w-5" />
<span>{{ stopButtonText }}</span> <span>{{ isStoppingState ? "Stopping..." : "Stop" }}</span>
</div> </div>
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled type="standard" color="brand"> <ButtonStyled type="standard" color="brand">
<button :disabled="!canTakeAction || disabled || isStopping" @click="handleAction"> <button :disabled="!canTakeAction" @click="handlePrimaryAction">
<div v-if="isStartingOrRestarting" class="grid place-content-center"> <div v-if="isTransitionState" class="grid place-content-center">
<UiServersIconsLoadingIcon /> <UiServersIconsLoadingIcon />
</div> </div>
<div v-else class="contents"> <component :is="isRunning ? UpdatedIcon : PlayIcon" v-else />
<component :is="showRestartIcon ? UpdatedIcon : PlayIcon" /> <span>{{ primaryActionText }}</span>
</div>
<span>
{{ actionButtonText }}
</span>
</button> </button>
</ButtonStyled> </ButtonStyled>
</div>
<!-- Dropdown options -->
<ButtonStyled circular type="transparent"> <ButtonStyled circular type="transparent">
<UiServersTeleportOverflowMenu <UiServersTeleportOverflowMenu :options="[...menuOptions]">
:options="[
...(props.isInstalling ? [] : [{ id: 'kill', action: () => killServer() }]),
{ id: 'allServers', action: () => router.push('/servers/manage') },
{ id: 'details', action: () => showDetailsModal() },
]"
>
<MoreVerticalIcon aria-hidden="true" /> <MoreVerticalIcon aria-hidden="true" />
<template #kill> <template #kill>
<SlashIcon class="h-5 w-5" /> <SlashIcon class="h-5 w-5" />
@ -99,27 +91,36 @@
</template> </template>
</UiServersTeleportOverflowMenu> </UiServersTeleportOverflowMenu>
</ButtonStyled> </ButtonStyled>
</template>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from "vue"; import { ref, computed } from "vue";
import { import {
PlayIcon, PlayIcon,
UpdatedIcon, UpdatedIcon,
StopCircleIcon, StopCircleIcon,
SlashIcon, SlashIcon,
MoreVerticalIcon,
XIcon, XIcon,
CheckIcon, CheckIcon,
ServerIcon, ServerIcon,
InfoIcon, InfoIcon,
MoreVerticalIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui"; import { ButtonStyled, NewModal } from "@modrinth/ui";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useStorage } from "@vueuse/core"; import { useStorage } from "@vueuse/core";
type ServerAction = "start" | "stop" | "restart" | "kill";
type ServerState = "stopped" | "starting" | "running" | "stopping" | "restarting";
interface PowerAction {
action: ServerAction;
nextState: ServerState;
}
const props = defineProps<{ const props = defineProps<{
isOnline: boolean; isOnline: boolean;
isActioning: boolean; isActioning: boolean;
@ -130,183 +131,142 @@ const props = defineProps<{
uptimeSeconds: number; uptimeSeconds: number;
}>(); }>();
const emit = defineEmits<{
(e: "action", action: ServerAction): void;
}>();
const router = useRouter(); const router = useRouter();
const serverId = router.currentRoute.value.params.id; const serverId = router.currentRoute.value.params.id;
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null);
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null);
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, { const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
powerDontAskAgain: false, powerDontAskAgain: false,
}); });
const emit = defineEmits<{ const serverState = ref<ServerState>(props.isOnline ? "running" : "stopped");
(e: "action", action: "start" | "restart" | "stop" | "kill"): void; const powerAction = ref<PowerAction | null>(null);
}>(); const dontAskAgain = ref(false);
const startingDelay = ref(false);
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null);
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null);
const ServerState = {
Stopped: "Stopped",
Starting: "Starting",
Running: "Running",
Stopping: "Stopping",
Restarting: "Restarting",
} as const;
type ServerStateType = (typeof ServerState)[keyof typeof ServerState];
const currentPendingAction = ref<string | null>(null);
const currentPendingState = ref<ServerStateType | null>(null);
const powerDontAskAgainCheckbox = ref(false);
const currentState = ref<ServerStateType>(
props.isOnline ? ServerState.Running : ServerState.Stopped,
);
const isStartingDelay = ref(false);
const showStopButton = computed(
() => currentState.value === ServerState.Running || currentState.value === ServerState.Stopping,
);
const showRestartIcon = computed(() => currentState.value === ServerState.Running);
const canTakeAction = computed( const canTakeAction = computed(
() => () => !props.isActioning && !startingDelay.value && !isTransitionState.value,
!props.isActioning &&
!isStartingDelay.value &&
currentState.value !== ServerState.Starting &&
currentState.value !== ServerState.Stopping,
); );
const isRunning = computed(() => serverState.value === "running");
const isStartingOrRestarting = computed( const isTransitionState = computed(() =>
() => ["starting", "stopping", "restarting"].includes(serverState.value),
currentState.value === ServerState.Starting || currentState.value === ServerState.Restarting,
); );
const isStoppingState = computed(() => serverState.value === "stopping");
const showStopButton = computed(() => isRunning.value || isStoppingState.value);
const isStopping = computed(() => currentState.value === ServerState.Stopping); const primaryActionText = computed(() => {
const states: Record<ServerState, string> = {
const actionButtonText = computed(() => { starting: "Starting...",
switch (currentState.value) { restarting: "Restarting...",
case ServerState.Starting: running: "Restart",
return "Starting..."; stopping: "Stopping...",
case ServerState.Restarting: stopped: "Start",
return "Restarting..."; };
case ServerState.Running: return states[serverState.value];
return "Restart";
case ServerState.Stopping:
return "Stopping...";
default:
return "Start";
}
}); });
const currentPendingActionFriendly = computed(() => { const confirmActionText = computed(() => {
switch (currentPendingAction.value) { if (!powerAction.value) return "";
case "start": return powerAction.value.action.charAt(0).toUpperCase() + powerAction.value.action.slice(1);
return "Start";
case "restart":
return "Restart";
case "stop":
return "Stop";
case "kill":
return "Kill";
default:
return null;
}
}); });
const stopButtonText = computed(() => const menuOptions = computed(() => [
currentState.value === ServerState.Stopping ? "Stopping..." : "Stop", ...(props.isInstalling
); ? []
: [
{
id: "kill",
label: "Kill server",
icon: SlashIcon,
action: () => initiateAction("kill"),
},
]),
{
id: "allServers",
label: "All servers",
icon: ServerIcon,
action: () => router.push("/servers/manage"),
},
{
id: "details",
label: "Details",
icon: InfoIcon,
action: () => detailsModal.value?.show(),
},
]);
const createPendingAction = () => { function initiateAction(action: ServerAction) {
if (!canTakeAction.value) return; if (!canTakeAction.value) return;
if (currentState.value === ServerState.Running) {
currentPendingAction.value = "restart"; const stateMap: Record<ServerAction, ServerState> = {
currentPendingState.value = ServerState.Restarting; start: "starting",
showPowerModal(); stop: "stopping",
} else { restart: "restarting",
runAction("start", ServerState.Starting); kill: "stopping",
};
if (action === "start") {
emit("action", action);
serverState.value = stateMap[action];
startingDelay.value = true;
setTimeout(() => (startingDelay.value = false), 5000);
return;
} }
};
const handleAction = () => { powerAction.value = { action, nextState: stateMap[action] };
createPendingAction();
};
const showPowerModal = () => {
if (userPreferences.value.powerDontAskAgain) { if (userPreferences.value.powerDontAskAgain) {
runAction( executePowerAction();
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
currentPendingState.value!,
);
} else { } else {
confirmActionModal.value?.show(); confirmActionModal.value?.show();
} }
}; }
const confirmAction = () => { function handlePrimaryAction() {
if (powerDontAskAgainCheckbox.value) { initiateAction(isRunning.value ? "restart" : "start");
}
function executePowerAction() {
if (!powerAction.value) return;
const { action, nextState } = powerAction.value;
emit("action", action);
serverState.value = nextState;
if (dontAskAgain.value) {
userPreferences.value.powerDontAskAgain = true; userPreferences.value.powerDontAskAgain = true;
} }
runAction(
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
currentPendingState.value!,
);
closePowerModal();
};
const runAction = (action: "start" | "restart" | "stop" | "kill", serverState: ServerStateType) => {
emit("action", action);
currentState.value = serverState;
if (action === "start") { if (action === "start") {
isStartingDelay.value = true; startingDelay.value = true;
setTimeout(() => { setTimeout(() => (startingDelay.value = false), 5000);
isStartingDelay.value = false;
}, 5000);
} }
};
const stopServer = () => { resetPowerAction();
if (!canTakeAction.value) return; }
currentPendingAction.value = "stop";
currentPendingState.value = ServerState.Stopping;
showPowerModal();
};
const killServer = () => { function resetPowerAction() {
currentPendingAction.value = "kill";
currentPendingState.value = ServerState.Stopping;
showPowerModal();
};
const closePowerModal = () => {
confirmActionModal.value?.hide(); confirmActionModal.value?.hide();
currentPendingAction.value = null; powerAction.value = null;
powerDontAskAgainCheckbox.value = false; dontAskAgain.value = false;
}; }
const closeDetailsModal = () => { function closeDetailsModal() {
detailsModal.value?.hide(); detailsModal.value?.hide();
}; }
const showDetailsModal = () => {
detailsModal.value?.show();
};
watch( watch(
() => props.isOnline, () => props.isOnline,
(newValue) => { (online) => (serverState.value = online ? "running" : "stopped"),
if (newValue) {
currentState.value = ServerState.Running;
} else {
currentState.value = ServerState.Stopped;
}
},
); );
watch( watch(
() => router.currentRoute.value.fullPath, () => router.currentRoute.value.fullPath,
() => { () => closeDetailsModal(),
closeDetailsModal();
},
); );
</script> </script>

View File

@ -1,66 +1,72 @@
<template> <template>
<div <div
:aria-label="`Server is ${getStatusText}`" :aria-label="`Server is ${getStatusText(state)}`"
class="relative inline-flex select-none items-center" class="relative inline-flex select-none items-center"
@mouseenter="isExpanded = true" @mouseenter="isExpanded = true"
@mouseleave="isExpanded = false" @mouseleave="isExpanded = false"
> >
<div <div
:class="`h-4 w-4 rounded-full transition-all duration-300 ease-in-out ${getStatusClass.main}`" :class="[
'h-4 w-4 rounded-full transition-all duration-300 ease-in-out',
getStatusClass(state).main,
]"
> >
<div <div
:class="`absolute inline-flex h-4 w-4 animate-ping rounded-full ${getStatusClass.bg}`" :class="[
'absolute inline-flex h-4 w-4 animate-ping rounded-full',
getStatusClass(state).bg,
]"
></div> ></div>
</div> </div>
<div <div
:class="`absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out ${getStatusClass.bg} ${ :class="[
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0' 'absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out',
}`" getStatusClass(state).bg,
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0',
]"
> >
<div class="h-3 w-3 rounded-full"></div> <div class="h-3 w-3 rounded-full"></div>
<span <span
class="origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out" :class="[
:class="`${isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75'}`" 'origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out',
isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75',
]"
> >
{{ getStatusText }} {{ getStatusText(state) }}
</span> </span>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue"; import { ref } from "vue";
import type { ServerState } from "~/types/servers"; import type { ServerState } from "~/types/servers";
const props = defineProps<{ const STATUS_CLASSES = {
running: { main: "bg-brand", bg: "bg-bg-green" },
stopped: { main: "", bg: "" },
crashed: { main: "bg-brand-red", bg: "bg-bg-red" },
unknown: { main: "", bg: "" },
} as const;
const STATUS_TEXTS = {
running: "Running",
stopped: "",
crashed: "Crashed",
unknown: "Unknown",
} as const;
defineProps<{
state: ServerState; state: ServerState;
}>(); }>();
const isExpanded = ref(false); const isExpanded = ref(false);
const getStatusClass = computed(() => { function getStatusClass(state: ServerState) {
switch (props.state) { return STATUS_CLASSES[state] ?? STATUS_CLASSES.unknown;
case "running": }
return { main: "bg-brand", bg: "bg-bg-green" };
case "stopped":
return { main: "", bg: "" };
case "crashed":
return { main: "bg-brand-red", bg: "bg-bg-red" };
default:
return { main: "", bg: "" };
}
});
const getStatusText = computed(() => { function getStatusText(state: ServerState) {
switch (props.state) { return STATUS_TEXTS[state] ?? STATUS_TEXTS.unknown;
case "running": }
return "Running";
case "stopped":
return "";
case "crashed":
return "Crashed";
default:
return "Unknown";
}
});
</script> </script>

View File

@ -260,7 +260,25 @@
</div> </div>
<NewModal ref="viewLogModal" class="z-[9999]" header="Viewing selected logs"> <NewModal ref="viewLogModal" class="z-[9999]" header="Viewing selected logs">
<div class="text-contrast"> <div class="text-contrast">
<pre class="select-text overflow-x-auto whitespace-pre font-mono">{{ selectedLog }}</pre> <pre
class="select-text overflow-x-auto whitespace-pre rounded-lg bg-bg font-mono"
v-html="processedLogWithLinks"
></pre>
<div v-if="detectedLinks.length" class="border-contrast/20 mt-4 border-t pt-4">
<h2>Detected Links</h2>
<ul class="flex flex-col gap-2">
<li v-for="(link, index) in detectedLinks" :key="index">
<a
:href="link"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-blue hover:underline"
>
{{ link }}
</a>
</li>
</ul>
</div>
</div> </div>
</NewModal> </NewModal>
</div> </div>
@ -272,6 +290,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import { useDebounceFn } from "@vueuse/core"; import { useDebounceFn } from "@vueuse/core";
import { NewModal } from "@modrinth/ui"; import { NewModal } from "@modrinth/ui";
import ButtonStyled from "@modrinth/ui/src/components/base/ButtonStyled.vue"; import ButtonStyled from "@modrinth/ui/src/components/base/ButtonStyled.vue";
import DOMPurify from "dompurify";
import { usePyroConsole } from "~/store/console.ts"; import { usePyroConsole } from "~/store/console.ts";
const { $cosmetics } = useNuxtApp(); 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 `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer nofollow" class="text-blue hover:underline">${url}</a>`;
});
});
watch( watch(
() => pyroConsole.filteredOutput.value, () => pyroConsole.filteredOutput.value,
() => { () => {

View File

@ -337,7 +337,7 @@ watch(
selectedLoaderVersions, selectedLoaderVersions,
(newVersions) => { (newVersions) => {
if (newVersions.length > 0 && !selectedLoaderVersion.value) { if (newVersions.length > 0 && !selectedLoaderVersion.value) {
selectedLoaderVersion.value = String(newVersions[0]); // Ensure string type selectedLoaderVersion.value = String(newVersions[0]);
} }
}, },
{ immediate: true }, { immediate: true },
@ -516,8 +516,6 @@ const handleReinstall = async () => {
const onShow = () => { const onShow = () => {
selectedMCVersion.value = props.server.general?.mc_version || ""; selectedMCVersion.value = props.server.general?.mc_version || "";
selectedLoaderVersion.value = "";
hardReset.value = false;
}; };
const onHide = () => { const onHide = () => {
@ -528,13 +526,15 @@ const onHide = () => {
loadingServerCheck.value = false; loadingServerCheck.value = false;
isLoading.value = false; isLoading.value = false;
selectedMCVersion.value = ""; selectedMCVersion.value = "";
selectedLoaderVersion.value = "";
serverCheckError.value = ""; serverCheckError.value = "";
paperVersions.value = {}; paperVersions.value = {};
purpurVersions.value = {}; purpurVersions.value = {};
}; };
const show = (loader: Loaders) => { const show = (loader: Loaders) => {
if (selectedLoader.value !== loader) {
selectedLoaderVersion.value = "";
}
selectedLoader.value = loader; selectedLoader.value = loader;
selectedMCVersion.value = props.server.general?.mc_version || ""; selectedMCVersion.value = props.server.general?.mc_version || "";
versionSelectModal.value?.show(); versionSelectModal.value?.show();

View File

@ -69,11 +69,13 @@
</div> </div>
<div <div
v-else-if="status === 'suspended' && suspension_reason !== 'upgrading'" v-else-if="status === 'suspended' && suspension_reason !== 'upgrading'"
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast" class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
> >
<UiServersIconsPanelErrorIcon class="!size-5" /> <div class="flex flex-row gap-2">
Your server has been suspended due to a billing issue. Please visit your billing settings or <UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please
contact Modrinth Support for more information. update your billing information or contact Modrinth Support for more information.
</div>
<UiCopyCode :text="`${props.server_id}`" class="ml-auto" />
</div> </div>
</NuxtLink> </NuxtLink>
</template> </template>

View File

@ -2,7 +2,7 @@
<div <div
v-if="uptimeSeconds || uptimeSeconds !== 0" v-if="uptimeSeconds || uptimeSeconds !== 0"
v-tooltip="`Online for ${verboseUptime}`" v-tooltip="`Online for ${verboseUptime}`"
class="flex min-w-0 flex-row items-center gap-4" class="server-action-buttons-anim flex min-w-0 flex-row items-center gap-4"
data-pyro-uptime data-pyro-uptime
> >
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div> <div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>

View File

@ -10,19 +10,111 @@ interface PyroFetchOptions {
url?: string; url?: string;
token?: string; token?: string;
}; };
retry?: boolean; retry?: number | boolean;
} }
async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promise<T> { class PyroServerError extends Error {
public readonly errors: Map<string, Error> = 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<T>(
path: string,
options: PyroFetchOptions = {},
module?: string,
): Promise<T> {
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const auth = await useAuth(); const auth = await useAuth();
const authToken = auth.value?.token; const authToken = auth.value?.token;
if (!authToken) { 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( const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
/\/$/, /\/$/,
@ -30,9 +122,11 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
); );
if (!base) { if (!base) {
throw new PyroFetchError( throw new PyroServersFetchError(
"Cannot pyrofetch without base url. Make sure to set a PYRO_BASE_URL in environment variables", "Configuration error: Missing PYRO_BASE_URL",
10001, 500,
undefined,
module,
); );
} }
@ -40,9 +134,7 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
? `https://${override.url}/${path.replace(/^\//, "")}` ? `https://${override.url}/${path.replace(/^\//, "")}`
: `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`; : `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`;
type HeadersRecord = Record<string, string>; const headers: Record<string, string> = {
const headers: HeadersRecord = {
Authorization: `Bearer ${override?.token ?? authToken}`, Authorization: `Bearer ${override?.token ?? authToken}`,
"Access-Control-Allow-Headers": "Authorization", "Access-Control-Allow-Headers": "Authorization",
"User-Agent": "Pyro/1.0 (https://pyro.host)", "User-Agent": "Pyro/1.0 (https://pyro.host)",
@ -57,43 +149,47 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
headers.Origin = window.location.origin; headers.Origin = window.location.origin;
} }
let attempts = 0;
const maxAttempts = (typeof retry === "boolean" ? (retry ? 1 : 0) : retry) + 1;
let lastError: Error | null = null;
while (attempts < maxAttempts) {
try { try {
const response = await $fetch<T>(fullUrl, { const response = await $fetch<T>(fullUrl, {
method, method,
headers, headers,
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined, body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
timeout: 10000, timeout: 10000,
retry: options.retry !== false ? (method === "GET" ? 3 : 0) : 0,
}); });
return response; return response;
} catch (error) { } catch (error) {
console.error("[PyroServers/PyroFetch]:", error); lastError = error as Error;
attempts++;
if (error instanceof FetchError) { if (error instanceof FetchError) {
const statusCode = error.response?.status; const statusCode = error.response?.status;
const statusText = error.response?.statusText || "[no status text available]"; const isRetryable = statusCode ? [408, 429, 500, 502, 503, 504].includes(statusCode) : true;
const errorMessages: { [key: number]: string } = {
400: "Bad Request", if (!isRetryable || attempts >= maxAttempts) {
401: "Unauthorized", throw new PyroServersFetchError(error.message, statusCode, error, module);
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);
} }
throw new PyroFetchError(
"[PyroServers/PyroFetch] An unexpected error occurred during the fetch operation.", 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, undefined,
error as Error, error as Error,
module,
); );
} }
}
throw lastError || new Error("Maximum retry attempts reached");
} }
const internalServerRefrence = ref<any>(null); const internalServerRefrence = ref<any>(null);
@ -271,11 +367,15 @@ const constructServerProperties = (properties: any): string => {
}; };
const processImage = async (iconUrl: string | undefined) => { const processImage = async (iconUrl: string | undefined) => {
const image = ref<string | null>(null);
const sharedImage = useState<string | undefined>( const sharedImage = useState<string | undefined>(
`server-icon-${internalServerRefrence.value.serverId}`, `server-icon-${internalServerRefrence.value.serverId}`,
() => undefined,
); );
if (sharedImage.value) {
return sharedImage.value;
}
try {
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerRefrence.value.serverId}/fs`); const auth = await PyroFetch<JWTAuth>(`servers/${internalServerRefrence.value.serverId}/fs`);
try { try {
const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, { const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, {
@ -285,86 +385,78 @@ const processImage = async (iconUrl: string | undefined) => {
if (fileData instanceof Blob) { if (fileData instanceof Blob) {
if (import.meta.client) { if (import.meta.client) {
const dataURL = await new Promise<string>((resolve) => {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
const img = new Image(); const img = new Image();
img.src = URL.createObjectURL(fileData);
await new Promise<void>((resolve) => {
img.onload = () => { img.onload = () => {
canvas.width = 512; canvas.width = 512;
canvas.height = 512; canvas.height = 512;
ctx?.drawImage(img, 0, 0, 512, 512); ctx?.drawImage(img, 0, 0, 512, 512);
const dataURL = canvas.toDataURL("image/png"); const dataURL = canvas.toDataURL("image/png");
internalServerRefrence.value.general.image = dataURL; sharedImage.value = dataURL;
image.value = dataURL; resolve(dataURL);
sharedImage.value = dataURL; // Store in useState URL.revokeObjectURL(img.src);
resolve();
}; };
img.src = URL.createObjectURL(fileData);
}); });
return dataURL;
} }
} }
} catch (error) { } catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 404) { if (error instanceof PyroServersFetchError && error.statusCode === 404 && iconUrl) {
sharedImage.value = undefined;
} else {
console.error(error);
}
}
if (image.value === null && iconUrl) {
console.log("iconUrl", iconUrl);
try { try {
const response = await fetch(iconUrl); const response = await fetch(iconUrl);
if (!response.ok) throw new Error("Failed to fetch icon");
const file = await response.blob(); const file = await response.blob();
const originalfile = new File([file], "server-icon-original.png", { const originalFile = new File([file], "server-icon-original.png", { type: "image/png" });
type: "image/png",
});
if (import.meta.client) { if (import.meta.client) {
const scaledFile = await new Promise<File>((resolve, reject) => { const dataURL = await new Promise<string>((resolve) => {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
const img = new Image(); const img = new Image();
img.src = URL.createObjectURL(file);
img.onload = () => { img.onload = () => {
canvas.width = 64; canvas.width = 64;
canvas.height = 64; canvas.height = 64;
ctx?.drawImage(img, 0, 0, 64, 64); ctx?.drawImage(img, 0, 0, 64, 64);
canvas.toBlob((blob) => { canvas.toBlob(async (blob) => {
if (blob) { if (blob) {
const data = new File([blob], "server-icon.png", { type: "image/png" }); const scaledFile = 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`, { await PyroFetch(`/create?path=/server-icon.png&type=file`, {
method: "POST", method: "POST",
contentType: "application/octet-stream", contentType: "application/octet-stream",
body: scaledFile, body: scaledFile,
override: auth, override: auth,
}); });
await PyroFetch(`/create?path=/server-icon-original.png&type=file`, { await PyroFetch(`/create?path=/server-icon-original.png&type=file`, {
method: "POST", method: "POST",
contentType: "application/octet-stream", contentType: "application/octet-stream",
body: originalfile, body: originalFile,
override: auth, 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) { } catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 404) { console.error("Failed to process external icon:", error);
console.log("[PYROSERVERS] No server icon found");
} else {
console.error(error);
} }
} }
} }
return image.value; } catch (error) {
console.error("Failed to process server icon:", error);
}
sharedImage.value = undefined;
return undefined;
}; };
// ------------------ GENERAL ------------------ // // ------------------ GENERAL ------------------ //
@ -564,10 +656,14 @@ const reinstallContent = async (replace: string, projectId: string, versionId: s
const createBackup = async (backupName: string) => { const createBackup = async (backupName: string) => {
try { try {
const response = (await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups`, { const response = await PyroFetch<{ id: string }>(
`servers/${internalServerRefrence.value.serverId}/backups`,
{
method: "POST", method: "POST",
body: { name: backupName }, body: { name: backupName },
})) as { id: string }; },
);
await internalServerRefrence.value.refresh(["backups"]);
return response.id; return response.id;
} catch (error) { } catch (error) {
console.error("Error creating backup:", error); console.error("Error creating backup:", error);
@ -581,6 +677,7 @@ const renameBackup = async (backupId: string, newName: string) => {
method: "POST", method: "POST",
body: { name: newName }, body: { name: newName },
}); });
await internalServerRefrence.value.refresh(["backups"]);
} catch (error) { } catch (error) {
console.error("Error renaming backup:", error); console.error("Error renaming backup:", error);
throw error; throw error;
@ -592,6 +689,7 @@ const deleteBackup = async (backupId: string) => {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}`, { await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}`, {
method: "DELETE", method: "DELETE",
}); });
await internalServerRefrence.value.refresh(["backups"]);
} catch (error) { } catch (error) {
console.error("Error deleting backup:", error); console.error("Error deleting backup:", error);
throw error; throw error;
@ -606,6 +704,7 @@ const restoreBackup = async (backupId: string) => {
method: "POST", method: "POST",
}, },
); );
await internalServerRefrence.value.refresh(["backups"]);
} catch (error) { } catch (error) {
console.error("Error restoring backup:", error); console.error("Error restoring backup:", error);
throw error; throw error;
@ -644,12 +743,10 @@ const getAutoBackup = async () => {
const lockBackup = async (backupId: string) => { const lockBackup = async (backupId: string) => {
try { try {
return await PyroFetch( await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`, {
`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`,
{
method: "POST", method: "POST",
}, });
); await internalServerRefrence.value.refresh(["backups"]);
} catch (error) { } catch (error) {
console.error("Error locking backup:", error); console.error("Error locking backup:", error);
throw error; throw error;
@ -658,14 +755,12 @@ const lockBackup = async (backupId: string) => {
const unlockBackup = async (backupId: string) => { const unlockBackup = async (backupId: string) => {
try { try {
return await PyroFetch( await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`, {
`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`,
{
method: "POST", method: "POST",
}, });
); await internalServerRefrence.value.refresh(["backups"]);
} catch (error) { } catch (error) {
console.error("Error locking backup:", error); console.error("Error unlocking backup:", error);
throw error; throw error;
} }
}; };
@ -760,7 +855,7 @@ const retryWithAuth = async (requestFn: () => Promise<any>) => {
try { try {
return await requestFn(); return await requestFn();
} catch (error) { } catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 401) { if (error instanceof PyroServersFetchError && error.statusCode === 401) {
await internalServerRefrence.value.refresh(["fs"]); await internalServerRefrence.value.refresh(["fs"]);
return await requestFn(); return await requestFn();
} }
@ -947,17 +1042,18 @@ const modules: any = {
general: { general: {
get: async (serverId: string) => { get: async (serverId: string) => {
try { try {
const data = await PyroFetch<General>(`servers/${serverId}`); const data = await PyroFetch<General>(`servers/${serverId}`, {}, "general");
// TODO: temp hack to fix hydration error
if (data.upstream?.project_id) { if (data.upstream?.project_id) {
const res = await $fetch( const res = await $fetch(
`https://api.modrinth.com/v2/project/${data.upstream.project_id}`, `https://api.modrinth.com/v2/project/${data.upstream.project_id}`,
); );
data.project = res as Project; data.project = res as Project;
} }
if (import.meta.client) { if (import.meta.client) {
data.image = (await processImage(data.project?.icon_url)) ?? undefined; data.image = (await processImage(data.project?.icon_url)) ?? undefined;
} }
const motd = await getMotd(); const motd = await getMotd();
if (motd === "A Minecraft Server") { if (motd === "A Minecraft Server") {
await setMotd( await setMotd(
@ -967,8 +1063,19 @@ const modules: any = {
data.motd = motd; data.motd = motd;
return data; return data;
} catch (error) { } catch (error) {
internalServerRefrence.value.setError(error); const fetchError =
return undefined; 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, updateName,
@ -982,16 +1089,23 @@ const modules: any = {
content: { content: {
get: async (serverId: string) => { get: async (serverId: string) => {
try { try {
const mods = await PyroFetch<Mod[]>(`servers/${serverId}/mods`); const mods = await PyroFetch<Mod[]>(`servers/${serverId}/mods`, {}, "content");
return { return {
data: data: mods.sort((a, b) => (a?.name ?? "").localeCompare(b?.name ?? "")),
internalServerRefrence.value.error === undefined
? mods.sort((a, b) => (a?.name ?? "").localeCompare(b?.name ?? ""))
: [],
}; };
} catch (error) { } catch (error) {
internalServerRefrence.value.setError(error); const fetchError =
return undefined; error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
data: [],
error: {
error: fetchError,
timestamp: Date.now(),
},
};
} }
}, },
install: installContent, install: installContent,
@ -1001,10 +1115,22 @@ const modules: any = {
backups: { backups: {
get: async (serverId: string) => { get: async (serverId: string) => {
try { try {
return { data: await PyroFetch<Backup[]>(`servers/${serverId}/backups`) }; return {
data: await PyroFetch<Backup[]>(`servers/${serverId}/backups`, {}, "backups"),
};
} catch (error) { } catch (error) {
internalServerRefrence.value.setError(error); const fetchError =
return undefined; error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
data: [],
error: {
error: fetchError,
timestamp: Date.now(),
},
};
} }
}, },
create: createBackup, create: createBackup,
@ -1020,10 +1146,26 @@ const modules: any = {
network: { network: {
get: async (serverId: string) => { get: async (serverId: string) => {
try { try {
return { allocations: await PyroFetch<Allocation[]>(`servers/${serverId}/allocations`) }; return {
allocations: await PyroFetch<Allocation[]>(
`servers/${serverId}/allocations`,
{},
"network",
),
};
} catch (error) { } catch (error) {
internalServerRefrence.value.setError(error); const fetchError =
return undefined; error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
allocations: [],
error: {
error: fetchError,
timestamp: Date.now(),
},
};
} }
}, },
reserveAllocation, reserveAllocation,
@ -1035,10 +1177,19 @@ const modules: any = {
startup: { startup: {
get: async (serverId: string) => { get: async (serverId: string) => {
try { try {
return await PyroFetch<Startup>(`servers/${serverId}/startup`); return await PyroFetch<Startup>(`servers/${serverId}/startup`, {}, "startup");
} catch (error) { } catch (error) {
internalServerRefrence.value.setError(error); const fetchError =
return undefined; error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
error: {
error: fetchError,
timestamp: Date.now(),
},
};
} }
}, },
update: updateStartupSettings, update: updateStartupSettings,
@ -1046,20 +1197,39 @@ const modules: any = {
ws: { ws: {
get: async (serverId: string) => { get: async (serverId: string) => {
try { try {
return await PyroFetch<JWTAuth>(`servers/${serverId}/ws`); return await PyroFetch<JWTAuth>(`servers/${serverId}/ws`, {}, "ws");
} catch (error) { } catch (error) {
internalServerRefrence.value.setError(error); const fetchError =
return undefined; error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
error: {
error: fetchError,
timestamp: Date.now(),
},
};
} }
}, },
}, },
fs: { fs: {
get: async (serverId: string) => { get: async (serverId: string) => {
try { try {
return { auth: await PyroFetch<JWTAuth>(`servers/${serverId}/fs`) }; return { auth: await PyroFetch<JWTAuth>(`servers/${serverId}/fs`, {}, "fs") };
} catch (error) { } catch (error) {
internalServerRefrence.value.setError(error); const fetchError =
return undefined; error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
auth: undefined,
error: {
error: fetchError,
timestamp: Date.now(),
},
};
} }
}, },
listDirContents, listDirContents,
@ -1367,12 +1537,44 @@ type FSFunctions = {
downloadFile: (path: string, raw?: boolean) => Promise<any>; downloadFile: (path: string, raw?: boolean) => Promise<any>;
}; };
type GeneralModule = General & GeneralFunctions; type ModuleError = {
type ContentModule = { data: Mod[] } & ContentFunctions; error: PyroServersFetchError;
type BackupsModule = { data: Backup[] } & BackupFunctions; timestamp: number;
type NetworkModule = { allocations: Allocation[] } & NetworkFunctions; };
type StartupModule = Startup & StartupFunctions;
export type FSModule = { auth: JWTAuth } & FSFunctions; 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 = { type ModulesMap = {
general: GeneralModule; general: GeneralModule;
@ -1380,7 +1582,7 @@ type ModulesMap = {
backups: BackupsModule; backups: BackupsModule;
network: NetworkModule; network: NetworkModule;
startup: StartupModule; startup: StartupModule;
ws: JWTAuth; ws: WSModule;
fs: FSModule; fs: FSModule;
}; };
@ -1401,6 +1603,7 @@ export type Server<T extends avaliableModules> = {
preserveInstallState?: boolean; preserveInstallState?: boolean;
}, },
) => Promise<void>; ) => Promise<void>;
loadModules: (modulesToLoad: avaliableModules) => Promise<void>;
setError: (error: Error) => void; setError: (error: Error) => void;
error?: Error; error?: Error;
serverId: string; serverId: string;
@ -1419,58 +1622,92 @@ export const usePyroServer = async (serverId: string, includedModules: avaliable
return; return;
} }
const modulesToRefresh = refreshModules || includedModules; const modulesToRefresh = [...new Set(refreshModules || includedModules)];
const promises: Promise<void>[] = []; const serverError = new PyroServerError();
const uniqueModules = [...new Set(modulesToRefresh)]; const modulePromises = modulesToRefresh.map(async (module) => {
try {
for (const module of uniqueModules) {
const mods = modules[module]; const mods = modules[module];
if (mods.get) { if (!mods?.get) return;
promises.push(
(async () => {
const data = await mods.get(serverId); const data = await mods.get(serverId);
if (data) { if (!data) return;
if (module === "general" && options?.preserveConnection) { if (module === "general" && options?.preserveConnection) {
const updatedData = { server[module] = {
...server[module], ...server[module],
...data, ...data,
image: server[module]?.image || data.image,
motd: server[module]?.motd || data.motd,
status:
options.preserveInstallState && server[module]?.status === "installing"
? "installing"
: data.status,
}; };
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 { } else {
server[module] = { ...server[module], ...data }; 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.all(promises); 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;
newModules.forEach((module) => {
server[module] = modules[module];
});
await server.refresh(newModules);
}, },
setError: (error: Error) => { setError: (error: Error) => {
if (!server.error) {
server.error = 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, serverId,
}); });
for (const module of includedModules) { const initialModules = includedModules.filter((module) => ["general", "ws"].includes(module));
const mods = modules[module]; const deferredModules = includedModules.filter((module) => !["general", "ws"].includes(module));
server[module] = mods;
} initialModules.forEach((module) => {
server[module] = modules[module];
});
internalServerRefrence.value = server; internalServerRefrence.value = server;
await server.refresh(initialModules);
await server.refresh(); if (deferredModules.length > 0) {
await server.loadModules(deferredModules);
}
return server as Server<typeof includedModules>; return server as Server<typeof includedModules>;
}; };

View File

@ -258,7 +258,8 @@
<button <button
v-if=" v-if="
result.installed || result.installed ||
server.content.data.find((x) => x.project_id === result.project_id) || (server?.content?.data &&
server.content.data.find((x) => x.project_id === result.project_id)) ||
server.general?.project?.id === result.project_id server.general?.project?.id === result.project_id
" "
disabled disabled
@ -376,7 +377,9 @@ async function updateServerContext() {
if (!auth.value.user) { if (!auth.value.user) {
router.push("/auth/sign-in?redirect=" + encodeURIComponent(route.fullPath)); router.push("/auth/sign-in?redirect=" + encodeURIComponent(route.fullPath));
} else if (route.query.sid !== null) { } else if (route.query.sid !== null) {
server.value = await usePyroServer(route.query.sid, ["general", "content"]); server.value = await usePyroServer(route.query.sid, ["general", "content"], {
waitForModules: true,
});
} }
} }
@ -495,8 +498,8 @@ async function serverInstall(project) {
) ?? versions[0]; ) ?? versions[0];
if (projectType.value.id === "modpack") { if (projectType.value.id === "modpack") {
await server.value.general?.reinstall( await server.value.general.reinstall(
route.query.sid, server.value.serverId,
false, false,
project.project_id, project.project_id,
version.id, version.id,
@ -504,7 +507,7 @@ async function serverInstall(project) {
eraseDataOnInstall.value, eraseDataOnInstall.value,
); );
project.installed = true; project.installed = true;
navigateTo(`/servers/manage/${route.query.sid}/options/loader`); navigateTo(`/servers/manage/${server.value.serverId}/options/loader`);
} else if (projectType.value.id === "mod") { } else if (projectType.value.id === "mod") {
await server.value.content.install("mod", version.project_id, version.id); await server.value.content.install("mod", version.project_id, version.id);
await server.value.refresh(["content"]); await server.value.refresh(["content"]);

View File

@ -10,10 +10,10 @@
<div class="grid place-content-center rounded-full bg-bg-blue p-4"> <div class="grid place-content-center rounded-full bg-bg-blue p-4">
<TransferIcon class="size-12 text-blue" /> <TransferIcon class="size-12 text-blue" />
</div> </div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server Upgrading</h1> <h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server upgrading</h1>
</div> </div>
<p class="text-lg text-secondary"> <p class="text-lg text-secondary">
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!
</p> </p>
</div> </div>
</div> </div>
@ -47,17 +47,18 @@
<div class="grid place-content-center rounded-full bg-bg-orange p-4"> <div class="grid place-content-center rounded-full bg-bg-orange p-4">
<LockIcon class="size-12 text-orange" /> <LockIcon class="size-12 text-orange" />
</div> </div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server Suspended</h1> <h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server suspended</h1>
</div> </div>
<p class="text-lg text-secondary"> <p class="text-lg text-secondary">
{{ {{
serverData.suspension_reason 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: ${serverData.suspension_reason}`
: "Your server has been suspended." : "Your server has been suspended."
}} }}
<br /> <br />
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.
</p> </p>
</div> </div>
<ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')"> <ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')">
@ -66,7 +67,10 @@
</div> </div>
</div> </div>
<div <div
v-else-if="server.error && server.error.message.includes('Forbidden')" v-else-if="
server.general?.error?.error.statusCode === 403 ||
server.general?.error?.error.statusCode === 404
"
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"> <div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
@ -82,14 +86,15 @@
this is an error, please contact Modrinth support. this is an error, please contact Modrinth support.
</p> </p>
</div> </div>
<UiCopyCode :text="server.error ? String(server.error) : 'Unknown error'" /> <UiCopyCode :text="JSON.stringify(server.general?.error)" />
<ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')"> <ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')">
<button class="mt-6 !w-full">Go back to all servers</button> <button class="mt-6 !w-full">Go back to all servers</button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</div> </div>
<div <div
v-else-if="server.error && server.error.message.includes('Service Unavailable')" v-else-if="server.general?.error?.error.statusCode === 503"
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"> <div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
@ -141,7 +146,7 @@
</div> </div>
</div> </div>
<div <div
v-else-if="server.error" v-else-if="server.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"> <div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
@ -164,7 +169,7 @@
temporary network issue. You'll be reconnected automatically. temporary network issue. You'll be reconnected automatically.
</p> </p>
</div> </div>
<UiCopyCode :text="server.error ? String(server.error) : 'Unknown error'" /> <UiCopyCode :text="JSON.stringify(server.general?.error)" />
<ButtonStyled <ButtonStyled
:disabled="formattedTime !== '00'" :disabled="formattedTime !== '00'"
size="large" size="large"
@ -228,7 +233,7 @@
:show-loader-label="showLoaderLabel" :show-loader-label="showLoaderLabel"
:uptime-seconds="uptimeSeconds" :uptime-seconds="uptimeSeconds"
:linked="true" :linked="true"
class="flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex" class="server-action-buttons-anim flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
/> />
</div> </div>
</div> </div>
@ -363,7 +368,6 @@
</div> </div>
</div> </div>
</div> </div>
<NuxtPage <NuxtPage
:route="route" :route="route"
:is-connected="isConnected" :is-connected="isConnected"
@ -425,21 +429,25 @@ const createdAt = ref(
const route = useNativeRoute(); const route = useNativeRoute();
const router = useRouter(); const router = useRouter();
const serverId = route.params.id as string; const serverId = route.params.id as string;
const server = await usePyroServer(serverId, [
"general", const server = await usePyroServer(serverId, ["general", "ws"]);
"content",
"backups", const loadModulesPromise = Promise.resolve().then(() => {
"network", if (server.general?.status === "suspended") {
"startup", return;
"ws", }
"fs", return server.loadModules(["content", "backups", "network", "startup", "fs"]);
]); });
provide("modulesLoaded", loadModulesPromise);
watch( watch(
() => server.error, () => [server.general?.error, server.ws?.error],
(newError) => { ([generalError, wsError]) => {
if (server.general?.status === "suspended") return; if (server.general?.status === "suspended") return;
if (newError && !newError.message.includes("Forbidden")) {
const error = generalError?.error || wsError?.error;
if (error && error.statusCode !== 403) {
startPolling(); startPolling();
} }
}, },
@ -450,11 +458,9 @@ const errorMessage = ref("An unexpected error occurred.");
const errorLog = ref(""); const errorLog = ref("");
const errorLogFile = ref(""); const errorLogFile = ref("");
const serverData = computed(() => server.general); const serverData = computed(() => server.general);
const error = ref<Error | null>(null);
const isConnected = ref(false); const isConnected = ref(false);
const isWSAuthIncorrect = ref(false); const isWSAuthIncorrect = ref(false);
const pyroConsole = usePyroConsole(); const pyroConsole = usePyroConsole();
console.log("||||||||||||||||||||||| console", pyroConsole.output);
const cpuData = ref<number[]>([]); const cpuData = ref<number[]>([]);
const ramData = ref<number[]>([]); const ramData = ref<number[]>([]);
const isActioning = ref(false); const isActioning = ref(false);
@ -465,6 +471,7 @@ const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>();
const uptimeSeconds = ref(0); const uptimeSeconds = ref(0);
const firstConnect = ref(true); const firstConnect = ref(true);
const copied = ref(false); const copied = ref(false);
const error = ref<Error | null>(null);
const initialConsoleMessage = [ const initialConsoleMessage = [
" __________________________________________________", " __________________________________________________",
@ -665,6 +672,26 @@ const newLoader = ref<string | null>(null);
const newLoaderVersion = ref<string | null>(null); const newLoaderVersion = ref<string | null>(null);
const newMCVersion = ref<string | null>(null); const newMCVersion = ref<string | null>(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) => { const handleInstallationResult = async (data: WSInstallationResultEvent) => {
switch (data.result) { switch (data.result) {
case "ok": { 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"]) => { const updateStats = (currentStats: Stats["current"]) => {
isConnected.value = true; isConnected.value = true;
stats.value = { stats.value = {
@ -924,6 +931,10 @@ const cleanup = () => {
onMounted(() => { onMounted(() => {
isMounted.value = true; isMounted.value = true;
if (server.general?.status === "suspended") {
isLoading.value = false;
return;
}
if (server.error) { if (server.error) {
if (!server.error.message.includes("Forbidden")) { if (!server.error.message.includes("Forbidden")) {
startPolling(); startPolling();
@ -991,7 +1002,7 @@ definePageMeta({
}); });
</script> </script>
<style scoped> <style>
@keyframes server-action-buttons-anim { @keyframes server-action-buttons-anim {
0% { 0% {
opacity: 0; opacity: 0;

View File

@ -1,6 +1,30 @@
<template> <template>
<div class="contents"> <div class="contents">
<div v-if="data" class="contents"> <div
v-if="server.backups?.error"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load backups</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's backups. Here's what went wrong:
</p>
<p>
<span class="break-all font-mono">{{ JSON.stringify(server.backups.error) }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['backups'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="contents">
<LazyUiServersBackupCreateModal <LazyUiServersBackupCreateModal
ref="createBackupModal" ref="createBackupModal"
:server="server" :server="server"
@ -241,6 +265,7 @@ import {
BoxIcon, BoxIcon,
LockIcon, LockIcon,
LockOpenIcon, LockOpenIcon,
IssuesIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import type { Server } from "~/composables/pyroServers"; import type { Server } from "~/composables/pyroServers";
@ -297,33 +322,37 @@ const showbackupSettingsModal = () => {
backupSettingsModal.value?.show(); backupSettingsModal.value?.show();
}; };
const handleBackupCreated = (payload: { success: boolean; message: string }) => { const handleBackupCreated = async (payload: { success: boolean; message: string }) => {
if (payload.success) { if (payload.success) {
addNotification({ type: "success", text: payload.message }); addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else { } else {
addNotification({ type: "error", text: payload.message }); addNotification({ type: "error", text: payload.message });
} }
}; };
const handleBackupRenamed = (payload: { success: boolean; message: string }) => { const handleBackupRenamed = async (payload: { success: boolean; message: string }) => {
if (payload.success) { if (payload.success) {
addNotification({ type: "success", text: payload.message }); addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else { } else {
addNotification({ type: "error", text: payload.message }); addNotification({ type: "error", text: payload.message });
} }
}; };
const handleBackupRestored = (payload: { success: boolean; message: string }) => { const handleBackupRestored = async (payload: { success: boolean; message: string }) => {
if (payload.success) { if (payload.success) {
addNotification({ type: "success", text: payload.message }); addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else { } else {
addNotification({ type: "error", text: payload.message }); addNotification({ type: "error", text: payload.message });
} }
}; };
const handleBackupDeleted = (payload: { success: boolean; message: string }) => { const handleBackupDeleted = async (payload: { success: boolean; message: string }) => {
if (payload.success) { if (payload.success) {
addNotification({ type: "success", text: payload.message }); addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else { } else {
addNotification({ type: "error", text: payload.message }); addNotification({ type: "error", text: payload.message });
} }
@ -387,8 +416,8 @@ onMounted(() => {
} }
if (hasOngoingBackups) { if (hasOngoingBackups) {
refreshInterval.value = setInterval(() => { refreshInterval.value = setInterval(async () => {
props.server.refresh(["backups"]); await props.server.refresh(["backups"]);
}, 10000); }, 10000);
} }
}); });

View File

@ -10,7 +10,30 @@
@change-version="changeModVersion($event)" @change-version="changeModVersion($event)"
/> />
<div v-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col"> <div
v-if="server.content?.error"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load content</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's {{ type.toLowerCase() }}s. Here's what we know:
<span class="break-all font-mono">{{ JSON.stringify(server.content.error) }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['content'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col">
<div ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel /> <div ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel />
<div class="relative flex h-full w-full flex-col"> <div class="relative flex h-full w-full flex-col">
<div class="sticky top-0 z-20 -mt-3 flex items-center justify-between bg-bg py-3"> <div class="sticky top-0 z-20 -mt-3 flex items-center justify-between bg-bg py-3">
@ -322,6 +345,7 @@ import {
WrenchIcon, WrenchIcon,
ListIcon, ListIcon,
FileIcon, FileIcon,
IssuesIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui"; import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, onMounted, onUnmounted } from "vue"; import { ref, computed, watch, onMounted, onUnmounted } from "vue";

View File

@ -189,6 +189,8 @@ const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>; server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>(); }>();
const modulesLoaded = inject<Promise<void>>("modulesLoaded");
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@ -245,6 +247,8 @@ useHead({
}); });
const fetchDirectoryContents = async (): Promise<DirectoryResponse> => { const fetchDirectoryContents = async (): Promise<DirectoryResponse> => {
await modulesLoaded;
const path = Array.isArray(currentPath.value) ? currentPath.value.join("") : currentPath.value; const path = Array.isArray(currentPath.value) ? currentPath.value.join("") : currentPath.value;
try { try {
const data = await props.server.fs?.listDirContents(path, currentPage.value, maxResults); const data = await props.server.fs?.listDirContents(path, currentPage.value, maxResults);
@ -719,7 +723,22 @@ const editFile = async (item: { name: string; type: string; path: string }) => {
} }
}; };
const initializeFileEdit = async () => {
if (!route.query.editing || !props.server.fs) return;
const filePath = route.query.editing as string;
await editFile({
name: filePath.split("/").pop() || "",
type: "file",
path: filePath,
});
};
onMounted(async () => { onMounted(async () => {
await modulesLoaded;
await initializeFileEdit();
await import("ace-builds"); await import("ace-builds");
await import("ace-builds/src-noconflict/mode-json"); await import("ace-builds/src-noconflict/mode-json");
await import("ace-builds/src-noconflict/mode-yaml"); await import("ace-builds/src-noconflict/mode-yaml");

View File

@ -169,7 +169,7 @@
</div> </div>
</div> </div>
</div> </div>
<div v-else-if="!isConnected && !isWsAuthIncorrect" /> <UiServersOverviewLoading v-else-if="!isConnected && !isWsAuthIncorrect" />
<div v-else-if="isWsAuthIncorrect" class="flex flex-col"> <div v-else-if="isWsAuthIncorrect" class="flex flex-col">
<h2>Could not connect to the server.</h2> <h2>Could not connect to the server.</h2>
<p> <p>
@ -244,9 +244,12 @@ interface ErrorData {
const inspectingError = ref<ErrorData | null>(null); const inspectingError = ref<ErrorData | null>(null);
const inspectError = async () => { const inspectError = async () => {
try {
const log = await props.server.fs?.downloadFile("logs/latest.log"); const log = await props.server.fs?.downloadFile("logs/latest.log");
if (!log) return;
// @ts-ignore // @ts-ignore
const analysis = (await $fetch(`https://api.mclo.gs/1/analyse`, { const response = await $fetch(`https://api.mclo.gs/1/analyse`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
@ -254,9 +257,18 @@ const inspectError = async () => {
body: new URLSearchParams({ body: new URLSearchParams({
content: log, content: log,
}), }),
})) as ErrorData; });
inspectingError.value = analysis; // @ts-ignore
if (response && response.analysis && Array.isArray(response.analysis.problems)) {
inspectingError.value = response as ErrorData;
} else {
inspectingError.value = null;
}
} catch (error) {
console.error("Failed to analyze logs:", error);
inspectingError.value = null;
}
}; };
const clearError = () => { const clearError = () => {
@ -266,7 +278,7 @@ const clearError = () => {
watch( watch(
() => props.serverPowerState, () => props.serverPowerState,
(newVal) => { (newVal) => {
if (newVal === "crashed") { if (newVal === "crashed" && !props.powerStateDetails?.oom_killed) {
inspectError(); inspectError();
} else { } else {
clearError(); clearError();
@ -274,7 +286,7 @@ watch(
}, },
); );
if (props.serverPowerState === "crashed") { if (props.serverPowerState === "crashed" && !props.powerStateDetails?.oom_killed) {
inspectError(); inspectError();
} }

View File

@ -5,7 +5,7 @@
<div class="card flex flex-col gap-4"> <div class="card flex flex-col gap-4">
<label for="server-name-field" class="flex flex-col gap-2"> <label for="server-name-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Server name</span> <span class="text-lg font-bold text-contrast">Server name</span>
<span> Change your server's name. This name is only visible on Modrinth.</span> <span> This name is only visible on Modrinth.</span>
</label> </label>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<input <input
@ -64,10 +64,7 @@
<div class="card flex flex-col gap-4"> <div class="card flex flex-col gap-4">
<label for="server-icon-field" class="flex flex-col gap-2"> <label for="server-icon-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Server icon</span> <span class="text-lg font-bold text-contrast">Server icon</span>
<span> <span> This icon will be visible on the Minecraft server list and on Modrinth. </span>
Change your server's icon. Changes will be visible on the Minecraft server list and on
Modrinth.
</span>
</label> </label>
<div class="flex gap-4"> <div class="flex gap-4">
<div <div
@ -91,20 +88,7 @@
> >
<EditIcon class="h-8 w-8 text-contrast" /> <EditIcon class="h-8 w-8 text-contrast" />
</div> </div>
<img <UiServersServerIcon :image="icon" />
v-if="icon"
no-shadow
alt="Server Icon"
class="h-[6rem] w-[6rem] rounded-xl"
:src="icon"
/>
<img
v-else
no-shadow
alt="Server Icon"
class="h-[6rem] w-[6rem] rounded-xl"
src="~/assets/images/servers/minecraft_server_icon.png"
/>
</div> </div>
<ButtonStyled> <ButtonStyled>
<button v-tooltip="'Synchronize icon with installed modpack'" @click="resetIcon"> <button v-tooltip="'Synchronize icon with installed modpack'" @click="resetIcon">
@ -234,8 +218,6 @@ const resetGeneral = () => {
const uploadFile = async (e: Event) => { const uploadFile = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]; const file = (e.target as HTMLInputElement).files?.[0];
// down scale the image to 64x64
const scaledFile = await new Promise<File>((resolve, reject) => {
if (!file) { if (!file) {
addNotification({ addNotification({
group: "serverOptions", group: "serverOptions",
@ -243,37 +225,55 @@ const uploadFile = async (e: Event) => {
title: "No file selected", title: "No file selected",
text: "Please select a file to upload.", text: "Please select a file to upload.",
}); });
reject(new Error("No file selected"));
return; return;
} }
const scaledFile = await new Promise<File>((resolve, reject) => {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
const img = new Image(); const img = new Image();
img.src = URL.createObjectURL(file);
img.onload = () => { img.onload = () => {
canvas.width = 64; canvas.width = 64;
canvas.height = 64; canvas.height = 64;
ctx?.drawImage(img, 0, 0, 64, 64); ctx?.drawImage(img, 0, 0, 64, 64);
// turn the downscaled image back to a png file
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
if (blob) { if (blob) {
const data = new File([blob], "server-icon.png", { type: "image/png" }); resolve(new File([blob], "server-icon.png", { type: "image/png" }));
resolve(data);
} else { } else {
reject(new Error("Canvas toBlob failed")); reject(new Error("Canvas toBlob failed"));
} }
}, "image/png"); }, "image/png");
URL.revokeObjectURL(img.src);
}; };
img.onerror = reject; img.onerror = reject;
img.src = URL.createObjectURL(file);
}); });
if (!file) return;
try {
if (data.value?.image) { if (data.value?.image) {
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false); await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false); await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
} }
await props.server.fs?.uploadFile("/server-icon.png", scaledFile); await props.server.fs?.uploadFile("/server-icon.png", scaledFile);
await props.server.fs?.uploadFile("/server-icon-original.png", file); await props.server.fs?.uploadFile("/server-icon-original.png", file);
await props.server.refresh();
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
await new Promise<void>((resolve) => {
img.onload = () => {
canvas.width = 512;
canvas.height = 512;
ctx?.drawImage(img, 0, 0, 512, 512);
const dataURL = canvas.toDataURL("image/png");
useState(`server-icon-${props.server.serverId}`).value = dataURL;
if (data.value) data.value.image = dataURL;
resolve();
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
addNotification({ addNotification({
group: "serverOptions", group: "serverOptions",
@ -281,20 +281,43 @@ const uploadFile = async (e: Event) => {
title: "Server icon updated", title: "Server icon updated",
text: "Your server icon was successfully changed.", text: "Your server icon was successfully changed.",
}); });
} catch (error) {
console.error("Error uploading icon:", error);
addNotification({
group: "serverOptions",
type: "error",
title: "Upload failed",
text: "Failed to upload server icon.",
});
}
}; };
const resetIcon = async () => { const resetIcon = async () => {
if (data.value?.image) { if (data.value?.image) {
try {
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false); await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false); await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
await new Promise((resolve) => setTimeout(resolve, 2000));
await reloadNuxtApp(); useState(`server-icon-${props.server.serverId}`).value = undefined;
if (data.value) data.value.image = undefined;
await props.server.refresh(["general"]);
addNotification({ addNotification({
group: "serverOptions", group: "serverOptions",
type: "success", type: "success",
title: "Server icon reset", title: "Server icon reset",
text: "Your server icon was successfully reset.", text: "Your server icon was successfully reset.",
}); });
} catch (error) {
console.error("Error resetting icon:", error);
addNotification({
group: "serverOptions",
type: "error",
title: "Reset failed",
text: "Failed to reset server icon.",
});
}
} }
}; };

View File

@ -59,7 +59,29 @@
/> />
<div class="relative h-full w-full overflow-y-auto"> <div class="relative h-full w-full overflow-y-auto">
<div v-if="data" class="flex h-full w-full flex-col justify-between gap-4"> <div
v-if="server.network?.error"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load network settings</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's network settings. Here's what we know:
<span class="break-all font-mono">{{ JSON.stringify(server.network.error) }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['network'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="flex h-full w-full flex-col justify-between gap-4">
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
<!-- Subdomain section --> <!-- Subdomain section -->
<div class="card flex flex-col gap-4"> <div class="card flex flex-col gap-4">
@ -155,7 +177,7 @@
</span> </span>
</div> </div>
<ButtonStyled type="standard" color="brand" @click="showNewAllocationModal"> <ButtonStyled type="standard" @click="showNewAllocationModal">
<button class="!w-full sm:!w-auto"> <button class="!w-full sm:!w-auto">
<PlusIcon /> <PlusIcon />
<span>New allocation</span> <span>New allocation</span>
@ -247,6 +269,7 @@ import {
SaveIcon, SaveIcon,
InfoIcon, InfoIcon,
UploadIcon, UploadIcon,
IssuesIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { ButtonStyled, NewModal, ConfirmModal } from "@modrinth/ui"; import { ButtonStyled, NewModal, ConfirmModal } from "@modrinth/ui";
import { ref, computed, nextTick } from "vue"; import { ref, computed, nextTick } from "vue";
@ -286,12 +309,11 @@ const addNewAllocation = async () => {
try { try {
await props.server.network?.reserveAllocation(newAllocationName.value); await props.server.network?.reserveAllocation(newAllocationName.value);
await props.server.refresh(["network"]);
newAllocationModal.value?.hide(); newAllocationModal.value?.hide();
newAllocationName.value = ""; newAllocationName.value = "";
await props.server.refresh();
addNotification({ addNotification({
group: "serverOptions", group: "serverOptions",
type: "success", type: "success",
@ -332,8 +354,8 @@ const confirmDeleteAllocation = async () => {
if (allocationToDelete.value === null) return; if (allocationToDelete.value === null) return;
await props.server.network?.deleteAllocation(allocationToDelete.value); await props.server.network?.deleteAllocation(allocationToDelete.value);
await props.server.refresh(["network"]);
await props.server.refresh();
addNotification({ addNotification({
group: "serverOptions", group: "serverOptions",
type: "success", type: "success",
@ -349,12 +371,11 @@ const editAllocation = async () => {
try { try {
await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value); await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value);
await props.server.refresh(["network"]);
editAllocationModal.value?.hide(); editAllocationModal.value?.hide();
newAllocationName.value = ""; newAllocationName.value = "";
await props.server.refresh();
addNotification({ addNotification({
group: "serverOptions", group: "serverOptions",
type: "success", type: "success",

View File

@ -1,7 +1,27 @@
<template> <template>
<div class="relative h-full w-full select-none overflow-y-auto"> <div class="relative h-full w-full select-none overflow-y-auto">
<div v-if="server.fs?.error" class="flex w-full flex-col items-center justify-center gap-4 p-4">
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load properties</h1>
</div>
<p class="text-lg text-secondary">
We couldn't access your server's properties. Here's what we know:
<span class="break-all font-mono">{{ JSON.stringify(server.fs.error) }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['fs'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div <div
v-if="propsData && status === 'success'" v-else-if="propsData && status === 'success'"
class="flex h-full w-full flex-col justify-between gap-6 overflow-y-auto" class="flex h-full w-full flex-col justify-between gap-6 overflow-y-auto"
> >
<div class="card flex flex-col gap-4"> <div class="card flex flex-col gap-4">
@ -118,8 +138,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed } from "vue"; import { ref, watch, computed, inject } from "vue";
import { EyeIcon, SearchIcon } from "@modrinth/assets"; import { EyeIcon, SearchIcon, IssuesIcon } from "@modrinth/assets";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import type { Server } from "~/composables/pyroServers"; import type { Server } from "~/composables/pyroServers";
@ -134,7 +154,9 @@ const isUpdating = ref(false);
const searchInput = ref(""); const searchInput = ref("");
const data = computed(() => props.server.general); const data = computed(() => props.server.general);
const modulesLoaded = inject<Promise<void>>("modulesLoaded");
const { data: propsData, status } = await useAsyncData("ServerProperties", async () => { const { data: propsData, status } = await useAsyncData("ServerProperties", async () => {
await modulesLoaded;
const rawProps = await props.server.fs?.downloadFile("server.properties"); const rawProps = await props.server.fs?.downloadFile("server.properties");
if (!rawProps) return null; if (!rawProps) return null;

View File

@ -1,7 +1,33 @@
<template> <template>
<div class="relative h-full w-full"> <div class="relative h-full w-full">
<div v-if="data" class="flex h-full w-full flex-col gap-4"> <div
<div class="rounded-2xl border-solid border-orange bg-bg-orange p-4 text-contrast"> v-if="server.startup?.error"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load startup settings</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's startup settings. Here's what we know:
</p>
<p>
<span class="break-all font-mono">{{ JSON.stringify(server.startup.error) }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['startup'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="flex h-full w-full flex-col gap-4">
<div
class="rounded-2xl border-[1px] border-solid border-orange bg-bg-orange p-4 text-contrast"
>
These settings are for advanced users. Changing them can break your server. These settings are for advanced users. Changing them can break your server.
</div> </div>
@ -84,7 +110,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { UpdatedIcon } from "@modrinth/assets"; import { UpdatedIcon, IssuesIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui"; import { ButtonStyled } from "@modrinth/ui";
import type { Server } from "~/composables/pyroServers"; import type { Server } from "~/composables/pyroServers";
@ -109,13 +135,41 @@ const jdkBuildMap = [
{ value: "graal", label: "GraalVM" }, { value: "graal", label: "GraalVM" },
]; ];
const invocation = ref(startupSettings.value?.invocation); const invocation = ref("");
const jdkVersion = ref( const jdkVersion = ref("");
jdkVersionMap.find((v) => v.value === startupSettings.value?.jdk_version)?.label || "", const jdkBuild = ref("");
const originalInvocation = ref("");
const originalJdkVersion = ref("");
const originalJdkBuild = ref("");
watch(
startupSettings,
(newSettings) => {
if (newSettings) {
invocation.value = newSettings.invocation;
originalInvocation.value = newSettings.invocation;
const jdkVersionLabel =
jdkVersionMap.find((v) => v.value === newSettings.jdk_version)?.label || "";
jdkVersion.value = jdkVersionLabel;
originalJdkVersion.value = jdkVersionLabel;
const jdkBuildLabel = jdkBuildMap.find((v) => v.value === newSettings.jdk_build)?.label || "";
jdkBuild.value = jdkBuildLabel;
originalJdkBuild.value = jdkBuildLabel;
}
},
{ immediate: true },
); );
const jdkBuild = ref(
jdkBuildMap.find((v) => v.value === startupSettings.value?.jdk_build)?.label || "", const hasUnsavedChanges = computed(
() =>
invocation.value !== originalInvocation.value ||
jdkVersion.value !== originalJdkVersion.value ||
jdkBuild.value !== originalJdkBuild.value,
); );
const isUpdating = ref(false); const isUpdating = ref(false);
const compatibleJavaVersions = computed(() => { const compatibleJavaVersions = computed(() => {
@ -139,15 +193,6 @@ const displayedJavaVersions = computed(() => {
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value; return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value;
}); });
const hasUnsavedChanges = computed(
() =>
invocation.value !== startupSettings.value?.invocation ||
jdkVersion.value !==
(jdkVersionMap.find((v) => v.value === startupSettings.value?.jdk_version)?.label || "") ||
jdkBuild.value !==
jdkBuildMap.find((v) => v.value === startupSettings.value?.jdk_build || "")?.label,
);
const saveStartup = async () => { const saveStartup = async () => {
try { try {
isUpdating.value = true; isUpdating.value = true;
@ -155,14 +200,25 @@ const saveStartup = async () => {
const jdkVersionKey = jdkVersionMap.find((v) => v.label === jdkVersion.value)?.value; const jdkVersionKey = jdkVersionMap.find((v) => v.label === jdkVersion.value)?.value;
const jdkBuildKey = jdkBuildMap.find((v) => v.label === jdkBuild.value)?.value; const jdkBuildKey = jdkBuildMap.find((v) => v.label === jdkBuild.value)?.value;
await props.server.startup?.update(invocationValue, jdkVersionKey as any, jdkBuildKey as any); await props.server.startup?.update(invocationValue, jdkVersionKey as any, jdkBuildKey as any);
await new Promise((resolve) => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, 10));
await props.server.refresh(["startup"]);
if (props.server.startup) {
invocation.value = props.server.startup.invocation;
jdkVersion.value =
jdkVersionMap.find((v) => v.value === props.server.startup?.jdk_version)?.label || "";
jdkBuild.value =
jdkBuildMap.find((v) => v.value === props.server.startup?.jdk_build)?.label || "";
}
addNotification({ addNotification({
group: "serverOptions", group: "serverOptions",
type: "success", type: "success",
title: "Server settings updated", title: "Server settings updated",
text: "Your server settings were successfully changed.", text: "Your server settings were successfully changed.",
}); });
await props.server.refresh();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
addNotification({ addNotification({
@ -177,15 +233,13 @@ const saveStartup = async () => {
}; };
const resetStartup = () => { const resetStartup = () => {
invocation.value = startupSettings.value?.invocation; invocation.value = originalInvocation.value;
jdkVersion.value = jdkVersion.value = originalJdkVersion.value;
jdkVersionMap.find((v) => v.value === startupSettings.value?.jdk_version)?.label || ""; jdkBuild.value = originalJdkBuild.value;
jdkBuild.value =
jdkBuildMap.find((v) => v.value === startupSettings.value?.jdk_build)?.label || "";
}; };
const resetToDefault = () => { const resetToDefault = () => {
invocation.value = startupSettings.value?.original_invocation; invocation.value = startupSettings.value?.original_invocation ?? "";
}; };
</script> </script>