Modrinth Servers Mega Features & Bug Fix-a-thon (#3222)

* fix(content): changing mod versions works again

* chore(assets): update pyro logo

* fix(properties): deprecate fetchconfigfile

* Revert "fix(content): changing mod versions works again"

This reverts commit d7c0d1196f8c1850fd7ccbc1644941c6db4dc306.

* feat(files): ability to sort via column click

* chore(startup): update clunky wording

* feat(serverListing): server icons SSR friendly

* fix(servers): if archon fails, display err in listing

* chore(serverlisting): use pyroserver hook to init icon

* chore(servers): much more graceful reinstall

* fix(servers): tw warn

* fix(platform): correctly react when pack reinstalled

* fix(serversroot): explicitly import navigateTo

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

* chore(serverlabels): show skeleton instead of hiding

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

* feat(platform): install-aware controls

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

* refactor!(platform): rewrite platform page

* fix(platform): regression in autoselecting loader

* chore(platform): prefer version over project modification date

* fix(platform): permanent hang after initial mount

* chore(platform): do not silently fail and hang if modpack fails loading

* oops: remove hardcoded error causer

* fix(platform): switch modpack btn while installing doesnt need class

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

* chore(platform): adjust styling in version modal

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

* chore(platform): prevent changing project card style

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

* refactor(pyrodropdown): rewrite

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

* fix(pyrodropdown): do nopt use deprecated substr

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

* chore: clean

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

* fix(network): sentence case

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

* refactor(terminal): initial batch

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

* fix(terminal): fulllog over fullscreen

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

* fix(terminal): fullscreen conflict with body scroll

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

* feat(terminal): init drag select

* feat(terminal): shift click support

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

* chore(terminal): double lines limit

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

* feat(terminal): copy button

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

* chore(terminal): protip style

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

* chore(terminal): improve styles

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

* feat(terminal): regex search

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

* chore(terminal): move icons to icons dir

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

* chore(terminal): improve drag select autoscroll inertia

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

* fix(terminal): cancel selection on right click

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

* fix(terminal): progblur and stb btn disappearing

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

* refactor(serverstats): power efficiency

* fix(subdomainlabel): correct tooltip terminology

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

* feat(preferences): users hide subdomain label

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

* chore(servers): clean

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

* chore(terminal): deselect lines on escape

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

* fix(serversidebar): type err

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

* fix(fileitem): vue server render type

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

* fix(terminal): disable pointer events on lines if scrolling

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

* fix(terminal): search result counts style

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

* fix(terminal): plural

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

* chore(terminal): clean

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

* feat(terminal): view selection

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

* feat(terminal): show actively selected lines in scrollbar

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

* fix(terminallog): btn color

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

* chore: clean

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

* fix(gamelabel): align to text

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

* fix(gamelabel): align to text

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

* fix(listing): remove deadcode

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

* fix(serverlisting): deprecated process.server

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

* fix(platform): correctly disable button

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

* fix(backups): do not allow backup creation during server installation

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

* fix(platform): flush stale currentversion data on successful install

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

* fix(gamelabel): fix gap

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

* chore(network): vaporize uppercase

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

* chore(info): vaporize uppercase

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

* chore(backups): style unification

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

* chore(backups): finalize style change

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

* fix(servers): catch pyro servers fetch errors during ssr

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

* fix(serverstats): ram as bytes graph now works

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

* fix(platform): unify attempts and refresh interval

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

* fix(terminal): input

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

* feat(servers): installing ticket + update available notice back in platform

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

* chore(terminal): dont add bg to scroll track

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

* fix(terminal): preserve whitespace

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

* chore(serversroot): unnest blurred icon query

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

* fix(serverstats): clamp memory usage to 100% no matter what

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

* feat(terminal): allow copy of single lines, show btn

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

* chore(terminal): animate copy>view transition

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

* init: search improvements

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

* fix: lint

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

* chore: change log modal title

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

* fix: hide fullscreen when selecting and cancel selection on clickout

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

* refactor(terminal): more reliable jumpToLine

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

* feat: search results separator

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

* chore: remove buggy isScrollable check

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

* fix: style

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

* refactor: correctly store pos to make jump reliable

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

* fix: disparity between search/log dragselect

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

* fix: prevent propagation of click events when clicking on jump btn

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

* fix: switch selection strategies depending on terminal mode

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

* chore: smarter esc handling

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

* finalize

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

* run fix

* fix: ensure lines between cannot be selected

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

* fix: increase initial log batch to 256

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

* fix(terminal): click on scroll track should take user to new scroll position

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

* fix(terminal): update aria label for view selected logs btn

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

* chore: clean

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

---------

Signed-off-by: Evan Song <theevansong@gmail.com>
This commit is contained in:
Evan Song 2025-02-10 08:39:13 -07:00 committed by GitHub
parent 037cc86c1f
commit a75538c093
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 3276 additions and 2518 deletions

View File

@ -75,7 +75,7 @@ import {
RightArrowIcon,
} from "@modrinth/assets";
import { computed, shallowRef, ref } from "vue";
import { renderToString } from "@vue/server-renderer";
import { renderToString } from "vue/server-renderer";
import { useRouter, useRoute } from "vue-router";
import {
UiServersIconsCogFolderIcon,

View File

@ -2,7 +2,7 @@
<div ref="pyroFilesSentinel" class="sentinel" data-pyro-files-sentinel />
<header
:class="[
'duration-20 h-26 top-0 flex select-none flex-col justify-between gap-2 bg-table-alternateRow p-3 transition-[border-radius] sm:h-12 sm:flex-row',
'duration-20 top-0 flex select-none flex-col justify-between gap-2 bg-table-alternateRow p-3 transition-[border-radius] sm:h-12 sm:flex-row',
!isStuck ? 'rounded-t-2xl' : 'sticky top-0 z-20',
]"
data-pyro-files-state="browsing"
@ -76,25 +76,23 @@
<UiServersTeleportOverflowMenu
position="bottom"
direction="left"
aria-label="Sort files"
aria-label="Filter view"
:options="[
{ id: 'normal', action: () => $emit('sort', 'default') },
{ id: 'modified', action: () => $emit('sort', 'modified') },
{ id: 'created', action: () => $emit('sort', 'created') },
{ id: 'filesOnly', action: () => $emit('sort', 'filesOnly') },
{ id: 'foldersOnly', action: () => $emit('sort', 'foldersOnly') },
{ id: 'all', action: () => $emit('filter', 'all') },
{ id: 'filesOnly', action: () => $emit('filter', 'filesOnly') },
{ id: 'foldersOnly', action: () => $emit('filter', 'foldersOnly') },
]"
>
<span class="hidden whitespace-pre text-sm font-medium sm:block">
{{ sortMethodLabel }}
</span>
<SortAscendingIcon aria-hidden="true" />
<div class="flex items-center gap-1">
<FilterIcon aria-hidden="true" class="h-5 w-5" />
<span class="hidden text-sm font-medium sm:block">
{{ filterLabel }}
</span>
</div>
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #normal> Alphabetical </template>
<template #modified> Date modified </template>
<template #created> Date created </template>
<template #filesOnly> Files only </template>
<template #foldersOnly> Folders only </template>
<template #all>Show all</template>
<template #filesOnly>Files only</template>
<template #foldersOnly>Folders only</template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
<div class="mx-1 w-full text-sm sm:w-48">
@ -148,9 +146,9 @@ import {
DropdownIcon,
FolderOpenIcon,
SearchIcon,
SortAscendingIcon,
HomeIcon,
ChevronRightIcon,
FilterIcon,
} from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { ref, computed } from "vue";
@ -159,15 +157,15 @@ import { useIntersectionObserver } from "@vueuse/core";
const props = defineProps<{
breadcrumbSegments: string[];
searchQuery: string;
sortMethod: string;
currentFilter: string;
}>();
defineEmits<{
(e: "navigate", index: number): void;
(e: "sort", method: string): void;
(e: "create", type: "file" | "directory"): void;
(e: "upload"): void;
(e: "update:searchQuery", value: string): void;
(e: "filter", type: string): void;
}>();
const pyroFilesSentinel = ref<HTMLElement | null>(null);
@ -181,18 +179,14 @@ useIntersectionObserver(
{ threshold: [0, 1] },
);
const sortMethodLabel = computed(() => {
switch (props.sortMethod) {
case "modified":
return "Date modified";
case "created":
return "Date created";
const filterLabel = computed(() => {
switch (props.currentFilter) {
case "filesOnly":
return "Files only";
case "foldersOnly":
return "Folders only";
default:
return "Alphabetical";
return "Show all";
}
});
</script>

View File

@ -9,7 +9,7 @@
@mouseleave="stopPan"
@wheel.prevent="handleWheel"
>
<UiServersPyroLoading v-if="state.isLoading" />
<div v-if="state.isLoading" />
<div
v-if="state.hasError"
class="flex h-full w-full flex-col items-center justify-center gap-8"

View File

@ -1,14 +1,65 @@
<template>
<div
aria-hidden="true"
class="flex w-full select-none flex-row items-center border-0 border-b border-solid border-bg-raised px-3 py-2 text-xs font-bold uppercase"
class="sticky top-12 z-20 flex h-8 w-full select-none flex-row items-center border-0 border-b border-solid border-bg-raised bg-bg px-3 text-xs font-bold uppercase"
>
<div class="min-w-[48px]"></div>
<span class="flex w-full">Name</span>
<button
class="flex h-full w-full appearance-none items-center gap-1 bg-transparent text-left hover:text-brand"
@click="$emit('sort', 'name')"
>
<span>Name</span>
<ChevronUpIcon v-if="sortField === 'name' && !sortDesc" class="h-3 w-3" aria-hidden="true" />
<ChevronDownIcon v-if="sortField === 'name' && sortDesc" class="h-3 w-3" aria-hidden="true" />
</button>
<div class="flex shrink-0 gap-4 text-right md:gap-12">
<span class="hidden min-w-[160px] md:block">Created</span>
<span class="mr-4 min-w-[160px]">Modified</span>
<div class="min-w-[36px]"></div>
<button
class="hidden min-w-[160px] appearance-none items-center justify-start gap-1 bg-transparent hover:text-brand md:flex"
@click="$emit('sort', 'created')"
>
<span>Created</span>
<ChevronUpIcon
v-if="sortField === 'created' && !sortDesc"
class="h-3 w-3"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'created' && sortDesc"
class="h-3 w-3"
aria-hidden="true"
/>
</button>
<button
class="mr-4 hidden min-w-[160px] appearance-none items-center justify-start gap-1 bg-transparent hover:text-brand md:flex"
@click="$emit('sort', 'modified')"
>
<span>Modified</span>
<ChevronUpIcon
v-if="sortField === 'modified' && !sortDesc"
class="h-3 w-3"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'modified' && sortDesc"
class="h-3 w-3"
aria-hidden="true"
/>
</button>
<div class="min-w-[24px]"></div>
</div>
</div>
</template>
<script setup lang="ts">
import ChevronDownIcon from "./icons/ChevronDownIcon.vue";
import ChevronUpIcon from "./icons/ChevronUpIcon.vue";
defineProps<{
sortField: string;
sortDesc: boolean;
}>();
defineEmits<{
(e: "sort", field: string): void;
}>();
</script>

View File

@ -0,0 +1,76 @@
<template>
<div class="ticker-container">
<div class="ticker-content">
<div
v-for="(message, index) in msgs"
:key="message"
class="ticker-item text-xs"
:class="{ active: index === currentIndex % msgs.length }"
>
{{ message }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
const msgs = [
"Organizing files...",
"Downloading mods...",
"Configuring server...",
"Setting up environment...",
"Adding Java...",
];
const currentIndex = ref(0);
let intervalId: NodeJS.Timeout | null = null;
onMounted(() => {
intervalId = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % msgs.length;
}, 3000);
});
onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId);
}
});
</script>
<style scoped>
.ticker-container {
height: 20px;
width: 100%;
position: relative;
}
.ticker-content {
position: relative;
width: 100%;
}
.ticker-item {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 20px;
display: flex;
align-items: center;
color: var(--color-secondary-text);
opacity: 0;
transform: scale(0.9);
filter: blur(4px);
transition: all 0.3s ease-in-out;
}
.ticker-item.active {
opacity: 1;
transform: scale(1);
filter: blur(0);
}
</style>

View File

