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:
parent
6c4548a303
commit
a88593fec5
@ -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>
|
||||||
|
|||||||
80
apps/frontend/src/components/ui/servers/OverviewLoading.vue
Normal file
80
apps/frontend/src/components/ui/servers/OverviewLoading.vue
Normal 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>
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,45 +149,49 @@ 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);
|
||||||
|
|
||||||
interface License {
|
interface License {
|
||||||
@ -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>;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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"]);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user