@ -10,6 +10,7 @@
:is-current="isCurrentLoader(loader.name)"
:loader-version="data.loader_version"
:current-loader="data.loader"
:is-installing="isInstalling"
@select="selectLoader"
/>
</div>
@ -28,6 +29,7 @@
:is-current="isCurrentLoader(loader.name)"
:loader-version="data.loader_version"
:current-loader="data.loader"
:is-installing="isInstalling"
@select="selectLoader"
/>
</div>
@ -47,6 +49,7 @@
:is-current="isCurrentLoader(loader.name)"
:loader-version="data.loader_version"
:current-loader="data.loader"
:is-installing="isInstalling"
@select="selectLoader"
/>
</div>
@ -60,6 +63,7 @@ const props = defineProps<{
loader: string | null;
loader_version: string | null;
};
isInstalling?: boolean;
}>();
const emit = defineEmits<{

View File

@ -31,7 +31,7 @@
</div>
<ButtonStyled>
<button @click="onSelect">
<button :disabled="isInstalling" @click="onSelect">
<DownloadIcon class="h-5 w-5" />
{{ isCurrentLoader ? "Reinstall" : "Install" }}
</button>
@ -52,6 +52,7 @@ interface Props {
loader: LoaderInfo;
currentLoader: string | null;
loaderVersion: string | null;
isInstalling?: boolean;
}
const props = defineProps<Props>();

View File

@ -0,0 +1,91 @@
<template>
<div
class="parsed-log relative flex h-8 w-full items-center overflow-hidden rounded-lg px-6"
@mouseenter="checkOverflow"
@touchstart="checkOverflow"
>
<div ref="logContent" class="log-content flex-1 truncate whitespace-pre">
<span v-html="sanitizedLog"></span>
</div>
<button
v-if="isOverflowing"
class="ml-2 flex h-6 items-center rounded-md bg-bg px-2 text-xs text-contrast opacity-50 transition-opacity hover:opacity-100"
type="button"
@click.stop="$emit('show-full-log', props.log)"
>
...
</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import Convert from "ansi-to-html";
import DOMPurify from "dompurify";
const props = defineProps<{
log: string;
}>();
defineEmits<{
"show-full-log": [log: string];
}>();
const logContent = ref<HTMLElement | null>(null);
const isOverflowing = ref(false);
const checkOverflow = () => {
if (logContent.value && !isOverflowing.value) {
isOverflowing.value = logContent.value.scrollWidth > logContent.value.clientWidth;
}
};
const convert = new Convert({
fg: "#FFF",
bg: "#000",
newline: false,
escapeXML: true,
stream: false,
});
const sanitizedLog = computed(() =>
DOMPurify.sanitize(convert.toHtml(props.log), {
ALLOWED_TAGS: ["span"],
ALLOWED_ATTR: ["style"],
USE_PROFILES: { html: true },
}),
);
const preventSelection = (e: MouseEvent) => {
e.preventDefault();
};
onMounted(() => {
logContent.value?.addEventListener("mousedown", preventSelection);
});
onUnmounted(() => {
logContent.value?.removeEventListener("mousedown", preventSelection);
});
</script>
<style scoped>
.parsed-log {
background: transparent;
transition: background-color 0.1s;
}
.parsed-log:hover {
background: rgba(128, 128, 128, 0.25);
transition: 0s;
}
.log-content > span {
user-select: none;
white-space: pre;
}
.log-content {
white-space: pre;
}
</style>

View File

@ -1,107 +0,0 @@
<template>
<div class="parsed-log group relative w-full overflow-hidden px-6 py-1">
<div
ref="logContent"
class="log-content selectable whitespace-pre-wrap selection:bg-black selection:text-white dark:selection:bg-white dark:selection:text-black"
v-html="sanitizedLog"
></div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import Convert from "ansi-to-html";
import DOMPurify from "dompurify";
const props = defineProps<{
log: string;
index: number;
}>();
const logContent = ref<HTMLElement | null>(null);
const colors = {
30: "#101010",
31: "#EFA6A2",
32: "#80C990",
33: "#A69460",
34: "#A3B8EF",
35: "#E6A3DC",
36: "#50CACD",
37: "#808080",
90: "#454545",
91: "#E0AF85",
92: "#5ACCAF",
93: "#C8C874",
94: "#CCACED",
95: "#F2A1C2",
96: "#74C3E4",
97: "#C0C0C0",
};
const convert = new Convert({
fg: "#FFF",
bg: "#000",
newline: false,
escapeXML: true,
stream: false,
colors,
});
const urlRegex = /https?:\/\/[^\s]+/g;
const usernameRegex = /&lt;([^&]+)&gt;/g;
const sanitizedLog = computed(() => {
let html = convert.toHtml(props.log);
html = html.replace(
urlRegex,
(url) =>
`<a style="color:var(--color-link);text-decoration:underline;" href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`,
);
html = html.replace(
usernameRegex,
(_, username) => `<span class="minecraft-username">&lt;${username}&gt;</span>`,
);
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["span", "a"],
ALLOWED_ATTR: ["style", "href", "target", "rel", "class"],
ADD_ATTR: ["target"],
RETURN_TRUSTED_TYPE: true,
USE_PROFILES: { html: true },
});
});
</script>
<style scoped>
.parsed-log:hover:not(.selected) {
border-radius: 0.5rem;
}
html.light-mode .parsed-log:hover:not(.selected) {
background-color: #ccc;
}
html.dark-mode .parsed-log:hover:not(.selected) {
background-color: #222;
}
html.oled-mode .parsed-log:hover:not(.selected) {
background-color: #222;
}
.minecraft-username {
font-weight: bold;
}
::v-deep(.log-content) {
user-select: none;
}
::v-deep(.log-content.selectable) {
user-select: text;
}
::v-deep(.log-content *) {
user-select: text;
}
</style>

View File

@ -1,31 +0,0 @@
<template>
<ButtonStyled type="standard">
<button aria-label="Copy server IP" @click="copyText">
<CopyIcon />
Copy IP
</button>
</ButtonStyled>
</template>
<script setup lang="ts">
import { CopyIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
const props = defineProps<{
ip: string;
port: number;
subdomain?: string | null;
}>();
const copyText = () => {
const text = props.subdomain ? `${props.subdomain}.modrinth.gg` : `${props.ip}:${props.port}`;
navigator.clipboard.writeText(text);
addNotification({
group: "server",
title: `Copied IP`,
text: `Your server's IP has been copied to your clipboard`,
type: "success",
});
};
</script>

View File

@ -1,77 +0,0 @@
<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 text-3xl font-extrabold text-contrast">0 Bytes</h2>
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 0 Bytes</h3>
</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="console relative h-full min-h-[488px] w-full overflow-hidden rounded-xl bg-bg text-sm"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CPUIcon, DBIcon, FolderOpenIcon } 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>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,164 @@
<template>
<NewModal
ref="modal"
:header="'Changing ' + props.project?.title + ' version'"
@hide="onHide"
@show="onShow"
>
<div class="flex flex-col gap-4 md:w-[600px]">
<div class="flex flex-col gap-2">
<p class="m-0">
Select the version of {{ props.project?.title || "the modpack" }} you want to install on
your server.
</p>
<p v-if="props.currentVersion" class="m-0 text-sm text-secondary">
Currently installed: {{ props.currentVersion.version_number }}
</p>
</div>
<div class="flex w-full flex-col gap-4">
<UiServersTeleportDropdownMenu
v-if="props.versions?.length"
v-model="selectedVersion"
:options="versionOptions"
placeholder="Select version..."
name="version"
class="w-full max-w-full"
/>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="modpack-hard-reset">
Erase all data
</label>
<input
id="modpack-hard-reset"
v-model="hardReset"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>
If enabled, existing mods, worlds, and configurations, will be deleted before installing
the new modpack version.
</div>
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="hardReset ? 'red' : 'brand'">
<button
:disabled="isLoading || !selectedVersion || props.serverStatus === 'installing'"
@click="handleReinstall"
>
<DownloadIcon class="size-4" />
{{ isLoading ? "Installing..." : hardReset ? "Erase and install" : "Install" }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="isLoading" @click="hide">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { DownloadIcon, XIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
project: any;
versions: any[];
currentVersion?: any;
currentVersionId?: string;
serverStatus?: string;
}>();
const emit = defineEmits<{
reinstall: [any?];
}>();
const modal = ref();
const hardReset = ref(false);
const isLoading = ref(false);
const selectedVersion = ref("");
const versionOptions = computed(() => props.versions?.map((v) => v.version_number) || []);
const handleReinstall = async () => {
if (!selectedVersion.value || !props.project?.id) return;
isLoading.value = true;
try {
const versionId = props.versions.find((v) => v.version_number === selectedVersion.value)?.id;
await props.server.general?.reinstall(
props.server.serverId,
false,
props.project.id,
versionId,
undefined,
hardReset.value,
);
emit("reinstall");
hide();
} catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 429) {
addNotification({
group: "server",
title: "Cannot reinstall server",
text: "You are being rate limited. Please try again later.",
type: "error",
});
} else {
addNotification({
group: "server",
title: "Reinstall Failed",
text: "An unexpected error occurred while reinstalling. Please try again later.",
type: "error",
});
}
} finally {
isLoading.value = false;
}
};
watch(
() => props.serverStatus,
(newStatus) => {
if (newStatus === "installing") {
hide();
}
},
);
const onShow = () => {
hardReset.value = false;
selectedVersion.value =
props.currentVersion?.version_number ?? props.versions?.[0]?.version_number ?? "";
};
const onHide = () => {
hardReset.value = false;
selectedVersion.value = "";
isLoading.value = false;
};
const show = () => modal.value?.show();
const hide = () => modal.value?.hide();
defineExpose({ show, hide });
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@ -0,0 +1,281 @@
<template>
<NewModal ref="mrpackModal" header="Uploading mrpack" @hide="onHide" @show="onShow">
<div class="flex flex-col gap-4 md:w-[600px]">
<p
v-if="isMrpackModalSecondPhase"
:style="{
lineHeight: isMrpackModalSecondPhase ? '1.5' : undefined,
marginBottom: isMrpackModalSecondPhase ? '-12px' : '0',
marginTop: isMrpackModalSecondPhase ? '-4px' : '-2px',
}"
>
This will reinstall your server and erase all data. You may want to back up your server
before proceeding. Are you sure you want to continue?
</p>
<div v-if="!isMrpackModalSecondPhase" class="flex flex-col gap-4">
<div class="mx-auto flex flex-row items-center gap-4">
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
>
<UploadIcon class="size-10" />
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-10"
>
<path d="M5 9v6" />
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
</svg>
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
>
<ServerIcon class="size-10" />
</div>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="text-sm font-bold text-contrast">Upload mrpack</div>
<input
type="file"
accept=".mrpack"
class=""
:disabled="isLoading"
@change="uploadMrpack"
/>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
Erase all data
</label>
<input
id="hard-reset"
v-model="hardReset"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>
Removes all data on your server, including your worlds, mods, and configuration files,
then reinstalls it with the selected version.
</div>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="backup-server-mrpack">
Backup server
</label>
<input
id="backup-server-mrpack"
v-model="backupServer"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>Creates a backup of your server before proceeding.</div>
</div>
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button :disabled="canInstall" @click="handleReinstall">
<RightArrowIcon />
{{
isBackingUp
? "Backing up..."
: isMrpackModalSecondPhase
? "Erase and install"
: loadingServerCheck
? "Loading..."
: isDangerous
? "Erase and install"
: "Install"
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button
:disabled="isLoading"
@click="
if (isMrpackModalSecondPhase) {
isMrpackModalSecondPhase = false;
} else {
hide();
}
"
>
<XIcon />
{{ isMrpackModalSecondPhase ? "Go back" : "Cancel" }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { UploadIcon, RightArrowIcon, XIcon, ServerIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const emit = defineEmits<{
reinstall: [any?];
}>();
const mrpackModal = ref();
const isMrpackModalSecondPhase = ref(false);
const hardReset = ref(false);
const backupServer = ref(false);
const isLoading = ref(false);
const isBackingUp = ref(false);
const loadingServerCheck = ref(false);
const mrpackFile = ref<File | null>(null);
const isDangerous = computed(() => hardReset.value);
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
const uploadMrpack = (event: Event) => {
const target = event.target as HTMLInputElement;
if (!target.files || target.files.length === 0) {
return;
}
mrpackFile.value = target.files[0];
};
const performBackup = async (): Promise<boolean> => {
try {
const date = new Date();
const format = date.toLocaleString(navigator.language || "en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZoneName: "short",
});
const backupName = `Reinstallation - ${format}`;
isLoading.value = true;
const backupId = await props.server.backups?.create(backupName);
isBackingUp.value = true;
let attempts = 0;
while (true) {
attempts++;
if (attempts > 100) {
addNotification({
group: "server",
title: "Backup Failed",
text: "An unexpected error occurred while backing up. Please try again later.",
});
return false;
}
await props.server.refresh(["backups"]);
const backups = await props.server.backups?.data;
const backup = backupId ? backups?.find((x) => x.id === backupId) : undefined;
if (backup && !backup.ongoing) {
isBackingUp.value = false;
break;
}
await new Promise((resolve) => setTimeout(resolve, 5000));
}
return true;
} catch {
addNotification({
group: "server",
title: "Backup Failed",
text: "An unexpected error occurred while backing up. Please try again later.",
});
return false;
}
};
const handleReinstall = async () => {
if (hardReset.value && !backupServer.value && !isMrpackModalSecondPhase.value) {
isMrpackModalSecondPhase.value = true;
return;
}
if (backupServer.value && !(await performBackup())) {
isLoading.value = false;
return;
}
isLoading.value = true;
try {
if (!mrpackFile.value) {
throw new Error("No mrpack file selected");
}
const mrpack = new File([mrpackFile.value], mrpackFile.value.name, {
type: mrpackFile.value.type,
});
await props.server.general?.reinstallFromMrpack(mrpack, hardReset.value);
emit("reinstall", {
loader: "mrpack",
lVersion: "",
mVersion: "",
});
await nextTick();
window.scrollTo(0, 0);
hide();
} catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 429) {
addNotification({
group: "server",
title: "Cannot reinstall server",
text: "You are being rate limited. Please try again later.",
type: "error",
});
} else {
addNotification({
group: "server",
title: "Reinstall Failed",
text: "An unexpected error occurred while reinstalling. Please try again later.",
type: "error",
});
}
} finally {
isLoading.value = false;
}
};
const onShow = () => {
hardReset.value = false;
backupServer.value = false;
isMrpackModalSecondPhase.value = false;
loadingServerCheck.value = false;
isLoading.value = false;
mrpackFile.value = null;
};
const onHide = () => {
onShow();
};
const show = () => mrpackModal.value?.show();
const hide = () => mrpackModal.value?.hide();
defineExpose({ show, hide });
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@ -0,0 +1,551 @@
<template>
<NewModal
ref="versionSelectModal"
:header="
isSecondPhase
? 'Confirming reinstallation'
: `${props.currentLoader === selectedLoader ? 'Reinstalling' : 'Installing'}
${selectedLoader.toLowerCase() === 'vanilla' ? 'Vanilla Minecraft' : selectedLoader}`
"
@hide="onHide"
@show="onShow"
>
<div class="flex flex-col gap-4 md:w-[600px]">
<p
v-if="isSecondPhase"
:style="{
lineHeight: isSecondPhase ? '1.5' : undefined,
marginBottom: isSecondPhase ? '-12px' : '0',
marginTop: isSecondPhase ? '-4px' : '-2px',
}"
>
{{
backupServer
? "A backup will be created before proceeding with the reinstallation, then all data will be erased from your server. Are you sure you want to continue?"
: "This will reinstall your server and erase all data. Are you sure you want to continue?"
}}
</p>
<div v-if="!isSecondPhase" class="flex flex-col gap-4">
<div class="mx-auto flex flex-row items-center gap-4">
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
>
<UiServersIconsLoaderIcon class="size-10" :loader="selectedLoader" />
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-10"
>
<path d="M5 9v6" />
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
</svg>
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
>
<ServerIcon class="size-10" />
</div>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="text-sm font-bold text-contrast">Minecraft version</div>
<UiServersTeleportDropdownMenu
v-model="selectedMCVersion"
name="mcVersion"
:options="mcVersions"
class="w-full max-w-[100%]"
placeholder="Select Minecraft version..."
/>
</div>
<div
v-if="selectedLoader.toLowerCase() !== 'vanilla'"
class="flex w-full flex-col gap-2 rounded-2xl p-4"
:class="{
'bg-table-alternateRow':
!selectedMCVersion || isLoading || selectedLoaderVersions.length > 0,
'bg-highlight-red':
selectedMCVersion && !isLoading && selectedLoaderVersions.length === 0,
}"
>
<div class="flex flex-col gap-2">
<div class="text-sm font-bold text-contrast">{{ selectedLoader }} version</div>
<template v-if="!selectedMCVersion">
<div
class="relative flex h-9 w-full select-none items-center rounded-xl bg-button-bg px-4 opacity-50"
>
Select a Minecraft version to see available versions
<DropdownIcon class="absolute right-4" />
</div>
</template>
<template v-else-if="isLoading">
<div
class="relative flex h-9 w-full items-center rounded-xl bg-button-bg px-4 opacity-50"
>
<UiServersIconsLoadingIcon class="mr-2 animate-spin" />
Loading versions...
<DropdownIcon class="absolute right-4" />
</div>
</template>
<template v-else-if="selectedLoaderVersions.length > 0">
<UiServersTeleportDropdownMenu
v-model="selectedLoaderVersion"
name="loaderVersion"
:options="selectedLoaderVersions"
class="w-full max-w-[100%]"
:placeholder="
selectedLoader.toLowerCase() === 'paper' ||
selectedLoader.toLowerCase() === 'purpur'
? `Select build number...`
: `Select loader version...`
"
/>
</template>
<template v-else>
<div>No versions available for Minecraft {{ selectedMCVersion }}.</div>
</template>
</div>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
Erase all data
</label>
<input
id="hard-reset"
v-model="hardReset"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>
Removes all data on your server, including your worlds, mods, and configuration files,
then reinstalls it with the selected version.
</div>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="backup-server">
Backup server
</label>
<input
id="backup-server"
v-model="backupServer"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
</div>
<div>
Creates a backup of your server before proceeding with the installation or
reinstallation.
</div>
</div>
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button :disabled="canInstall" @click="handleReinstall">
<RightArrowIcon />
{{
isBackingUp
? "Backing up..."
: isLoading
? "Installing..."
: isSecondPhase
? "Erase and install"
: hardReset
? "Continue"
: "Install"
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button
:disabled="isLoading"
@click="
if (isSecondPhase) {
isSecondPhase = false;
} else {
hide();
}
"
>
<XIcon />
{{ isSecondPhase ? "Go back" : "Cancel" }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { RightArrowIcon, XIcon, ServerIcon, DropdownIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
import type { Loaders } from "~/types/servers";
interface LoaderVersion {
id: string;
stable: boolean;
loaders: {
id: string;
url: string;
stable: boolean;
}[];
}
type VersionMap = Record<string, LoaderVersion[]>;
type VersionCache = Record<string, any>;
const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
currentLoader: Loaders | undefined;
}>();
const emit = defineEmits<{
reinstall: [any?];
}>();
const versionSelectModal = ref();
const isSecondPhase = ref(false);
const hardReset = ref(false);
const backupServer = ref(false);
const isLoading = ref(false);
const isBackingUp = ref(false);
const loadingServerCheck = ref(false);
const serverCheckError = ref("");
const selectedLoader = ref<Loaders>("Vanilla");
const selectedMCVersion = ref("");
const selectedLoaderVersion = ref("");
const paperVersions = ref<Record<string, number[]>>({});
const purpurVersions = ref<Record<string, string[]>>({});
const loaderVersions = ref<VersionMap>({});
const cachedVersions = ref<VersionCache>({});
const versionStrings = ["forge", "fabric", "quilt", "neo"] as const;
const fetchLoaderVersions = async () => {
const versions = await Promise.all(
versionStrings.map(async (loader) => {
const runFetch = async (iterations: number) => {
if (iterations > 5) {
throw new Error("Failed to fetch loader versions");
}
try {
const res = await $fetch(`/loader-versions?loader=${loader}`);
return { [loader]: (res as any).gameVersions };
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_) {
return await runFetch(iterations + 1);
}
};
try {
return await runFetch(0);
} catch (e) {
console.error(e);
return { [loader]: [] };
}
}),
);
loaderVersions.value = versions.reduce((acc, val) => ({ ...acc, ...val }), {});
};
const fetchPaperVersions = async (mcVersion: string) => {
try {
const res = await $fetch(`https://api.papermc.io/v2/projects/paper/versions/${mcVersion}`);
paperVersions.value[mcVersion] = (res as any).builds.sort((a: number, b: number) => b - a);
return res;
} catch (e) {
console.error(e);
return null;
}
};
const fetchPurpurVersions = async (mcVersion: string) => {
try {
const res = await $fetch(`https://api.purpurmc.org/v2/purpur/${mcVersion}`);
purpurVersions.value[mcVersion] = (res as any).builds.all.sort(
(a: string, b: string) => parseInt(b) - parseInt(a),
);
return res;
} catch (e) {
console.error(e);
return null;
}
};
const selectedLoaderVersions = computed(() => {
const loader = selectedLoader.value.toLowerCase();
if (loader === "paper") {
return paperVersions.value[selectedMCVersion.value] || [];
}
if (loader === "purpur") {
return purpurVersions.value[selectedMCVersion.value] || [];
}
if (loader === "vanilla") {
return [];
}
let apiLoader = loader;
if (loader === "neoforge") {
apiLoader = "neo";
}
const backwardsCompatibleVersion = loaderVersions.value[apiLoader]?.find(
// eslint-disable-next-line no-template-curly-in-string
(x) => x.id === "${modrinth.gameVersion}",
);
if (backwardsCompatibleVersion) {
return backwardsCompatibleVersion.loaders.map((x) => x.id);
}
return (
loaderVersions.value[apiLoader]
?.find((x) => x.id === selectedMCVersion.value)
?.loaders.map((x) => x.id) || []
);
});
watch(selectedLoader, async () => {
if (selectedMCVersion.value) {
selectedLoaderVersion.value = "";
serverCheckError.value = "";
await checkVersionAvailability(selectedMCVersion.value);
}
});
watch(
selectedLoaderVersions,
(newVersions) => {
if (newVersions.length > 0 && !selectedLoaderVersion.value) {
selectedLoaderVersion.value = String(newVersions[0]); // Ensure string type
}
},
{ immediate: true },
);
const checkVersionAvailability = async (version: string) => {
if (!version || version.trim().length < 3) return;
isLoading.value = true;
loadingServerCheck.value = true;
try {
const mcRes =
cachedVersions.value[version] ||
(await $fetch(`/loader-versions?loader=minecraft&version=${version}`));
cachedVersions.value[version] = mcRes;
if (!mcRes.downloads?.server) {
serverCheckError.value = "We couldn't find a server.jar for this version.";
return;
}
const loader = selectedLoader.value.toLowerCase();
if (loader === "paper" || loader === "purpur") {
const fetchFn = loader === "paper" ? fetchPaperVersions : fetchPurpurVersions;
const result = await fetchFn(version);
if (!result) {
serverCheckError.value = `This Minecraft version is not supported by ${loader}.`;
return;
}
}
serverCheckError.value = "";
} catch (error) {
console.error(error);
serverCheckError.value = "Failed to fetch versions.";
} finally {
loadingServerCheck.value = false;
isLoading.value = false;
}
};
watch(selectedMCVersion, checkVersionAvailability);
onMounted(() => {
fetchLoaderVersions();
});
const tags = useTags();
const mcVersions = tags.value.gameVersions
.filter((x) => x.version_type === "release")
.map((x) => x.version)
.filter((x) => {
const segment = parseInt(x.split(".")[1], 10);
return !isNaN(segment) && segment > 2;
});
const isDangerous = computed(() => hardReset.value);
const canInstall = computed(() => {
const conds =
!selectedMCVersion.value ||
isLoading.value ||
loadingServerCheck.value ||
serverCheckError.value.trim().length > 0;
if (selectedLoader.value.toLowerCase() === "vanilla") {
return conds;
}
return conds || !selectedLoaderVersion.value;
});
const performBackup = async (): Promise<boolean> => {
try {
const date = new Date();
const format = date.toLocaleString(navigator.language || "en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZoneName: "short",
});
const backupName = `Reinstallation - ${format}`;
isLoading.value = true;
const backupId = await props.server.backups?.create(backupName);
isBackingUp.value = true;
let attempts = 0;
while (true) {
attempts++;
if (attempts > 100) {
addNotification({
group: "server",
title: "Backup Failed",
text: "An unexpected error occurred while backing up. Please try again later.",
});
return false;
}
await props.server.refresh(["backups"]);
const backups = await props.server.backups?.data;
const backup = backupId ? backups?.find((x) => x.id === backupId) : undefined;
if (backup && !backup.ongoing) {
isBackingUp.value = false;
break;
}
await new Promise((resolve) => setTimeout(resolve, 5000));
}
return true;
} catch {
addNotification({
group: "server",
title: "Backup Failed",
text: "An unexpected error occurred while backing up. Please try again later.",
});
return false;
}
};
const handleReinstall = async () => {
if (hardReset.value && !isSecondPhase.value) {
isSecondPhase.value = true;
return;
}
if (backupServer.value) {
isBackingUp.value = true;
if (!(await performBackup())) {
isBackingUp.value = false;
isLoading.value = false;
return;
}
isBackingUp.value = false;
}
isLoading.value = true;
try {
await props.server.general?.reinstall(
props.server.serverId,
true,
selectedLoader.value,
selectedMCVersion.value,
selectedLoader.value === "Vanilla" ? "" : selectedLoaderVersion.value,
hardReset.value,
);
emit("reinstall", {
loader: selectedLoader.value,
lVersion: selectedLoaderVersion.value,
mVersion: selectedMCVersion.value,
});
hide();
} catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 429) {
addNotification({
group: "server",
title: "Cannot reinstall server",
text: "You are being rate limited. Please try again later.",
type: "error",
});
} else {
addNotification({
group: "server",
title: "Reinstall Failed",
text: "An unexpected error occurred while reinstalling. Please try again later.",
type: "error",
});
}
} finally {
isLoading.value = false;
}
};
const onShow = () => {
selectedMCVersion.value = props.server.general?.mc_version || "";
selectedLoaderVersion.value = "";
hardReset.value = false;
};
const onHide = () => {
hardReset.value = false;
backupServer.value = false;
isSecondPhase.value = false;
serverCheckError.value = "";
loadingServerCheck.value = false;
isLoading.value = false;
selectedMCVersion.value = "";
selectedLoaderVersion.value = "";
serverCheckError.value = "";
paperVersions.value = {};
purpurVersions.value = {};
};
const show = (loader: Loaders) => {
selectedLoader.value = loader;
selectedMCVersion.value = props.server.general?.mc_version || "";
versionSelectModal.value?.show();
};
const hide = () => versionSelectModal.value?.hide();
defineExpose({ show, hide });
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@ -1,167 +0,0 @@
<template>
<div class="flex h-[400px] w-full max-w-xl flex-col overflow-hidden">
<div class="iconified-input mb-4 w-full">
<label class="hidden" for="search">Search</label>
<SearchIcon aria-hidden="true" />
<input
id="search"
v-model="queryFilter"
name="search"
type="search"
:placeholder="`Search ${props.type}s...`"
autocomplete="off"
@keyup.enter="resetList"
/>
</div>
<div class="flex h-full w-full flex-col">
<div
v-if="mods && mods.hits.length > 0"
ref="scrollContainer"
class="flex h-full w-full flex-col gap-2 overflow-y-scroll"
>
<div v-for="mod in mods.hits" :key="mod.title" class="rounded-lg px-2 py-2 hover:bg-bg">
<div class="flex cursor-pointer gap-2" @click="toggleMod(mod.project_id)">
<UiAvatar :src="mod.icon_url" class="!h-12 !min-h-12 !w-12 !min-w-12" />
<div class="flex flex-col gap-1">
<h1 class="m-0 text-2xl font-bold leading-none text-contrast">
{{ mod.title }}
</h1>
<span class="text-sm text-secondary">
{{ mod.description.substring(0, 100) }}
{{ mod.description.length > 100 ? "..." : "" }}
</span>
</div>
</div>
<div v-if="expandedMods[mod.project_id]" class="mt-2 flex items-center gap-2">
<DropdownSelect
id="version-select"
v-model="selectedVersions[mod.project_id]"
name="version-select"
:options="expandedMods[mod.project_id].versions"
placeholder="Select version..."
/>
<Button icon-only @click="emits('select', mod, selectedVersions[mod.project_id])">
<ChevronRightIcon />
</Button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ChevronRightIcon, SearchIcon } from "@modrinth/assets";
import { Button, DropdownSelect } from "@modrinth/ui";
import { useInfiniteScroll } from "@vueuse/core";
const emits = defineEmits(["select"]);
const props = defineProps<{
type: "mod" | "modpack" | "plugin" | "datapack";
isserver?: boolean;
}>();
const route = useNativeRoute();
const serverId = route.params.id as string;
const server = serverId ? await usePyroServer(serverId, ["general"]) : null;
const data = computed(() => (serverId ? server?.general : null));
const scrollContainer = ref<HTMLElement | null>(null);
const pages = ref(1);
const page = ref(0);
const queryFilter = ref("");
const facets = ref<any>([]);
if (props.isserver === false && props.type !== "modpack") {
facets.value.push(`["categories:${data.value?.loader?.toLocaleLowerCase()}"]`);
facets.value.push(`["versions:${data.value?.mc_version}"]`);
}
facets.value.push(`["project_type:${props.type}"]`);
const buildFacetString = (facets: string[]) => {
return "[" + facets.map((facet) => `${facet}`).join(",") + "]";
};
const mods = ref<any>({ hits: [] });
const modsStatus = ref("idle");
const loadMods = async () => {
modsStatus.value = "loading";
const newMods = (await useBaseFetch(
`search?query=${queryFilter.value}&facets=${buildFacetString(facets.value)}&index=relevance&limit=25&offset=${page.value * 25}`,
{},
false,
)) as any;
pages.value = newMods.total_hits;
mods.value.hits.push(...newMods.hits);
modsStatus.value = "success";
};
const versions = reactive<{ [key: string]: any[] }>({});
const getVersions = async (projectId: string) => {
if (!versions[projectId]) {
const allVersions = (await useBaseFetch(`project/${projectId}/version`, {}, false)) as any;
if (props.isserver === false && props.type !== "modpack") {
versions[projectId] = allVersions
.filter((x: any) => x.loaders.includes(data.value?.loader?.toLocaleLowerCase()))
.filter((x: any) => x.game_versions.includes(data.value?.mc_version))
.map((x: any) => x.version_number);
} else {
versions[projectId] = allVersions.map((x: any) => x.version_number);
}
}
return versions[projectId];
};
const selectedVersions = reactive<{ [key: string]: string }>({});
const expandedMods = reactive<{ [key: string]: { expanded: boolean; versions: any[] } }>({});
const toggleMod = async (modId: string) => {
if (!expandedMods[modId]) {
expandedMods[modId] = { expanded: false, versions: [] };
}
expandedMods[modId].expanded = !expandedMods[modId].expanded;
if (expandedMods[modId].expanded && expandedMods[modId].versions.length === 0) {
expandedMods[modId].versions = await getVersions(modId);
// Select the first version by default
if (expandedMods[modId].versions.length > 0) {
selectedVersions[modId] = expandedMods[modId].versions[0];
}
}
};
const loadMore = async () => {
page.value++;
await loadMods();
};
const { reset } = useInfiniteScroll(scrollContainer, async () => {
if (page.value <= pages.value) {
await loadMore();
console.log("loading more");
console.log(page.value);
console.log(pages.value);
}
});
const resetList = () => {
mods.value.hits = [];
Object.keys(expandedMods).forEach((key) => delete expandedMods[key]);
Object.keys(selectedVersions).forEach((key) => delete selectedVersions[key]);
page.value = 0;
loadMods();
reset();
};
onMounted(async () => {
await loadMods();
});
</script>

View File

@ -1,94 +0,0 @@
<template>
<div class="flex h-[70vh] w-full flex-col items-center justify-center">
<PyroIcon class="pyro-logo-animation size-32 opacity-10" />
<p
class="text-sm transition"
:class="{ 'opacity-0': !showLoading, 'animate-pulse opacity-100': showLoading }"
>
Loading...
</p>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { PyroIcon } from "@modrinth/assets";
const showLoading = ref(false);
onMounted(() => {
setTimeout(() => {
showLoading.value = true;
}, 5000);
});
</script>
<style>
.page-enter-active,
.page-leave-active {
transition: all 0.1s;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
}
@keyframes zoom-in {
0% {
transform: scale(0.5);
}
100% {
transform: scale(1);
}
}
.pyro-logo-animation {
animation: zoom-in 0.8s
linear(
0 0%,
0.01 0.8%,
0.04 1.6%,
0.161 3.3%,
0.816 9.4%,
1.046 11.9%,
1.189 14.4%,
1.231 15.7%,
1.254 17%,
1.259 17.8%,
1.257 18.6%,
1.236 20.45%,
1.194 22.3%,
1.057 27%,
0.999 29.4%,
0.955 32.1%,
0.942 33.5%,
0.935 34.9%,
0.933 36.65%,
0.939 38.4%,
1 47.3%,
1.011 49.95%,
1.017 52.6%,
1.016 56.4%,
1 65.2%,
0.996 70.2%,
1.001 87.2%,
1 100%
);
}
@keyframes fade-bg-in {
0% {
opacity: 0;
}
100% {
opacity: 0.6;
}
}
.bg-loading-animation {
animation: fade-bg-in 0.12s linear forwards;
}
</style>

View File

@ -1,60 +0,0 @@
<template>
<div
class="flex h-full flex-col gap-4 py-6"
:class="
'flex h-full flex-col gap-4 py-6' +
(danger
? ' rounded-2xl border-2 border-solid border-[#cb2245] bg-[#fff5f6] dark:border-[#FF496E] dark:bg-[#270B11]'
: '')
"
>
<div class="mb-2 flex items-center justify-between gap-4 px-6">
<div class="flex w-full items-center gap-4">
<UiServersServerIcon v-if="data" :image="data.image" class="h-12 w-12 rounded-lg" />
<div class="text-2xl font-extrabold text-contrast">{{ props.header }}</div>
</div>
<button
:class="
'h-8 w-8 rounded-full bg-button-bg p-2 text-contrast hover:bg-button-bgActive' +
(danger ? 'hover:bg-[#ffffff20] [&&]:bg-[#ffffff10]' : '')
"
@click="$emit('modal')"
>
<XIcon class="h-4 w-4" />
</button>
</div>
<div
class="border-0 border-b border-solid"
:class="danger ? 'border-[#cb2245] dark:border-[#612d38]' : 'border-divider'"
></div>
<div class="mt-2 h-full w-full overflow-auto px-6">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { XIcon } from "@modrinth/assets";
const emit = defineEmits(["modal"]);
const props = defineProps<{
header?: string;
data?: any;
danger?: boolean;
}>();
const onEscKeyRelease = (event: KeyboardEvent) => {
if (event.key === "Escape") {
emit("modal");
}
};
onMounted(() => {
document.body.addEventListener("keyup", onEscKeyRelease);
});
onBeforeUnmount(() => {
document.removeEventListener("keyup", onEscKeyRelease);
});
</script>

View File

@ -39,7 +39,7 @@ const props = defineProps<{
save: () => void;
reset: () => void;
isVisible: boolean;
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const saveAndRestart = async () => {

View File

@ -8,13 +8,19 @@
<NuxtLink
v-if="isLink"
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
class="min-w-0 truncate text-sm font-semibold"
class="flex min-w-0 items-center truncate text-sm font-semibold"
:class="serverId ? 'hover:underline' : ''"
>
{{ game[0].toUpperCase() + game.slice(1) }} {{ mcVersion }}
<div class="flex flex-row items-center gap-1">
{{ game[0].toUpperCase() + game.slice(1) }}
<span v-if="mcVersion">{{ mcVersion }}</span>
<span v-else class="inline-block h-3 w-12 animate-pulse rounded bg-button-border"></span>
</div>
</NuxtLink>
<div v-else class="min-w-0 truncate text-sm font-semibold">
{{ game[0].toUpperCase() + game.slice(1) }} {{ mcVersion }}
<div v-else class="flex min-w-0 flex-row items-center gap-1 truncate text-sm font-semibold">
{{ game[0].toUpperCase() + game.slice(1) }}
<span v-if="mcVersion">{{ mcVersion }}</span>
<span v-else class="inline-block h-3 w-16 animate-pulse rounded bg-button-border"></span>
</div>
</div>
</template>

View File

@ -2,19 +2,18 @@
<div>
<UiServersServerGameLabel
v-if="showGameLabel"
:game="serverData.game!"
:game="serverData.game"
:mc-version="serverData.mc_version ?? ''"
:is-link="linked"
/>
<UiServersServerLoaderLabel
v-if="showLoaderLabel"
:loader="serverData.loader!"
:loader="serverData.loader"
:loader-version="serverData.loader_version ?? ''"
:no-separator="column"
:is-link="linked"
/>
<UiServersServerSubdomainLabel
v-if="serverData.net.domain"
v-if="serverData.net?.domain"
:subdomain="serverData.net.domain"
:no-separator="column"
:is-link="linked"

View File

@ -47,7 +47,6 @@
:server-data="{ game, mc_version, loader, loader_version, net }"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
:show-subdomain-label="showSubdomainLabel"
:linked="false"
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
/>
@ -85,9 +84,12 @@ import type { Project, Server } from "~/types/servers";
const props = defineProps<Partial<Server>>();
if (props.server_id) {
await usePyroServer(props.server_id, ["general"]);
}
const showGameLabel = computed(() => !!props.game);
const showLoaderLabel = computed(() => !!props.loader);
const showSubdomainLabel = computed(() => !!props.net?.domain);
let projectData: Ref<Project | null>;
if (props.upstream) {
@ -103,39 +105,11 @@ if (props.upstream) {
projectData = ref(null);
}
const image = ref<string | undefined>();
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined);
onMounted(async () => {
const auth = (await usePyroFetch(`servers/${props.server_id}/fs`)) as any;
try {
const fileData = await usePyroFetch(`/download?path=/server-icon-original.png`, {
override: auth,
});
if (fileData instanceof Blob) {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.src = URL.createObjectURL(fileData);
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");
image.value = dataURL;
resolve();
};
});
}
} catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 404) {
image.value = undefined;
} else {
console.error(error);
}
}
});
if (import.meta.server && projectData.value?.icon_url) {
await usePyroServer(props.server_id!, ["general"]);
}
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
</script>

View File

@ -1,22 +1,33 @@
<template>
<div
v-if="loader"
v-tooltip="'Change server loader'"
class="flex min-w-0 flex-row items-center gap-4 truncate"
>
<div v-tooltip="'Change server loader'" class="flex min-w-0 flex-row items-center gap-4 truncate">
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
<div class="flex flex-row items-center gap-2">
<UiServersIconsLoaderIcon :loader="loader" class="flex shrink-0 [&&]:size-5" />
<UiServersIconsLoaderIcon v-if="loader" :loader="loader" class="flex shrink-0 [&&]:size-5" />
<div v-else class="size-5 shrink-0 animate-pulse rounded-full bg-button-border"></div>
<NuxtLink
v-if="isLink"
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
class="min-w-0 text-sm font-semibold"
class="flex min-w-0 items-center text-sm font-semibold"
:class="serverId ? 'hover:underline' : ''"
>
{{ loader }} <span v-if="loaderVersion">{{ loaderVersion }}</span>
<span v-if="loader">
{{ loader }}
<span v-if="loaderVersion">{{ loaderVersion }}</span>
</span>
<span v-else class="flex gap-2">
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
</span>
</NuxtLink>
<div v-else class="min-w-0 text-sm font-semibold">
{{ loader }} <span v-if="loaderVersion">{{ loaderVersion }}</span>
<span v-if="loader">
{{ loader }}
<span v-if="loaderVersion">{{ loaderVersion }}</span>
</span>
<span v-else class="flex gap-2">
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
</span>
</div>
</div>
</div>
@ -25,8 +36,8 @@
<script setup lang="ts">
defineProps<{
noSeparator?: boolean;
loader: "Fabric" | "Quilt" | "Forge" | "NeoForge" | "Paper" | "Spigot" | "Bukkit" | "Vanilla";
loaderVersion: string;
loader?: "Fabric" | "Quilt" | "Forge" | "NeoForge" | "Paper" | "Spigot" | "Bukkit" | "Vanilla";
loaderVersion?: string;
isLink?: boolean;
}>();

View File

@ -36,7 +36,7 @@ const emit = defineEmits(["reinstall"]);
const props = defineProps<{
navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
route: RouteLocationNormalized;
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const onReinstall = (...args: any[]) => {

View File

@ -1,18 +0,0 @@
<template>
<div class="flex flex-col gap-4">
<div
v-for="n in count"
:key="n"
class="relative h-[128px] w-full animate-pulse rounded-3xl bg-bg-raised p-4"
/>
</div>
</template>
<script setup lang="ts">
defineProps({
count: {
type: Number,
default: 3,
},
});
</script>

View File

@ -9,44 +9,34 @@
:key="index"
class="relative isolate 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"
:style="{
backdropFilter: 'blur(6px)',
}"
>
<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">
{{ metric.value }}
</h2>
<h3 class="relative z-10 text-sm font-normal text-secondary">/ {{ metric.max }}</h3>
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
<div class="relative z-10">
<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">{{ metric.value }}</h2>
<h3 class="text-sm font-normal text-secondary">/ {{ metric.max }}</h3>
</div>
<h3 class="flex items-center gap-2 text-base font-normal text-secondary">
{{ metric.title }}
<WarningIcon
v-if="metric.warning"
v-tooltip="metric.warning"
class="size-5"
:style="{ color: 'var(--color-orange)' }"
/>
</h3>
</div>
<h3 class="relative z-10 flex items-center gap-2 text-base font-normal text-secondary">
{{ metric.title }}
<WarningIcon
v-tooltip="getPotentialWarning(metric)"
:style="{
color: 'var(--color-orange)',
width: '1.25rem',
height: '1.25rem',
display: getPotentialWarning(metric) ? 'block' : 'none',
}"
/>
</h3>
<div class="absolute -left-8 -top-4 h-28 w-56 rounded-full bg-bg-raised blur-lg" />
</div>
<component :is="metric.icon" class="absolute right-10 top-10 z-10" />
<ClientOnly>
<VueApexCharts
v-if="
metric.data.length && !(metric.title === 'Memory usage' && userPreferences.ramAsNumber)
"
ref="chart"
v-if="metric.showGraph"
type="area"
height="142"
:options="generateOptions(metric)"
:series="[{ name: 'Chart', data: metric.data }]"
class="chart chart-animation absolute bottom-0 left-0 right-0 w-full"
:options="getChartOptions(metric.warning)"
:series="[{ name: metric.title, data: metric.data }]"
class="chart absolute bottom-0 left-0 right-0 w-full opacity-0"
/>
</ClientOnly>
</div>
@ -57,21 +47,17 @@
>
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">
{{ formatBytes(animatedStorageUsage) }}
{{ formatBytes(stats.storage_usage_bytes) }}
</h2>
<!-- <h3 class="relative z-10 text-sm font-normal text-secondary">
/ {{ formatBytes(props.data.current.storage_total_bytes) }}
</h3> -->
</div>
<h3 class="relative z-10 text-base font-normal text-secondary">Storage usage</h3>
<h3 class="text-base font-normal text-secondary">Storage usage</h3>
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
</NuxtLink>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { ref, computed, shallowRef } from "vue";
import { FolderOpenIcon, CPUIcon, DBIcon } from "@modrinth/assets";
import { useStorage } from "@vueuse/core";
import type { Stats } from "~/types/servers";
@ -79,252 +65,132 @@ import WarningIcon from "~/assets/images/utils/issues.svg?component";
const route = useNativeRoute();
const serverId = route.params.id;
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
ramAsNumber: false,
autoRestart: false,
backupWhileRunning: false,
});
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
const props = defineProps<{ data: Stats }>();
const props = defineProps({
data: {
type: Object as PropType<Stats>,
required: true,
},
});
const stats = shallowRef(props.data.current);
const lerp = (a: number, b: number) => {
return a + (b - a) * 0.5;
};
// I told you it would go into prod
const formatBytes = (bytes: number) => {
const units = ["Bytes", "KB", "MB", "GB", "TB"];
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 2) {
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unitIndex++;
unit++;
}
return `${Math.round(value * 100) / 100} ${units[unitIndex]}`;
return `${Math.round(value * 10) / 10} ${units[unit]}`;
};
const animatedStorageUsage = ref(0);
const cpuData = ref<number[]>(Array(20).fill(0));
const ramData = ref<number[]>(Array(20).fill(0));
const animateValue = (start: number, end: number, duration: number): void => {
let startTimestamp: number | null = null;
const step = (timestamp: number) => {
if (!startTimestamp) startTimestamp = timestamp;
const progress = Math.min((timestamp - startTimestamp) / duration, 1);
animatedStorageUsage.value = Math.floor(progress * (end - start) + start);
if (progress < 1) {
requestAnimationFrame(step);
}
};
requestAnimationFrame(step);
const updateGraphData = (arr: number[], newValue: number) => {
arr.push(newValue);
arr.shift();
};
onMounted(() => {
animateValue(0, props.data.current.storage_usage_bytes, 250);
const metrics = computed(() => {
const ramPercent = Math.min(
(stats.value.ram_usage_bytes / stats.value.ram_total_bytes) * 100,
100,
);
const cpuPercent = Math.min(stats.value.cpu_percent, 100);
updateGraphData(cpuData.value, cpuPercent);
updateGraphData(ramData.value, ramPercent);
return [
{
title: "CPU usage",
value: `${cpuPercent.toFixed(2)}%`,
max: "100%",
icon: CPUIcon,
data: cpuData.value,
showGraph: true,
warning: cpuPercent >= 90 ? "CPU usage is very high" : null,
},
{
title: "Memory usage",
value: userPreferences.value.ramAsNumber
? formatBytes(stats.value.ram_usage_bytes)
: `${ramPercent.toFixed(2)}%`,
max: userPreferences.value.ramAsNumber ? formatBytes(stats.value.ram_total_bytes) : "100%",
icon: DBIcon,
data: ramData.value,
showGraph: true,
warning: ramPercent >= 90 ? "Memory usage is very high" : null,
},
];
});
const getChartOptions = (hasWarning: string | null) => ({
chart: {
type: "area",
animations: { enabled: false },
sparkline: { enabled: true },
toolbar: { show: false },
padding: {
left: -10,
right: -10,
top: 0,
bottom: 0,
},
},
stroke: { curve: "smooth", width: 3 },
fill: {
type: "gradient",
gradient: {
shadeIntensity: 1,
opacityFrom: 0.25,
opacityTo: 0.05,
stops: [0, 100],
},
},
tooltip: { enabled: false },
grid: { show: false },
xaxis: {
labels: { show: false },
axisBorder: { show: false },
type: "numeric",
tickAmount: 20,
range: 20,
},
yaxis: {
show: false,
min: 0,
max: 100,
forceNiceScale: false,
},
colors: [hasWarning ? "var(--color-orange)" : "var(--color-brand)"],
dataLabels: {
enabled: false,
},
});
watch(
() => props.data.current.storage_usage_bytes,
(newValue, oldValue) => {
animateValue(oldValue, newValue, 250);
() => props.data.current,
(newStats) => {
stats.value = newStats;
},
);
const metrics = ref([
{
title: "CPU usage",
value: "0%",
max: "100%",
icon: markRaw(CPUIcon),
data: [] as number[],
},
{
title: "Memory usage",
value: "0%",
max: userPreferences.value.ramAsNumber
? formatBytes(props.data.current.ram_total_bytes)
: "100%",
icon: markRaw(DBIcon),
data: [] as number[],
},
]);
const updateMetrics = () => {
console.log(props.data.current.ram_usage_bytes);
metrics.value = metrics.value.map((metric, index) => {
if (userPreferences.value.ramAsNumber && index === 1) {
return {
...metric,
value: formatBytes(props.data.current.ram_usage_bytes),
data: [...metric.data.slice(-10), props.data.current.ram_usage_bytes],
max: formatBytes(props.data.current.ram_total_bytes),
};
} else {
const currentValue =
index === 0
? props.data.current.cpu_percent
: Math.min(
(props.data.current.ram_usage_bytes / props.data.current.ram_total_bytes) * 100,
100,
);
const pastValue =
index === 0
? props.data.past.cpu_percent
: Math.min(
(props.data.past.ram_usage_bytes / props.data.past.ram_total_bytes) * 100,
100,
);
const newValue = lerp(currentValue, pastValue);
return {
...metric,
value: `${newValue.toFixed(2)}%`,
data: [...metric.data.slice(-10), newValue],
// data: [36, 36],
};
}
});
};
// aww, you gotta give em that rinth tuah, mod on that thang
const getPotentialWarning = (metric: (typeof metrics.value)[0]) => {
// make all words in the string lowercase, unless the word is in all caps
const split = metric.title.split(" ");
const title = split
.map((word) => {
if (word === word.toUpperCase()) {
return word;
}
return word.toLowerCase();
})
.join(" ");
let data = metric.data.at(-1) || 0;
if (userPreferences.value.ramAsNumber) {
data = (props.data.current.ram_usage_bytes / props.data.current.ram_total_bytes) * 100;
}
switch (true) {
case data >= 90:
return `Your server's ${title} is very high.`;
default:
return "";
}
};
const generateOptions = (metric: (typeof metrics.value)[0]) => {
let color = "var(--color-brand)";
let data = metric.data.at(-1) || 0;
if (userPreferences.value.ramAsNumber) {
data = (props.data.current.ram_usage_bytes / props.data.current.ram_total_bytes) * 100;
}
switch (true) {
case data >= 90:
color = "var(--color-red)";
break;
case data >= 80:
color = "var(--color-orange)";
break;
}
return {
chart: {
id: "stats",
fontFamily:
"Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif",
foreColor: "var(--color-base)",
toolbar: { show: false },
zoom: { enabled: false },
sparkline: { enabled: true },
animations: {
enabled: true,
easing: "linear",
dynamicAnimation: { speed: 1000 },
},
},
stroke: { curve: "smooth" },
fill: {
colors: [color],
type: "gradient",
opacity: 1,
gradient: {
shade: "light",
type: "vertical",
shadeIntensity: 0,
gradientToColors: [color],
inverseColors: true,
opacityFrom: 0.5,
opacityTo: 0,
stops: [0, 100],
colorStops: [],
},
},
grid: { show: false },
legend: { show: false },
colors: [color],
dataLabels: { enabled: false },
xaxis: {
type: "numeric",
lines: { show: false },
axisBorder: { show: false },
labels: { show: false },
},
yaxis: {
min: 0,
max: 100,
tickAmount: 5,
labels: { show: false },
axisBorder: { show: false },
axisTicks: { show: false },
},
tooltip: { enabled: false },
};
};
// watch(
// metrics,
// () => {
// console.log(metrics.value[0].data.at(-1));
// },
// {
// deep: true,
// immediate: true,
// },
// );
let interval: number;
onMounted(() => {
updateMetrics();
interval = window.setInterval(updateMetrics, 1000);
});
onUnmounted(() => {
if (interval) {
clearInterval(interval);
}
});
</script>
<style scoped>
@keyframes chart-enter-animation {
0% {
opacity: 0;
}
100% {
.chart {
animation: fadeIn 0.2s ease-out 0.2s forwards;
margin-left: -24px;
margin-right: -24px;
width: calc(100% + 48px) !important;
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
.chart-animation {
opacity: 0;
animation: chart-enter-animation 0.5s ease-out forwards;
animation-delay: 1s;
}
</style>

View File

@ -1,6 +1,6 @@
<template>
<div
v-if="subdomain"
v-if="subdomain && !isHidden"
v-tooltip="'Copy custom URL'"
class="flex min-w-0 flex-row items-center gap-4 truncate hover:cursor-pointer"
>
@ -20,6 +20,8 @@
<script setup lang="ts">
import { LinkIcon } from "@modrinth/assets";
import { useStorage } from "@vueuse/core";
const props = defineProps<{
subdomain: string;
noSeparator?: boolean;
@ -29,12 +31,18 @@ const copySubdomain = () => {
navigator.clipboard.writeText(props.subdomain + ".modrinth.gg");
addNotification({
group: "servers",
title: "Subdomain copied",
text: "Your subdomain has been copied to your clipboard.",
title: "Custom URL copied",
text: "Your server's URL has been copied to your clipboard.",
type: "success",
});
};
const route = useNativeRoute();
const serverId = computed(() => route.params.id as string);
const userPreferences = useStorage(`pyro-server-${serverId.value}-preferences`, {
hideSubdomainLabel: false,
});
const isHidden = computed(() => userPreferences.value.hideSubdomainLabel);
</script>

View File

@ -8,7 +8,7 @@
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
<div class="flex gap-2">
<UiServersTimer class="flex size-5 shrink-0" />
<UiServersIconsTimer class="flex size-5 shrink-0" />
<time class="truncate text-sm font-semibold" :aria-label="verboseUptime">
{{ formattedUptime }}
</time>

View File

@ -1,28 +1,23 @@
<template>
<div
ref="dropdown"
data-pyro-dropdown
tabindex="0"
role="combobox"
:aria-expanded="dropdownVisible"
class="relative inline-block h-9 w-full max-w-80"
@focus="onFocus"
@blur="onBlur"
@mousedown.prevent
@keydown="handleKeyDown"
>
<div
data-pyro-dropdown-trigger
class="duration-50 flex h-full w-full cursor-pointer select-none items-center justify-between gap-4 rounded-xl bg-button-bg px-4 py-2 shadow-sm transition-all ease-in-out"
<div class="relative inline-block h-9 w-full max-w-80">
<button
ref="triggerRef"
type="button"
aria-haspopup="listbox"
:aria-expanded="dropdownVisible"
:aria-controls="listboxId"
:aria-labelledby="listboxId"
class="duration-50 flex h-full w-full cursor-pointer select-none appearance-none items-center justify-between gap-4 rounded-xl border-none bg-button-bg px-4 py-2 shadow-sm !outline-none transition-all ease-in-out"
:class="triggerClasses"
@click="toggleDropdown"
@keydown="handleTriggerKeyDown"
>
<span>{{ selectedOption }}</span>
<DropdownIcon
class="transition-transform duration-200 ease-in-out"
:class="{ 'rotate-180': dropdownVisible }"
/>
</div>
</button>
<Teleport to="#teleports">
<transition
@ -35,27 +30,28 @@
>
<div
v-if="dropdownVisible"
:id="listboxId"
ref="optionsContainer"
data-pyro-dropdown-options
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg"
role="listbox"
tabindex="-1"
:aria-activedescendant="activeDescendant"
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg outline-none"
:class="{
'rounded-b-xl': !isRenderingUp,
'rounded-t-xl': isRenderingUp,
}"
:style="positionStyle"
@keydown.stop="handleDropdownKeyDown"
@keydown="handleListboxKeyDown"
>
<div
class="overflow-y-auto"
:style="{ height: `${virtualListHeight}px` }"
data-pyro-dropdown-options-virtual-scroller
@scroll="handleScroll"
>
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
<div
v-for="item in visibleOptions"
:key="item.index"
data-pyro-dropdown-option
:style="{
position: 'absolute',
top: 0,
@ -65,32 +61,20 @@
}"
>
<div
:ref="(el) => handleOptionRef(el as HTMLElement, item.index)"
:id="`${listboxId}-option-${item.index}`"
role="option"
:tabindex="focusedOptionIndex === item.index ? 0 : -1"
class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out focus:border-none focus:outline-none"
:aria-selected="selectedValue === item.option"
class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out"
:class="{
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
'bg-bg-raised': focusedOptionIndex === item.index,
'rounded-b-xl': item.index === props.options.length - 1 && !isRenderingUp,
'rounded-t-xl': item.index === 0 && isRenderingUp,
}"
:aria-selected="selectedValue === item.option"
@click="selectOption(item.option, item.index)"
@mouseover="focusedOptionIndex = item.index"
@focus="focusedOptionIndex = item.index"
@mousemove="focusedOptionIndex = item.index"
>
<input
:id="`${name}-${item.index}`"
v-model="radioValue"
type="radio"
:value="item.option"
:name="name"
class="hidden"
/>
<label :for="`${name}-${item.index}`" class="w-full cursor-pointer">
{{ displayName(item.option) }}
</label>
{{ displayName(item.option) }}
</div>
</div>
</div>
@ -140,13 +124,14 @@ const emit = defineEmits<{
const dropdownVisible = ref(false);
const selectedValue = ref<OptionValue | null>(props.modelValue || props.defaultValue);
const focusedOptionIndex = ref<number | null>(null);
const focusedOptionRef = ref<HTMLElement | null>(null);
const dropdown = ref<HTMLElement | null>(null);
const optionsContainer = ref<HTMLElement | null>(null);
const scrollTop = ref(0);
const isRenderingUp = ref(false);
const virtualListHeight = ref(300);
const lastFocusedElement = ref<HTMLElement | null>(null);
const isOpen = ref(false);
const openDropdownCount = ref(0);
const listboxId = `pyro-listbox-${Math.random().toString(36).substring(2, 11)}`;
const triggerRef = ref<HTMLButtonElement | null>(null);
const positionStyle = ref<CSSProperties>({
position: "fixed",
@ -156,41 +141,6 @@ const positionStyle = ref<CSSProperties>({
zIndex: 999,
});
const handleOptionRef = (el: HTMLElement | null, index: number) => {
if (focusedOptionIndex.value === index) {
focusedOptionRef.value = el;
}
};
const onFocus = async () => {
if (!props.disabled) {
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value);
lastFocusedElement.value = document.activeElement as HTMLElement;
dropdownVisible.value = true;
await updatePosition();
nextTick(() => {
dropdown.value?.focus();
});
}
};
const onBlur = (event: FocusEvent) => {
if (!isChildOfDropdown(event.relatedTarget as HTMLElement | null)) {
closeDropdown();
}
};
const isChildOfDropdown = (element: HTMLElement | null): boolean => {
let currentNode: HTMLElement | null = element;
while (currentNode) {
if (currentNode === dropdown.value || currentNode === optionsContainer.value) {
return true;
}
currentNode = currentNode.parentElement;
}
return false;
};
const totalHeight = computed(() => props.options.length * ITEM_HEIGHT);
const visibleOptions = computed(() => {
@ -233,10 +183,10 @@ const triggerClasses = computed(() => ({
}));
const updatePosition = async () => {
if (!dropdown.value) return;
if (!triggerRef.value) return;
await nextTick();
const triggerRect = dropdown.value.getBoundingClientRect();
const triggerRect = triggerRef.value.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const margin = 8;
@ -263,20 +213,6 @@ const updatePosition = async () => {
};
};
const openDropdown = async () => {
if (!props.disabled) {
closeAllDropdowns();
dropdownVisible.value = true;
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value);
lastFocusedElement.value = document.activeElement as HTMLElement;
await updatePosition();
requestAnimationFrame(() => {
updatePosition();
});
}
};
const toggleDropdown = () => {
if (!props.disabled) {
if (dropdownVisible.value) {
@ -300,61 +236,6 @@ const handleScroll = (event: Event) => {
scrollTop.value = target.scrollTop;
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!dropdownVisible.value) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
lastFocusedElement.value = document.activeElement as HTMLElement;
toggleDropdown();
}
} else {
handleDropdownKeyDown(event);
}
};
const handleDropdownKeyDown = (event: KeyboardEvent) => {
event.stopPropagation();
switch (event.key) {
case "ArrowDown":
event.preventDefault();
focusNextOption();
break;
case "ArrowUp":
event.preventDefault();
focusPreviousOption();
break;
case "Enter":
event.preventDefault();
if (focusedOptionIndex.value !== null) {
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value);
}
break;
case "Escape":
event.preventDefault();
event.stopPropagation();
closeDropdown();
break;
case "Tab":
event.preventDefault();
if (event.shiftKey) {
focusPreviousOption();
} else {
focusNextOption();
}
break;
}
};
const closeDropdown = () => {
dropdownVisible.value = false;
focusedOptionIndex.value = null;
if (lastFocusedElement.value) {
lastFocusedElement.value.focus();
lastFocusedElement.value = null;
}
};
const closeAllDropdowns = () => {
const event = new CustomEvent("close-all-dropdowns");
window.dispatchEvent(event);
@ -373,9 +254,6 @@ const focusNextOption = () => {
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length;
}
scrollToFocused();
nextTick(() => {
focusedOptionRef.value?.focus();
});
};
const focusPreviousOption = () => {
@ -386,9 +264,6 @@ const focusPreviousOption = () => {
(focusedOptionIndex.value - 1 + props.options.length) % props.options.length;
}
scrollToFocused();
nextTick(() => {
focusedOptionRef.value?.focus();
});
};
const scrollToFocused = () => {
@ -407,6 +282,119 @@ const scrollToFocused = () => {
}
};
const openDropdown = async () => {
if (!props.disabled) {
closeAllDropdowns();
dropdownVisible.value = true;
isOpen.value = true;
openDropdownCount.value++;
document.body.style.overflow = "hidden";
await updatePosition();
nextTick(() => {
optionsContainer.value?.focus();
});
}
};
const closeDropdown = () => {
if (isOpen.value) {
dropdownVisible.value = false;
isOpen.value = false;
openDropdownCount.value--;
if (openDropdownCount.value === 0) {
document.body.style.overflow = "";
}
focusedOptionIndex.value = null;
triggerRef.value?.focus();
}
};
const handleTriggerKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case "ArrowDown":
case "ArrowUp":
event.preventDefault();
if (!dropdownVisible.value) {
openDropdown();
focusedOptionIndex.value = event.key === "ArrowUp" ? props.options.length - 1 : 0;
} else if (event.key === "ArrowDown") {
focusNextOption();
} else {
focusPreviousOption();
}
break;
case "Enter":
case " ":
event.preventDefault();
if (!dropdownVisible.value) {
openDropdown();
focusedOptionIndex.value = 0;
} else if (focusedOptionIndex.value !== null) {
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value);
}
break;
case "Escape":
event.preventDefault();
closeDropdown();
break;
case "Tab":
if (dropdownVisible.value) {
event.preventDefault();
}
break;
}
};
const handleListboxKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case "Enter":
case " ":
event.preventDefault();
if (focusedOptionIndex.value !== null) {
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value);
}
break;
case "ArrowDown":
event.preventDefault();
focusNextOption();
break;
case "ArrowUp":
event.preventDefault();
focusPreviousOption();
break;
case "Escape":
event.preventDefault();
closeDropdown();
break;
case "Tab":
event.preventDefault();
break;
case "Home":
event.preventDefault();
focusedOptionIndex.value = 0;
scrollToFocused();
break;
case "End":
event.preventDefault();
focusedOptionIndex.value = props.options.length - 1;
scrollToFocused();
break;
default:
if (event.key.length === 1) {
const char = event.key.toLowerCase();
const index = props.options.findIndex((option) =>
props.displayName(option).toLowerCase().startsWith(char),
);
if (index !== -1) {
focusedOptionIndex.value = index;
scrollToFocused();
}
}
break;
}
};
onMounted(() => {
window.addEventListener("resize", handleResize);
window.addEventListener("scroll", handleResize, true);
@ -416,6 +404,10 @@ onMounted(() => {
}
});
window.addEventListener("close-all-dropdowns", closeDropdown);
if (selectedValue.value) {
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value);
}
});
onUnmounted(() => {
@ -427,7 +419,13 @@ onUnmounted(() => {
}
});
window.removeEventListener("close-all-dropdowns", closeDropdown);
lastFocusedElement.value = null;
if (isOpen.value) {
openDropdownCount.value--;
if (openDropdownCount.value === 0) {
document.body.style.overflow = "";
}
}
});
watch(
@ -443,4 +441,19 @@ watch(dropdownVisible, async (newValue) => {
scrollTop.value = 0;
}
});
const activeDescendant = computed(() =>
focusedOptionIndex.value !== null ? `${listboxId}-option-${focusedOptionIndex.value}` : undefined,
);
const isChildOfDropdown = (element: HTMLElement | null): boolean => {
let currentNode: HTMLElement | null = element;
while (currentNode) {
if (currentNode === triggerRef.value || currentNode === optionsContainer.value) {
return true;
}
currentNode = currentNode.parentElement;
}
return false;
};
</script>

View File

@ -0,0 +1,16 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevron-down"
>
<path d="m6 9 6 6 6-6" />
</svg>
</template>

View File

@ -0,0 +1,16 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevron-up"
>
<path d="m18 15-6-6-6 6" />
</svg>
</template>

View File

@ -272,6 +272,10 @@ const constructServerProperties = (properties: any): string => {
const processImage = async (iconUrl: string | undefined) => {
const image = ref<string | null>(null);
const sharedImage = useState<string | undefined>(
`server-icon-${internalServerRefrence.value.serverId}`,
() => undefined,
);
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerRefrence.value.serverId}/fs`);
try {
const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, {
@ -293,6 +297,7 @@ const processImage = async (iconUrl: string | undefined) => {
const dataURL = canvas.toDataURL("image/png");
internalServerRefrence.value.general.image = dataURL;
image.value = dataURL;
sharedImage.value = dataURL; // Store in useState
resolve();
};
});
@ -300,7 +305,7 @@ const processImage = async (iconUrl: string | undefined) => {
}
} catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 404) {
console.log("[PYROSERVERS] No server icon found");
sharedImage.value = undefined;
} else {
console.error(error);
}
@ -973,7 +978,6 @@ const modules: any = {
suspend: suspendServer,
getMotd,
setMotd,
fetchConfigFile,
},
content: {
get: async (serverId: string) => {
@ -1131,8 +1135,7 @@ type GeneralFunctions = {
setMotd: (motd: string) => Promise<void>;
/**
* INTERNAL: Gets the config file of a server.
* @param fileName - The name of the file.
* @deprecated Use fs.downloadFile instead
*/
fetchConfigFile: (fileName: string) => Promise<any>;
};
@ -1389,8 +1392,15 @@ export type Server<T extends avaliableModules> = {
/**
* Refreshes the included modules of the server
* @param refreshModules - The modules to refresh.
* @param options - The options to use when refreshing the modules.
*/
refresh: (refreshModules?: avaliableModules) => Promise<void>;
refresh: (
refreshModules?: avaliableModules,
options?: {
preserveConnection?: boolean;
preserveInstallState?: boolean;
},
) => Promise<void>;
setError: (error: Error) => void;
error?: Error;
serverId: string;
@ -1398,33 +1408,53 @@ export type Server<T extends avaliableModules> = {
export const usePyroServer = async (serverId: string, includedModules: avaliableModules) => {
const server: Server<typeof includedModules> = reactive({
refresh: async (refreshModules?: avaliableModules) => {
refresh: async (
refreshModules?: avaliableModules,
options?: {
preserveConnection?: boolean;
preserveInstallState?: boolean;
},
) => {
if (server.general?.status === "installing" && !refreshModules) {
return;
}
const modulesToRefresh = refreshModules || includedModules;
const promises: Promise<void>[] = [];
if (refreshModules) {
for (const module of refreshModules) {
const uniqueModules = [...new Set(modulesToRefresh)];
for (const module of uniqueModules) {
const mods = modules[module];
if (mods.get) {
promises.push(
(async () => {
const mods = modules[module];
if (mods.get) {
const data = await mods.get(serverId);
server[module] = { ...server[module], ...data };
}
})(),
);
}
} else {
for (const module of includedModules) {
promises.push(
(async () => {
const mods = modules[module];
if (mods.get) {
const data = await mods.get(serverId);
server[module] = { ...server[module], ...data };
const data = await mods.get(serverId);
if (data) {
if (module === "general" && options?.preserveConnection) {
const updatedData = {
...server[module],
...data,
};
if (server[module]?.image) {
updatedData.image = server[module].image;
}
if (server[module]?.motd) {
updatedData.motd = server[module].motd;
}
if (options.preserveInstallState && server[module]?.status === "installing") {
updatedData.status = "installing";
}
server[module] = updatedData;
} else {
server[module] = { ...server[module], ...data };
}
}
})(),
);
}
}
await Promise.all(promises);
},
setError: (error: Error) => {

View File

@ -96,7 +96,7 @@
<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-red p-4">
<PanelErrorIcon class="size-12 text-red" />
<UiServersIconsPanelErrorIcon class="size-12 text-red" />
</div>
<h1 class="m-0 mb-4 w-fit text-4xl font-bold">Server Node Unavailable</h1>
</div>
@ -343,7 +343,7 @@
<div
v-if="isReconnecting"
data-pyro-server-ws-reconnecting
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-contrast"
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
>
<UiServersPanelSpinner />
Hang on, we're reconnecting to your server.
@ -352,10 +352,16 @@
<div
v-if="serverData.status === 'installing'"
data-pyro-server-installing
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-contrast"
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
>
<UiServersPanelSpinner />
We're preparing your server, this may take a few minutes.
<UiServersServerIcon :image="serverData.image" class="!h-10 !w-10" />
<div class="flex flex-col gap-1">
<span class="text-lg font-bold"> We're preparing your server! </span>
<div class="flex flex-row items-center gap-2">
<UiServersPanelSpinner class="!h-3 !w-3" /> <LazyUiServersInstallingTicker />
</div>
</div>
</div>
<NuxtPage
@ -392,10 +398,9 @@ import {
import DOMPurify from "dompurify";
import { ButtonStyled } from "@modrinth/ui";
import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
import { reloadNuxtApp } from "#app";
import { reloadNuxtApp, navigateTo } from "#app";
import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers";
import { usePyroConsole } from "~/store/console.ts";
import PanelErrorIcon from "~/components/ui/servers/icons/PanelErrorIcon.vue";
const socket = ref<WebSocket | null>(null);
const isReconnecting = ref(false);
@ -662,22 +667,49 @@ const newMCVersion = ref<string | null>(null);
const handleInstallationResult = async (data: WSInstallationResultEvent) => {
switch (data.result) {
case "ok":
case "ok": {
if (!serverData.value) break;
serverData.value.status = "available";
if (!isFirstMount.value) {
await server.refresh();
}
if (server.general) {
if (newLoader.value) server.general.loader = newLoader.value;
if (newLoaderVersion.value) server.general.loader_version = newLoaderVersion.value;
if (newMCVersion.value) server.general.mc_version = newMCVersion.value;
stopPolling();
try {
await new Promise((resolve) => setTimeout(resolve, 2000));
let attempts = 0;
const maxAttempts = 3;
let hasValidData = false;
while (!hasValidData && attempts < maxAttempts) {
attempts++;
await server.refresh(["general"], {
preserveConnection: true,
preserveInstallState: true,
});
if (serverData.value?.loader && serverData.value?.mc_version) {
hasValidData = true;
serverData.value.status = "available";
await server.refresh(["content", "startup"]);
break;
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (!hasValidData) {
console.error("Failed to get valid server data after installation");
}
} catch (err: unknown) {
console.error("Error refreshing data after installation:", err);
}
newLoader.value = null;
newLoaderVersion.value = null;
newMCVersion.value = null;
error.value = null;
break;
}
case "err": {
console.log("failed to install");
console.log(data);
@ -708,20 +740,9 @@ const handleInstallationResult = async (data: WSInstallationResultEvent) => {
const onReinstall = (potentialArgs: any) => {
if (!serverData.value) return;
serverData.value.status = "installing";
// serverData.value.loader = potentialArgs.loader;
// serverData.value.loader_version = potentialArgs.lVersion;
// serverData.value.mc_version = potentialArgs.mVersion;
// if (potentialArgs?.loader) {
// console.log("setting loadeconsole
// serverData.value.loader = potentialArgs.loader;
// }
// if (potentialArgs?.lVersion) {
// serverData.value.loader_version = potentialArgs.lVersion;
// }
// if (potentialArgs?.mVersion) {
// serverData.value.mc_version = potentialArgs.mVersion;
// }
if (potentialArgs?.loader) {
newLoader.value = potentialArgs.loader;
}
@ -732,15 +753,9 @@ const onReinstall = (potentialArgs: any) => {
newMCVersion.value = potentialArgs.mVersion;
}
if (!isFirstMount.value) {
server.refresh();
}
error.value = null;
errorTitle.value = "Error";
errorMessage.value = "An unexpected error occurred.";
console.log(serverData.value);
};
const updateStats = (currentStats: Stats["current"]) => {
@ -762,7 +777,6 @@ const updatePowerState = (
state: ServerState,
details?: { oom_killed?: boolean; exit_code?: number },
) => {
console.log("Power state:", state, details);
serverPowerState.value = state;
if (state === "crashed") {
@ -959,17 +973,15 @@ onUnmounted(() => {
watch(
() => serverData.value?.status,
(newStatus) => {
(newStatus, oldStatus) => {
if (isFirstMount.value) {
isFirstMount.value = false;
return;
}
if (newStatus === "installing") {
if (newStatus === "installing" && oldStatus !== "installing") {
countdown.value = 15;
startPolling();
} else {
stopPolling();
server.refresh();
}
},
);
@ -996,7 +1008,16 @@ definePageMeta({
}
.mobile-blurred-servericon::before {
@apply absolute left-0 top-0 block h-36 w-full bg-cover bg-center bg-no-repeat blur-2xl sm:hidden;
position: absolute;
left: 0;
top: 0;
display: block;
height: 9rem;
width: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
filter: blur(1rem);
content: "";
background-image: linear-gradient(
to bottom,
@ -1005,4 +1026,10 @@ definePageMeta({
),
var(--server-bg-image);
}
@media screen and (min-width: 640px) {
.mobile-blurred-servericon::before {
display: none;
}
}
</style>

View File

@ -53,7 +53,10 @@
</div>
<div class="flex w-full flex-col gap-2 sm:w-fit sm:flex-row">
<ButtonStyled type="standard">
<button @click="showbackupSettingsModal">
<button
:disabled="server.general?.status === 'installing'"
@click="showbackupSettingsModal"
>
<SettingsIcon class="h-5 w-5" />
Auto backups
</button>
@ -63,13 +66,16 @@
v-tooltip="
isServerRunning && !userPreferences.backupWhileRunning
? 'Cannot create backup while server is running. You can disable this from your server Options > Preferences.'
: ''
: server.general?.status === 'installing'
? 'Cannot create backups while server is being installed'
: ''
"
class="w-full sm:w-fit"
:disabled="
(isServerRunning && !userPreferences.backupWhileRunning) ||
data.used_backup_quota >= data.backup_quota ||
backups.some((backup) => backup.ongoing)
backups.some((backup) => backup.ongoing) ||
server.general?.status === 'installing'
"
@click="showCreateModel"
>
@ -90,108 +96,111 @@
automatically refresh when the backup is complete.
</div>
<li
v-for="(backup, index) in backups"
:key="backup.id"
class="relative m-0 w-full list-none rounded-2xl bg-bg-raised p-4 shadow-md"
>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div class="flex min-w-0 flex-row items-center gap-4">
<div
class="grid size-14 shrink-0 place-content-center overflow-hidden rounded-xl border-[1px] border-solid border-button-border shadow-sm"
:class="backup.ongoing ? 'text-green [&&]:bg-bg-green' : 'bg-button-bg'"
>
<UiServersIconsLoadingIcon
v-if="backup.ongoing"
v-tooltip="'Backup in progress'"
class="size-6 animate-spin"
/>
<LockIcon v-else-if="backup.locked" class="size-8" />
<BoxIcon v-else class="size-8" />
</div>
<div class="flex min-w-0 flex-col gap-2">
<div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
<div class="max-w-full truncate text-xl font-bold text-contrast">
{{ backup.name }}
</div>
<div class="flex w-full flex-col gap-2">
<li
v-for="(backup, index) in backups"
:key="backup.id"
class="relative m-0 w-full list-none rounded-2xl bg-bg-raised p-2 shadow-md"
>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div class="flex min-w-0 flex-row items-center gap-4">
<div
class="grid size-14 shrink-0 place-content-center overflow-hidden rounded-xl border-[1px] border-solid border-button-border shadow-sm"
:class="backup.ongoing ? 'text-green [&&]:bg-bg-green' : 'bg-button-bg'"
>
<UiServersIconsLoadingIcon
v-if="backup.ongoing"
v-tooltip="'Backup in progress'"
class="size-6 animate-spin"
/>
<LockIcon v-else-if="backup.locked" class="size-8" />
<BoxIcon v-else class="size-8" />
</div>
<div class="flex min-w-0 flex-col gap-2">
<div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
<div class="max-w-full truncate font-bold text-contrast">
{{ backup.name }}
</div>
<div
v-if="index == 0"
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
>
<CheckIcon class="size-4" /> Latest
<div
v-if="index == 0"
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
>
<CheckIcon class="size-4" /> Latest
</div>
</div>
<div class="flex items-center gap-1 text-xs">
<CalendarIcon class="size-4" />
{{
new Date(backup.created_at).toLocaleString("en-US", {
month: "numeric",
day: "numeric",
year: "2-digit",
hour: "numeric",
minute: "numeric",
hour12: true,
})
}}
</div>
</div>
<div class="flex items-center gap-2 text-sm font-semibold">
<CalendarIcon class="size-4" />
{{
new Date(backup.created_at).toLocaleString("en-US", {
month: "numeric",
day: "numeric",
year: "2-digit",
hour: "numeric",
minute: "numeric",
hour12: true,
})
}}
</div>
</div>
<ButtonStyled v-if="!backup.ongoing" circular type="transparent">
<UiServersTeleportOverflowMenu
direction="left"
position="bottom"
class="bg-transparent"
:disabled="backups.some((b) => b.ongoing)"
:options="[
{
id: 'rename',
action: () => {
renameBackupName = backup.name;
currentBackup = backup.id;
renameBackupModal?.show();
},
},
{
id: 'restore',
action: () => {
currentBackup = backup.id;
restoreBackupModal?.show();
},
},
{ id: 'download', action: () => initiateDownload(backup.id) },
{
id: 'lock',
action: () => {
if (backup.locked) {
unlockBackup(backup.id);
} else {
lockBackup(backup.id);
}
},
},
{
id: 'delete',
action: () => {
currentBackup = backup.id;
deleteBackupModal?.show();
},
color: 'red',
},
]"
>
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
<template #rename> <EditIcon /> Rename </template>
<template #restore> <ClipboardCopyIcon /> Restore </template>
<template v-if="backup.locked" #lock> <LockOpenIcon /> Unlock </template>
<template v-else #lock> <LockIcon /> Lock </template>
<template #download> <DownloadIcon /> Download </template>
<template #delete> <TrashIcon /> Delete </template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div>
<ButtonStyled v-if="!backup.ongoing" circular type="transparent">
<UiServersTeleportOverflowMenu
direction="left"
position="bottom"
class="bg-transparent"
:options="[
{
id: 'rename',
action: () => {
renameBackupName = backup.name;
currentBackup = backup.id;
renameBackupModal?.show();
},
},
{
id: 'restore',
action: () => {
currentBackup = backup.id;
restoreBackupModal?.show();
},
},
{ id: 'download', action: () => initiateDownload(backup.id) },
{
id: 'lock',
action: () => {
if (backup.locked) {
unlockBackup(backup.id);
} else {
lockBackup(backup.id);
}
},
},
{
id: 'delete',
action: () => {
currentBackup = backup.id;
deleteBackupModal?.show();
},
color: 'red',
},
]"
>
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
<template #rename> <EditIcon /> Rename </template>
<template #restore> <ClipboardCopyIcon /> Restore </template>
<template v-if="backup.locked" #lock> <LockOpenIcon /> Unlock </template>
<template v-else #lock> <LockIcon /> Lock </template>
<template #download> <DownloadIcon /> Download </template>
<template #delete> <TrashIcon /> Delete </template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div>
</div>
</li>
</li>
</div>
</ul>
<div

View File

@ -34,13 +34,18 @@
<UiServersFilesBrowseNavbar
:breadcrumb-segments="breadcrumbSegments"
:search-query="searchQuery"
:sort-method="sortMethod"
:current-filter="viewFilter"
@navigate="navigateToSegment"
@sort="sortFiles"
@create="showCreateModal"
@upload="initiateFileUpload"
@filter="handleFilter"
@update:search-query="searchQuery = $event"
/>
<UiServersFilesLabelBar
:sort-field="sortMethod"
:sort-desc="sortDesc"
@sort="handleSort"
/>
<FilesUploadDropdown
v-if="props.server.fs"
ref="uploadDropdownRef"
@ -94,7 +99,6 @@
</div>
<div v-else-if="items.length > 0" class="h-full w-full overflow-hidden rounded-b-2xl">
<UiServersFilesLabelBar />
<UiServersFileVirtualList
:items="filteredItems"
@delete="showDeleteModal"
@ -196,7 +200,8 @@ const operationHistory = ref<Operation[]>([]);
const redoStack = ref<Operation[]>([]);
const searchQuery = ref("");
const sortMethod = ref("default");
const sortMethod = ref("name");
const sortDesc = ref(false);
const maxResults = 100;
const currentPage = ref(1);
@ -227,6 +232,14 @@ const uploadDropdownRef = ref();
const data = computed(() => props.server.general);
const viewFilter = ref("all");
const handleFilter = (type: string) => {
viewFilter.value = type;
sortMethod.value = "name";
sortDesc.value = false;
};
useHead({
title: computed(() => `Files - ${data.value?.name ?? "Server"} - Modrinth`),
});
@ -567,6 +580,51 @@ const applyDefaultSort = (items: DirectoryItem[]) => {
});
};
const handleSort = (field: string) => {
if (sortMethod.value === field) {
sortDesc.value = !sortDesc.value;
} else {
sortMethod.value = field;
sortDesc.value = false;
}
};
const applySort = (items: DirectoryItem[]) => {
let result = [...items];
switch (viewFilter.value) {
case "filesOnly":
result = result.filter((item) => item.type !== "directory");
break;
case "foldersOnly":
result = result.filter((item) => item.type === "directory");
break;
}
const compareItems = (a: DirectoryItem, b: DirectoryItem) => {
if (viewFilter.value === "all") {
if (a.type === "directory" && b.type !== "directory") return -1;
if (a.type !== "directory" && b.type === "directory") return 1;
}
switch (sortMethod.value) {
case "modified":
return sortDesc.value
? new Date(a.modified).getTime() - new Date(b.modified).getTime()
: new Date(b.modified).getTime() - new Date(a.modified).getTime();
case "created":
return sortDesc.value
? new Date(a.created).getTime() - new Date(b.created).getTime()
: new Date(b.created).getTime() - new Date(a.created).getTime();
default:
return sortDesc.value ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name);
}
};
result.sort(compareItems);
return result;
};
const filteredItems = computed(() => {
let result = [...items.value];
@ -575,24 +633,7 @@ const filteredItems = computed(() => {
result = result.filter((item) => item.name.toLowerCase().includes(query));
}
switch (sortMethod.value) {
case "modified":
result.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
break;
case "created":
result.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
break;
case "filesOnly":
result = result.filter((item) => item.type !== "directory");
break;
case "foldersOnly":
result = result.filter((item) => item.type === "directory");
break;
default:
result = applyDefaultSort(result);
}
return result;
return applySort(result);
});
const { reset } = useInfiniteScroll(
@ -656,10 +697,6 @@ const onAnywhereClicked = (e: MouseEvent) => {
}
};
const sortFiles = (method: string) => {
sortMethod.value = method;
};
const imageExtensions = ["png", "jpg", "jpeg", "gif", "webp"];
const editFile = async (item: { name: string; type: string; path: string }) => {
@ -717,7 +754,9 @@ watch(
async (newQuery) => {
currentPage.value = 1;
searchQuery.value = "";
sortMethod.value = "default";
viewFilter.value = "all";
sortMethod.value = "name";
sortDesc.value = false;
currentPath.value = Array.isArray(newQuery.path)
? newQuery.path.join("")

View File

@ -80,14 +80,19 @@
<div class="flex flex-col-reverse gap-6 md:flex-col">
<UiServersServerStats :data="stats" />
<div
class="relative flex h-[600px] w-full flex-col gap-3 overflow-hidden rounded-2xl border border-divider bg-bg-raised p-4 transition-all duration-300 ease-in-out md:p-8"
class="relative flex h-[700px] w-full flex-col gap-3 overflow-hidden rounded-2xl border border-divider bg-bg-raised p-4 transition-all duration-300 ease-in-out md:p-8"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
<UiServersPanelServerStatus :state="serverPowerState" />
</div>
</div>
<!-- <div class="flex flex-row items-center gap-2 text-sm font-medium">
<InfoIcon class="hidden sm:block" />
Click and drag to select lines, then CMD+C to copy
</div> -->
<UiServersPanelTerminal :full-screen="fullScreen">
<div class="relative w-full px-4 pt-4">
<ul
@ -164,7 +169,7 @@
</div>
</div>
</div>
<UiServersPanelOverviewLoading v-else-if="!isConnected && !isWsAuthIncorrect" />
<div v-else-if="!isConnected && !isWsAuthIncorrect" />
<div v-else-if="isWsAuthIncorrect" class="flex flex-col">
<h2>Could not connect to the server.</h2>
<p>

View File

@ -116,7 +116,7 @@
</div>
</div>
</div>
<UiServersPyroLoading v-else />
<div v-else />
<UiServersSaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidServerName"
:server="props.server"

View File

@ -27,7 +27,7 @@
{{ data?.sftp_host }}
</span>
<span class="text-xs uppercase text-secondary">server address</span>
<span class="text-xs text-secondary">Server Address</span>
</div>
<ButtonStyled type="transparent">
@ -41,9 +41,9 @@
</div>
<div class="-mt-2 flex flex-col gap-2 sm:mt-0 sm:flex-row">
<div
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow p-4"
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow px-4 py-2"
>
<div class="flex items-center justify-between">
<div class="flex h-8 items-center justify-between">
<span class="font-bold text-contrast">
{{ data?.sftp_username }}
</span>
@ -57,12 +57,12 @@
</button>
</ButtonStyled>
</div>
<span class="text-xs uppercase text-secondary">username</span>
<span class="text-xs text-secondary">Username</span>
</div>
<div
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow p-4"
>
<div class="flex items-center justify-between">
<div class="flex h-8 items-center justify-between">
<span class="font-bold text-contrast">
{{
showPassword ? data?.sftp_password : "*".repeat(data?.sftp_password?.length ?? 0)
@ -89,7 +89,7 @@
</ButtonStyled>
</div>
</div>
<span class="text-xs uppercase text-secondary">password</span>
<span class="text-xs text-secondary">Password</span>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -25,7 +25,7 @@
</form>
</NewModal>
<NewModal ref="editAllocationModal" header="Edit Allocation">
<NewModal ref="editAllocationModal" header="Edit allocation">
<form class="flex flex-col gap-2 md:w-[600px]" @submit.prevent="editAllocation">
<label for="edit-allocation-name" class="font-semibold text-contrast"> Name </label>
<input
@ -40,7 +40,7 @@
<div class="mb-1 mt-4 flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!newAllocationName" type="submit">
<SaveIcon /> Update Allocation
<SaveIcon /> Update allocation
</button>
</ButtonStyled>
<ButtonStyled>
@ -94,7 +94,7 @@
/>
<div
class="flex max-w-full flex-none overflow-auto rounded-xl bg-table-alternateRow p-4"
class="flex max-w-full flex-none overflow-auto rounded-xl bg-table-alternateRow px-4 py-2"
>
<table
class="w-full flex-none border-collapse truncate rounded-lg border-2 border-gray-300"
@ -108,7 +108,7 @@
>
{{ record.type }}
</span>
<span class="text-xs uppercase text-secondary">type</span>
<span class="text-xs text-secondary">Type</span>
</div>
</td>
<td class="w-2/6 py-3 md:w-1/3">
@ -118,7 +118,7 @@
>
{{ record.name }}
</span>
<span class="text-xs uppercase text-secondary">name</span>
<span class="text-xs text-secondary">Name</span>
</div>
</td>
<td class="w-3/6 py-3 pl-4 md:w-5/12 lg:w-5/12">
@ -128,7 +128,7 @@
>
{{ record.content }}
</span>
<span class="text-xs uppercase text-secondary">content</span>
<span class="text-xs text-secondary">Content</span>
</div>
</td>
</tr>
@ -190,7 +190,7 @@
<span class="text-md font-bold tracking-wide text-contrast">
{{ allocation.name }}
</span>
<span class="hidden text-xs uppercase text-secondary sm:block">name</span>
<span class="hidden text-xs text-secondary sm:block">Name</span>
</div>
<div class="flex flex-col gap-1">
<span
@ -198,7 +198,7 @@
>
{{ allocation.port }}
</span>
<span class="hidden text-xs uppercase text-secondary sm:block">port</span>
<span class="hidden text-xs text-secondary sm:block">Port</span>
</div>
</div>
</div>

View File

@ -59,6 +59,11 @@ const preferences = {
"When enabled, RAM will be displayed as bytes instead of a percentage in your server's Overview.",
implemented: true,
},
hideSubdomainLabel: {
displayName: "Hide subdomain label",
description: "When enabled, the subdomain label will be hidden from the server header.",
implemented: true,
},
autoRestart: {
displayName: "Auto restart",
description: "When enabled, your server will automatically restart if it crashes.",
@ -84,6 +89,7 @@ type UserPreferences = {
const defaultPreferences: UserPreferences = {
ramAsNumber: false,
hideSubdomainLabel: false,
autoRestart: false,
powerDontAskAgain: false,
backupWhileRunning: false,

View File

@ -17,7 +17,7 @@
>
Minecraft Wiki
</NuxtLink>
has more detailed information about each property.
has more detailed information.
</div>
</div>
<div class="flex flex-col gap-4 rounded-2xl bg-table-alternateRow p-4">
@ -134,10 +134,29 @@ const isUpdating = ref(false);
const searchInput = ref("");
const data = computed(() => props.server.general);
const { data: propsData, status } = await useAsyncData(
"ServerProperties",
async () => await props.server.general?.fetchConfigFile("ServerProperties"),
);
const { data: propsData, status } = await useAsyncData("ServerProperties", async () => {
const rawProps = await props.server.fs?.downloadFile("server.properties");
if (!rawProps) return null;
const properties: Record<string, any> = {};
const lines = rawProps.split("\n");
for (const line of lines) {
if (line.startsWith("#") || !line.includes("=")) continue;
const [key, ...valueParts] = line.split("=");
let value = valueParts.join("=");
if (value.toLowerCase() === "true" || value.toLowerCase() === "false") {
value = value.toLowerCase() === "true";
} else if (!isNaN(value as any) && value !== "") {
value = Number(value);
}
properties[key.trim()] = value;
}
return properties;
});
const liveProperties = ref<Record<string, any>>({});
const originalProperties = ref<Record<string, any>>({});

View File

@ -35,10 +35,9 @@
<div class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Java version</span>
<span>
The version of Java that your server will run on. Your server is running Minecraft
{{ data.mc_version }}. By default, only the Java versions compatible with this
version of Minecraft are shown. Some mods or modpacks may require a specific Java
version.
The version of Java that your server will run on. By default, only the Java versions
compatible with this version of Minecraft are shown. Some mods may require a
different Java version to work properly.
</span>
</div>
<div class="flex items-center gap-2">

View File

@ -4,44 +4,91 @@
class="experimental-styles-within relative mx-auto flex min-h-screen w-full max-w-[1280px] flex-col px-6"
>
<div
v-if="serverList.length > 0 || isPollingForNewServers"
class="relative flex h-fit w-full flex-col items-center justify-between md:flex-row"
v-if="hasError || fetchError"
class="mx-auto flex h-full min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 text-left"
>
<h1 class="w-full text-4xl font-bold text-contrast">Servers</h1>
<div class="mb-4 flex w-full flex-row items-center justify-end gap-2 md:mb-0 md:gap-4">
<div class="relative w-full text-sm md:w-72">
<label class="sr-only" for="search">Search</label>
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search"
v-model="searchInput"
class="w-full border-[1px] border-solid border-button-border pl-9"
type="search"
name="search"
autocomplete="off"
placeholder="Search servers..."
/>
<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-blue p-4">
<HammerIcon class="size-12 text-blue" />
</div>
<h1 class="m-0 w-fit text-3xl font-bold">Servers could not be loaded</h1>
</div>
<p class="text-lg text-secondary">We may have temporary issues with our servers.</p>
<ul class="m-0 list-disc space-y-4 p-0 pl-4 text-left text-sm leading-[170%]">
<li>
Our systems automatically alert our team when there's an issue. We are already working
on getting them back online.
</li>
<li>
If you recently purchased your Modrinth Server, it is currently in a queue and will
appear here as soon as it's ready. <br />
<span class="font-medium text-contrast"
>Do not attempt to purchase a new server.</span
>
</li>
<li>
If you require personalized support regarding the status of your server, please
contact Modrinth Support.
</li>
<li v-if="fetchError" class="text-red">
<p>Error details:</p>
<UiCopyCode
:text="(fetchError as PyroFetchError).message || 'Unknown error'"
:copyable="false"
:selectable="false"
:language="'json'"
/>
</li>
</ul>
</div>
<ButtonStyled type="standard">
<NuxtLink
class="!h-10 whitespace-pre !border-[1px] !border-solid !border-button-border text-sm !font-medium"
:to="{ path: '/servers', hash: '#plan' }"
>
<PlusIcon class="size-4" />
New server
</NuxtLink>
<ButtonStyled size="large" type="standard" color="brand">
<a class="mt-6 !w-full" href="https://support.modrinth.com">Contact Modrinth Support</a>
</ButtonStyled>
<ButtonStyled size="large" @click="() => reloadNuxtApp()">
<button class="mt-3 !w-full">Reload</button>
</ButtonStyled>
</div>
</div>
<LazyUiServersServerManageEmptyState
v-if="serverList.length === 0 && !isPollingForNewServers"
v-else-if="serverList.length === 0 && !isPollingForNewServers && !hasError"
/>
<template v-else>
<div class="relative flex h-fit w-full flex-col items-center justify-between md:flex-row">
<h1 class="w-full text-4xl font-bold text-contrast">Servers</h1>
<div class="mb-4 flex w-full flex-row items-center justify-end gap-2 md:mb-0 md:gap-4">
<div class="relative w-full text-sm md:w-72">
<label class="sr-only" for="search">Search</label>
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search"
v-model="searchInput"
class="w-full border-[1px] border-solid border-button-border pl-9"
type="search"
name="search"
autocomplete="off"
placeholder="Search servers..."
/>
</div>
<ButtonStyled type="standard">
<NuxtLink
class="!h-10 whitespace-pre !border-[1px] !border-solid !border-button-border text-sm !font-medium"
:to="{ path: '/servers', hash: '#plan' }"
>
<PlusIcon class="size-4" />
New server
</NuxtLink>
</ButtonStyled>
</div>
</div>
<ul v-if="filteredData.length > 0" class="m-0 flex flex-col gap-4 p-0">
<UiServersServerListing
v-for="server in filteredData"
@ -68,10 +115,12 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import Fuse from "fuse.js";
import { PlusIcon, SearchIcon } from "@modrinth/assets";
import { HammerIcon, PlusIcon, SearchIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { reloadNuxtApp } from "#app";
import type { PyroFetchError } from "~/composables/pyroFetch";
import type { Server } from "~/types/servers";
definePageMeta({
@ -87,21 +136,23 @@ interface ServerResponse {
}
const route = useRoute();
const hasError = ref(false);
const isPollingForNewServers = ref(false);
const { data: serverResponse, refresh } = await useAsyncData<ServerResponse>(
"ServerList",
async () => {
try {
const response = await usePyroFetch<{ servers: Server[] }>("servers");
return response;
} catch {
throw new PyroFetchError("Unable to load servers");
}
},
);
const {
data: serverResponse,
error: fetchError,
refresh,
} = await useAsyncData<ServerResponse>("ServerList", () => usePyroFetch<ServerResponse>("servers"));
const serverList = computed(() => serverResponse.value?.servers || []);
watch([fetchError, serverResponse], ([error, response]) => {
hasError.value = !!error || !response;
});
const serverList = computed(() => {
if (!serverResponse.value) return [];
return serverResponse.value.servers;
});
const searchInput = ref("");

View File

@ -1,11 +1,13 @@
import { createGlobalState } from "@vueuse/core";
import { type Ref, ref } from "vue";
import { type Ref, shallowRef } from "vue";
/**
* Maximum number of console output lines to store
* @type {number}
*/
const maxLines = 5000;
const maxLines = 10000;
const batchTimeout = 300; // ms
const initialBatchSize = 256;
/**
* Provides a global console output state management system
@ -21,7 +23,56 @@ export const usePyroConsole = createGlobalState(() => {
* Reactive array storing console output lines
* @type {Ref<string[]>}
*/
const output: Ref<string[]> = ref<string[]>([]);
const output: Ref<string[]> = shallowRef<string[]>([]);
const searchQuery: Ref<string> = shallowRef("");
const filteredOutput: Ref<string[]> = shallowRef([]);
let searchRegex: RegExp | null = null;
let lineBuffer: string[] = [];
let batchTimer: NodeJS.Timeout | null = null;
let isProcessingInitialBatch = false;
let refilterTimer: NodeJS.Timeout | null = null;
const refilterTimeout = 100; // ms
const updateFilter = () => {
if (!searchQuery.value) {
filteredOutput.value = [];
return;
}
if (!searchRegex) {
searchRegex = new RegExp(searchQuery.value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
}
filteredOutput.value = output.value.filter((line) => searchRegex?.test(line) ?? false);
};
const scheduleRefilter = () => {
if (refilterTimer) clearTimeout(refilterTimer);
refilterTimer = setTimeout(updateFilter, refilterTimeout);
};
const flushBuffer = () => {
if (lineBuffer.length === 0) return;
const processedLines = lineBuffer.flatMap((line) => line.split("\n").filter(Boolean));
if (isProcessingInitialBatch && processedLines.length >= initialBatchSize) {
isProcessingInitialBatch = false;
output.value = processedLines.slice(-maxLines);
} else {
const newOutput = [...output.value, ...processedLines];
output.value = newOutput.slice(-maxLines);
}
lineBuffer = [];
batchTimer = null;
if (searchQuery.value) {
scheduleRefilter();
}
};
/**
* Adds a new output line to the console output
@ -30,10 +81,10 @@ export const usePyroConsole = createGlobalState(() => {
* @param {string} line - The console output line to add
*/
const addLine = (line: string): void => {
output.value.push(line);
lineBuffer.push(line);
if (output.value.length > maxLines) {
output.value.shift();
if (!batchTimer) {
batchTimer = setTimeout(flushBuffer, batchTimeout);
}
};
@ -45,11 +96,29 @@ export const usePyroConsole = createGlobalState(() => {
* @returns {void}
*/
const addLines = (lines: string[]): void => {
output.value.push(...lines);
if (output.value.length > maxLines) {
output.value.splice(0, output.value.length - maxLines);
if (output.value.length === 0 && lines.length >= initialBatchSize) {
isProcessingInitialBatch = true;
lineBuffer = lines;
flushBuffer();
return;
}
lineBuffer.push(...lines);
if (!batchTimer) {
batchTimer = setTimeout(flushBuffer, batchTimeout);
}
};
/**
* Sets the search query and filters the output based on the query
*
* @param {string} query - The search query
*/
const setSearchQuery = (query: string): void => {
searchQuery.value = query;
searchRegex = null;
updateFilter();
};
/**
@ -57,12 +126,39 @@ export const usePyroConsole = createGlobalState(() => {
*/
const clear = (): void => {
output.value = [];
filteredOutput.value = [];
searchQuery.value = "";
lineBuffer = [];
isProcessingInitialBatch = false;
if (batchTimer) {
clearTimeout(batchTimer);
batchTimer = null;
}
if (refilterTimer) {
clearTimeout(refilterTimer);
refilterTimer = null;
}
searchRegex = null;
};
/**
* Finds the index of a line in the main output
*
* @param {string} line - The line to find
* @returns {number} The index of the line, or -1 if not found
*/
const findLineIndex = (line: string): number => {
return output.value.findIndex((l) => l === line);
};
return {
output,
searchQuery,
filteredOutput,
addLine,
addLines,
setSearchQuery,
clear,
findLineIndex,
};
});

View File

@ -1 +1 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12.1355 28.2805C12.1355 27.5892 12.1355 27.2435 12.1946 26.9561C12.437 25.7757 13.3896 24.853 14.6082 24.6182C14.905 24.561 15.2618 24.561 15.9755 24.561C16.6892 24.561 17.0461 24.561 17.3428 24.6182C18.5615 24.853 19.5141 25.7757 19.7565 26.9561C19.8155 27.2435 19.8155 27.5892 19.8155 28.2805V29.5203C19.8155 30.4473 19.8155 30.9109 19.6097 31.2561C19.4749 31.4823 19.281 31.6701 19.0475 31.8007C18.6911 32 18.2126 32 17.2555 32H14.6955C13.7385 32 13.26 32 12.9035 31.8007C12.67 31.6701 12.4761 31.4823 12.3413 31.2561C12.1355 30.9109 12.1355 30.4473 12.1355 29.5203V28.2805Z" fill="currentColor"></path><path d="M6.3827 32H9.01425C9.31219 32 9.46117 32 9.53801 31.9082C9.61485 31.8165 9.58266 31.6686 9.51829 31.3727C6.98423 19.7258 18.7021 22.3442 20.3986 14.286C21.2645 10.1733 18.7998 4.65319 18.9453 0.817638C18.9635 0.33689 18.9727 0.0965167 18.8384 0.0220137C18.7042 -0.0524892 18.5103 0.0670423 18.1226 0.306105C12.6454 3.68306 8.65384 10.1385 10.1126 14.0992C10.3356 14.7047 10.4472 15.0074 10.3061 15.118C10.1651 15.2285 9.92652 15.086 9.44929 14.801C8.67327 14.3375 7.75105 13.5788 7.10995 12.3833C6.92186 12.0326 6.82781 11.8572 6.68983 11.844C6.55184 11.8307 6.43328 11.9737 6.19616 12.2595C-0.695594 20.5682 2.0972 28.8655 6.07761 31.9001C6.14113 31.9485 6.17289 31.9727 6.2136 31.9863C6.2543 32 6.2971 32 6.3827 32Z" fill="currentColor"></path><path d="M23.5502 13.1095C23.903 16.9267 21.5318 19.5377 18.8234 21.3464C18.3506 21.6621 18.1142 21.82 18.1346 21.9704C18.155 22.1208 18.4376 22.2182 19.0027 22.413C22.614 23.6579 22.7781 27.2264 22.4791 31.4665C22.4614 31.7172 22.4526 31.8426 22.5285 31.9213C22.6044 32 22.7334 32 22.9913 32H25.2472C25.3155 32 25.3497 32 25.3828 31.9912C25.4159 31.9825 25.4452 31.9657 25.5039 31.9322C33.5906 27.3121 29.2489 16.2756 24.3156 12.6327C23.9559 12.3671 23.7761 12.2343 23.6299 12.3124C23.4838 12.3906 23.5059 12.6302 23.5502 13.1095Z" fill="currentColor"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 26" fill="none" width="24" height="24"><path fill="currentColor" d="M9.1 23v-1.1c.3-1 1.1-1.7 2.2-1.9h2.5c1.1.2 2 1 2.2 1.9v2c0 .8 0 1.2-.2 1.5 0 .2-.3.3-.5.4-.3.2-.7.2-1.6.2h-2.3c-.8 0-1.3 0-1.6-.2-.2 0-.4-.2-.5-.4-.2-.3-.2-.7-.2-1.4v-1Z"></path><path fill="currentColor" d="M4 26h2.8v-.5c-1.5-10.4 9-7.2 9.8-13.9C17 7 14 7 15.2.6V0l-.7.2C9.5 3 6 8.2 7.3 11.5c.2.4.3.7.2.8-.2 0-.4 0-.8-.3-.7-.4-1.5-1-2.1-2l-.4-.4-.4.4c-6.2 6.7-3.7 13.5-.1 16H4Z"></path><path fill="currentColor" d="M19.3 10.2c.3 3-2 6.2-4.5 7.6-.4.2-.6.3-.6.5l.8.3c3.4 1 4.4 4.3 3.3 7v.3l.5.1H21c8-4.6 5.2-12.4-1-16.2l-.7-.3c-.2.1-.2.3-.1.7Z"></path></svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 681 B

View File

@ -216,11 +216,11 @@ export const MastodonIcon = _MastodonIcon
export const OpenCollectiveIcon = _OpenCollectiveIcon
export const PatreonIcon = _PatreonIcon
export const PayPalIcon = _PayPalIcon
export const PyroIcon = _PyroIcon
export const RedditIcon = _RedditIcon
export const TwitterIcon = _TwitterIcon
export const WindowsIcon = _WindowsIcon
export const YouTubeIcon = _YouTubeIcon
export const PyroIcon = _PyroIcon
export const AlignLeftIcon = _AlignLeftIcon
export const ArchiveIcon = _ArchiveIcon
export const ArrowBigUpDashIcon = _ArrowBigUpDashIcon